53 Commits

Author SHA1 Message Date
yohlo
e67f6b073c ready for regionals
All checks were successful
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m46s
2026-03-07 01:22:21 -06:00
569ea8833b Merge pull request 'center' (#10) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m28s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 49s
Reviewed-on: #10
2026-03-02 01:13:07 -06:00
yohlo
74d83da466 center
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m12s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 43s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
2026-03-02 01:12:42 -06:00
aff5fa2ea4 Merge pull request 'more pwa' (#9) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m10s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 51s
Reviewed-on: #9
2026-03-02 01:01:17 -06:00
yohlo
299f32a2b7 more pwa
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m51s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-03-02 01:00:46 -06:00
3f125e5761 Merge pull request 'pwa' (#8) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m28s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
Reviewed-on: #8
2026-03-02 00:49:50 -06:00
yohlo
fb4f50b851 pwa
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m41s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-03-02 00:49:18 -06:00
5305dc37e7 Merge pull request 'center text/whitespace prewrap desc' (#7) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m42s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 47s
Reviewed-on: #7
2026-03-02 00:00:16 -06:00
yohlo
3e5ef04c9f center text/whitespace prewrap desc
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m59s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 50s
2026-03-01 23:59:50 -06:00
e51ff24944 Merge pull request 'fix' (#6) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m31s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 53s
Reviewed-on: #6
2026-03-01 21:47:34 -06:00
yohlo
c138442530 fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m0s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 45s
2026-03-01 21:46:59 -06:00
3baec5ac0f Merge pull request 'regionals' (#5) from development into main
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 2m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
Reviewed-on: #5
2026-03-01 21:23:52 -06:00
yohlo
8f84dddc64 fix team avatars
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 2m39s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 16s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-03-01 20:59:31 -06:00
yohlo
9571980898 fix team avatars 2026-03-01 20:20:24 -06:00
yohlo
1ce8a6a982 fix team avatars 2026-03-01 20:10:06 -06:00
yohlo
f0d8297f34 new badges 2026-03-01 19:58:18 -06:00
yohlo
f49234702c regionals on tournament profile 2026-03-01 19:49:46 -06:00
yohlo
f6da7be404 tiebreakers 2026-03-01 19:43:29 -06:00
yohlo
c7487e37f2 tiebreakers 2026-03-01 19:41:34 -06:00
yohlo
873ca3e4c4 cross mix knockout seeds 2026-03-01 19:36:26 -06:00
yohlo
c5b3ad50ca fixed b2b 2026-03-01 18:04:45 -06:00
yohlo
938418cec5 fixed b2b 2026-03-01 17:52:47 -06:00
yohlo
5d6136a445 new badges 2026-03-01 17:04:06 -06:00
yohlo
9a64f316b9 b2b 2026-03-01 16:30:49 -06:00
yohlo
6199afc687 regionals 2026-03-01 16:21:27 -06:00
yohlo
f83a7d69c8 groups init 2026-02-25 19:54:51 -06:00
yohlo
2dd3e5b170 enrollment polish 2026-02-21 23:30:58 -06:00
yohlo
b9e16e2b64 regionals enrollments 2026-02-21 23:12:21 -06:00
yohlo
7f60b4d200 idk 2026-02-14 15:46:31 -06:00
yohlo
6e9e014fcc cookie and pwa stuff 2026-02-14 12:59:01 -06:00
a54a74d7de Merge pull request 'development' (#4) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 53s
Reviewed-on: #4
2026-02-10 14:03:25 -06:00
yohlo
236fcda671 session fixes
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m52s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-02-09 23:53:54 -06:00
yohlo
9ed054e5d0 regionals on tournament form 2026-02-09 23:44:07 -06:00
yohlo
63853f22de regional teams 2026-02-09 23:36:04 -06:00
yohlo
5dd41d8022 fix scroll bug 2026-02-09 22:20:00 -06:00
yohlo
937758bd49 facehash avatars 2026-02-09 14:31:55 -06:00
7b95998b05 Merge pull request 'new prod env' (#3) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m43s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 46s
Reviewed-on: #3
2026-02-09 12:59:07 -06:00
yohlo
f069ba3827 include supertokens api key
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m5s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 49s
2026-02-09 12:14:38 -06:00
yohlo
70a032db20 more env stuff
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m18s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 12:07:45 -06:00
yohlo
243fb094e1 env
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m19s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-02-09 01:03:30 -06:00
yohlo
84202cdbe9 hopefully fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 00:46:36 -06:00
yohlo
6370ebe48a hopefully fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m15s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 43s
2026-02-09 00:42:44 -06:00
yohlo
8b271f93ac hopefully fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m15s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 00:37:01 -06:00
yohlo
2326693bfb hopefully fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 43s
2026-02-09 00:06:47 -06:00
yohlo
b209bbf4ef copy server.ts
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 58s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m12s
2026-02-08 23:30:51 -06:00
yohlo
c4bf13744c fix migrations?
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m45s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m9s
2026-02-08 23:21:21 -06:00
yohlo
1e3eaf0c35 server.ts 2026-02-08 23:19:48 -06:00
yohlo
d4b52e762b dist
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m47s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m9s
2026-02-08 23:05:31 -06:00
yohlo
a4e618f327 fix
Some checks failed
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Failing after 16m45s
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 19m23s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been cancelled
2026-02-08 22:32:54 -06:00
yohlo
d1951afb3c testing cicd
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m30s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-02-08 16:01:21 -06:00
yohlo
dce31905fc fixes 2025-10-18 23:09:57 -05:00
yohlo
e58ed86d3b fixes 2025-10-17 19:22:07 -05:00
yohlo
d833e5f1a1 Merge branch 'caro/badges-stats' 2025-10-16 15:41:36 -05:00
130 changed files with 6887 additions and 1062 deletions

143
.gitea/workflows/ci-cd.yaml Normal file
View File

@@ -0,0 +1,143 @@
name: CI/CD Pipeline
on:
push:
branches:
- development
- main
jobs:
build-app:
name: Build and Push App Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set environment variables
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
echo "ENVIRONMENT=prod" >> $GITHUB_ENV
else
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
echo "ENVIRONMENT=dev" >> $GITHUB_ENV
fi
- name: Build and push app Docker image
run: |
docker login git.yohler.net -u ${{ github.actor }} -p ${{ secrets.PACKAGES_TOKEN }}
docker build \
-f Dockerfile \
-t git.yohler.net/kyle/flxn-app:${{ env.DOCKER_TAG }} \
-t git.yohler.net/kyle/flxn-app:${{ github.sha }} \
.
docker push git.yohler.net/kyle/flxn-app:${{ env.DOCKER_TAG }}
docker push git.yohler.net/kyle/flxn-app:${{ github.sha }}
build-pocketbase:
name: Build and Push PocketBase Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if PocketBase Dockerfile or migrations changed
id: check_changes
run: |
if [ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ] || ! git cat-file -e ${{ github.event.before }} 2>/dev/null; then
echo "changed=true" >> $GITHUB_OUTPUT
elif git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -qE "(Dockerfile.pocketbase|pb_migrations/)"; then
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
fi
- name: Build and push PocketBase Docker image
if: steps.check_changes.outputs.changed == 'true' || github.event.before == '0000000000000000000000000000000000000000'
run: |
docker login git.yohler.net -u ${{ github.actor }} -p ${{ secrets.PACKAGES_TOKEN }}
docker build \
-f Dockerfile.pocketbase \
-t git.yohler.net/kyle/flxn-pocketbase:latest \
-t git.yohler.net/kyle/flxn-pocketbase:${{ github.sha }} \
.
docker push git.yohler.net/kyle/flxn-pocketbase:latest
docker push git.yohler.net/kyle/flxn-pocketbase:${{ github.sha }}
deploy:
name: Deploy to Kubernetes
needs: [build-app, build-pocketbase]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set environment variables
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "ENVIRONMENT=prod" >> $GITHUB_ENV
echo "NAMESPACE=flxn-prod" >> $GITHUB_ENV
else
echo "ENVIRONMENT=dev" >> $GITHUB_ENV
echo "NAMESPACE=flxn-dev" >> $GITHUB_ENV
fi
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
mv kubectl /usr/local/bin/
- name: Configure kubectl
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config
chmod 600 $HOME/.kube/config
kubectl config set-cluster local --insecure-skip-tls-verify=true
- name: Verify kubectl access
run: |
kubectl version --client
kubectl get nodes
- name: Deploy shared services (SuperTokens)
run: |
kubectl apply -k k8s/overlays/shared/
- name: Deploy to ${{ env.ENVIRONMENT }}
run: |
kubectl apply -k k8s/overlays/${{ env.ENVIRONMENT }}/
- name: Force rollout to pull latest image
run: |
kubectl rollout restart deployment/flxn-app -n ${{ env.NAMESPACE }}
kubectl rollout restart deployment/flxn-pocketbase -n ${{ env.NAMESPACE }}
- name: Wait for rollout
run: |
kubectl rollout status deployment/flxn-app -n ${{ env.NAMESPACE }} --timeout=5m
kubectl rollout status deployment/flxn-pocketbase -n ${{ env.NAMESPACE }} --timeout=5m
- name: Verify deployment
run: |
kubectl get pods -n ${{ env.NAMESPACE }} -l app=flxn
kubectl get svc -n ${{ env.NAMESPACE }} -l app=flxn
kubectl get ingress -n ${{ env.NAMESPACE }}
- name: Check app health
run: |
sleep 15
APP_POD=$(kubectl get pod -n ${{ env.NAMESPACE }} -l component=app -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n ${{ env.NAMESPACE }} $APP_POD -- wget -O- http://localhost:3000/api/health || echo "Health check failed (endpoint may not exist yet)"
- name: Check PocketBase health
run: |
PB_POD=$(kubectl get pod -n ${{ env.NAMESPACE }} -l component=pocketbase -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n ${{ env.NAMESPACE }} $PB_POD -- wget -O- http://localhost:8090/api/health || echo "PocketBase health check completed"

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lockb* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/server.ts ./server.ts
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
ENV NITRO_PORT=3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
CMD ["bun", "run", "server.ts"]

View File

@@ -1,16 +1,23 @@
FROM alpine:latest
ARG PB_VERSION=0.29.2
ARG PB_VERSION=0.26.5
RUN apk add --no-cache \
unzip \
ca-certificates
# download and unzip PocketBase
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
RUN unzip /tmp/pb.zip -d /pb/ && \
rm /tmp/pb.zip && \
chmod +x /pb/pocketbase
RUN mkdir -p /pb/pb_data
COPY pb_migrations /pb/pb_migrations
EXPOSE 8090
# start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]

Binary file not shown.

1130
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@ services:
- .env.docker
volumes:
- postgres-data:/var/lib/postgresql/data
- ./.docker-postgres-init:/docker-entrypoint-initdb.d
networks:
- app-network

View File

@@ -0,0 +1,129 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: flxn-app
labels:
app: flxn
component: app
spec:
replicas: 1
selector:
matchLabels:
app: flxn
component: app
template:
metadata:
labels:
app: flxn
component: app
spec:
containers:
- name: flxn-app
image: git.yohler.net/kyle/flxn-app:latest
imagePullPolicy: Always
ports:
- containerPort: 3000
name: http
protocol: TCP
env:
- name: VITE_API_DOMAIN
valueFrom:
configMapKeyRef:
name: flxn-config
key: vite_api_domain
- name: VITE_WEBSITE_DOMAIN
valueFrom:
configMapKeyRef:
name: flxn-config
key: vite_website_domain
- name: SUPERTOKENS_URI
valueFrom:
configMapKeyRef:
name: flxn-config
key: supertokens_uri
- name: POCKETBASE_URL
valueFrom:
configMapKeyRef:
name: flxn-config
key: pocketbase_url
- name: SUPERTOKENS_API_KEY
valueFrom:
secretKeyRef:
name: flxn-secrets
key: supertokens_api_key
- name: PORT
value: "3000"
- name: NODE_ENV
value: "production"
- name: TWILIO_ACCOUNT_SID
valueFrom:
secretKeyRef:
name: flxn-secrets
key: twilio_account_sid
- name: TWILIO_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: flxn-secrets
key: twilio_auth_token
- name: TWILIO_SERVICE_SID
valueFrom:
secretKeyRef:
name: flxn-secrets
key: twilio_service_sid
- name: POCKETBASE_ADMIN_EMAIL
valueFrom:
secretKeyRef:
name: flxn-secrets
key: pocketbase_admin_email
- name: POCKETBASE_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: flxn-secrets
key: pocketbase_admin_password
- name: VITE_SPOTIFY_CLIENT_ID
valueFrom:
configMapKeyRef:
name: flxn-config
key: vite_spotify_client_id
- name: SPOTIFY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: flxn-secrets
key: spotify_client_secret
- name: VITE_SPOTIFY_REDIRECT_URI
valueFrom:
configMapKeyRef:
name: flxn-config
key: vite_spotify_redirect_uri
- name: COOKIE_DOMAIN
valueFrom:
configMapKeyRef:
name: flxn-config
key: cookie_domain
resources:
requests:
memory: "768Mi"
cpu: "250m"
limits:
memory: "1536Mi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3

17
k8s/base/app-service.yaml Normal file
View File

@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: flxn-app
labels:
app: flxn
component: app
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: 3000
protocol: TCP
name: http
selector:
app: flxn
component: app

View File

@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- app-deployment.yaml
- app-service.yaml
- pocketbase-deployment.yaml
- pocketbase-service.yaml
- pb-data-pvc.yaml
commonLabels:
app: flxn

13
k8s/base/pb-data-pvc.yaml Normal file
View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: flxn-pb-data
labels:
app: flxn
component: pocketbase
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi

View File

@@ -0,0 +1,57 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: flxn-pocketbase
labels:
app: flxn
component: pocketbase
spec:
replicas: 1
selector:
matchLabels:
app: flxn
component: pocketbase
template:
metadata:
labels:
app: flxn
component: pocketbase
spec:
containers:
- name: pocketbase
image: git.yohler.net/kyle/flxn-pocketbase:latest
imagePullPolicy: Always
ports:
- containerPort: 8090
name: http
protocol: TCP
volumeMounts:
- name: pb-data
mountPath: /pb/pb_data
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /api/health
port: 8090
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 8090
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: pb-data
persistentVolumeClaim:
claimName: flxn-pb-data

View File

@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: flxn-pocketbase
labels:
app: flxn
component: pocketbase
spec:
type: NodePort
ports:
- port: 8090
targetPort: 8090
nodePort: 30090
protocol: TCP
name: http
selector:
app: flxn
component: pocketbase

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: flxn-config
namespace: flxn-dev
data:
vite_api_domain: "https://dev.flexxon.app"
vite_website_domain: "https://dev.flexxon.app"
supertokens_uri: "http://192.168.0.50:30568"
pocketbase_url: "http://192.168.0.50:30096"
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-dev"
cookie_domain: "dev.flexxon.app"

View File

@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: flxn-app
namespace: flxn-dev
spec:
rules:
- host: dev.flexxon.app
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: flxn-app
port:
number: 3000

View File

@@ -0,0 +1,50 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: flxn-dev
resources:
- namespace.yaml
- ../../base
- configmap.yaml
- ingress.yaml
images:
- name: git.yohler.net/kyle/flxn-app
newTag: dev
- name: git.yohler.net/kyle/flxn-pocketbase
newTag: latest
commonLabels:
environment: dev
patches:
- patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: "768Mi"
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: "1536Mi"
target:
kind: Deployment
name: flxn-app
- patch: |-
- op: replace
path: /spec/type
value: NodePort
- op: add
path: /spec/ports/0/nodePort
value: 30083
target:
kind: Service
name: flxn-app
- patch: |-
- op: replace
path: /spec/ports/0/nodePort
value: 30096
target:
kind: Service
name: flxn-pocketbase

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: flxn-dev

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: flxn-config
namespace: flxn-prod
data:
vite_api_domain: "https://flexxon.app"
vite_website_domain: "https://flexxon.app"
supertokens_uri: "http://192.168.0.50:30568"
pocketbase_url: "http://192.168.0.50:30097"
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
s3_endpoint: "https://s3.yohler.net"
s3_bucket: "flxn-prod"
cookie_domain: "flexxon.app"

View File

@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: flxn-app
namespace: flxn-prod
spec:
rules:
- host: flexxon.app
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: flxn-app
port:
number: 3000

View File

@@ -0,0 +1,50 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: flxn-prod
resources:
- namespace.yaml
- ../../base
- configmap.yaml
- ingress.yaml
images:
- name: git.yohler.net/kyle/flxn-app
newTag: latest
- name: git.yohler.net/kyle/flxn-pocketbase
newTag: latest
commonLabels:
environment: prod
patches:
- patch: |-
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: "1536Mi"
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: "3Gi"
target:
kind: Deployment
name: flxn-app
- patch: |-
- op: replace
path: /spec/type
value: NodePort
- op: add
path: /spec/ports/0/nodePort
value: 30084
target:
kind: Service
name: flxn-app
- patch: |-
- op: replace
path: /spec/ports/0/nodePort
value: 30097
target:
kind: Service
name: flxn-pocketbase

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: flxn-prod

View File

@@ -0,0 +1,5 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: supertokens-config
namespace: flxn-shared

View File

@@ -0,0 +1,16 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: flxn-shared
resources:
- namespace.yaml
- supertokens-deployment.yaml
- supertokens-service.yaml
- supertokens-postgres-deployment.yaml
- supertokens-postgres-service.yaml
- supertokens-db-pvc.yaml
labels:
- pairs:
environment: shared

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: flxn-shared

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: supertokens-db-data
labels:
app: flxn
component: supertokens-db
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi

View File

@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: supertokens
labels:
app: flxn
component: supertokens
spec:
replicas: 1
selector:
matchLabels:
app: flxn
component: supertokens
template:
metadata:
labels:
app: flxn
component: supertokens
spec:
enableServiceLinks: false
containers:
- name: supertokens
image: registry.supertokens.io/supertokens/supertokens-postgresql:latest
ports:
- containerPort: 3567
name: http
protocol: TCP
env:
- name: PORT
value: "3567"
- name: POSTGRESQL_USER
value: supertokens
- name: POSTGRESQL_PASSWORD
valueFrom:
secretKeyRef:
name: supertokens-secrets
key: postgres_password
- name: POSTGRESQL_HOST
value: supertokens-db
- name: POSTGRESQL_PORT
value: "5432"
- name: POSTGRESQL_DATABASE_NAME
value: supertokens
- name: API_KEYS
valueFrom:
secretKeyRef:
name: supertokens-secrets
key: api_keys
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /hello
port: 3567
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /hello
port: 3567
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3

View File

@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: supertokens-db
labels:
app: flxn
component: supertokens-db
spec:
replicas: 1
selector:
matchLabels:
app: flxn
component: supertokens-db
template:
metadata:
labels:
app: flxn
component: supertokens-db
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
protocol: TCP
env:
- name: POSTGRES_USER
value: "supertokens"
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: supertokens-secrets
key: postgres_password
- name: POSTGRES_DB
value: "supertokens"
- name: PGDATA
value: "/var/lib/postgresql/data/pgdata"
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "128Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
exec:
command:
- pg_isready
- -U
- supertokens
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
exec:
command:
- pg_isready
- -U
- supertokens
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: supertokens-db-data

View File

@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: supertokens-db
labels:
app: flxn
component: supertokens-db
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
protocol: TCP
name: postgres
selector:
app: flxn
component: supertokens-db

View File

@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: supertokens
labels:
app: flxn
component: supertokens
spec:
type: NodePort
ports:
- port: 3567
targetPort: 3567
nodePort: 30568
protocol: TCP
name: http
selector:
app: flxn
component: supertokens

View File

@@ -6,8 +6,7 @@
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit",
"start": "bun run .output/server/index.mjs",
"start:node": "node .output/server/index.mjs"
"start": "bun run server.ts"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
@@ -23,10 +22,10 @@
"@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "^1.130.12",
"@tanstack/react-router-devtools": "^1.130.13",
"@tanstack/react-router-with-query": "^1.130.12",
"@tanstack/react-start": "^1.132.2",
"@tanstack/react-router": "^1.143.6",
"@tanstack/react-router-devtools": "^1.143.6",
"@tanstack/react-router-ssr-query": "^1.143.6",
"@tanstack/react-start": "^1.143.6",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3",
@@ -36,6 +35,7 @@
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
"facehash": "^0.0.7",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",
@@ -57,7 +57,6 @@
"zustand": "^5.0.7"
},
"devDependencies": {
"@tanstack/react-router-ssr-query": "^1.132.2",
"@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4",
"@types/pg": "^8.15.5",
@@ -72,6 +71,8 @@
"tsx": "^4.20.3",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^5.1.4",
"workbox-window": "^7.4.0"
}
}

View File

@@ -1,8 +1,12 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_4251874343");
return app.delete(collection);
try {
const collection = app.findCollectionByNameOrId("pbc_4251874343");
return app.delete(collection);
} catch (e) {
console.log("Collection pbc_4251874343 not found, skipping deletion");
return null;
}
}, (app) => {
const collection = new Collection({
"createRule": null,

View File

@@ -0,0 +1,109 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation3177167065",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number4113142680",
"max": null,
"min": null,
"name": "order",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1568971955",
"hidden": false,
"id": "relation2529305176",
"maxSelect": 999,
"minSelect": 0,
"name": "teams",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_3346940990",
"indexes": [],
"listRule": null,
"name": "groups",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3346940990");
return app.delete(collection);
})

View File

@@ -0,0 +1,52 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("select3736761055")
// add field
collection.fields.addAt(13, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text3736761055",
"max": 0,
"min": 0,
"name": "format",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(13, new Field({
"hidden": false,
"id": "select3736761055",
"maxSelect": 1,
"name": "format",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"single_elim",
"double_elim",
"groups",
"swiss",
"swiss_bracket",
"round_robin"
]
}))
// remove field
collection.fields.removeById("text3736761055")
return app.save(collection)
})

View File

@@ -0,0 +1,25 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(14, new Field({
"hidden": false,
"id": "json118290348",
"maxSize": 0,
"name": "group_config",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("json118290348")
return app.save(collection)
})

View File

@@ -0,0 +1,29 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(15, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text2982008523",
"max": 0,
"min": 0,
"name": "phase",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("text2982008523")
return app.save(collection)
})

View File

@@ -0,0 +1,47 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2541054544")
// add field
collection.fields.addAt(22, new Field({
"cascadeDelete": false,
"collectionId": "pbc_3346940990",
"hidden": false,
"id": "relation1841317061",
"maxSelect": 1,
"minSelect": 0,
"name": "group",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
// add field
collection.fields.addAt(23, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text3987859035",
"max": 0,
"min": 0,
"name": "match_type",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2541054544")
// remove field
collection.fields.removeById("relation1841317061")
// remove field
collection.fields.removeById("text3987859035")
return app.save(collection)
})

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
public/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
public/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -1,17 +1,28 @@
{
"name": "FLXN IX",
"short_name": "FLXN",
"name": "FLXN",
"description": "Amicus meus madidus",
"icons": [
{
"src": "/favicon.png",
"src": "/icon-192x192.png",
"type": "image/png",
"sizes": "192x192",
"type": "image/png"
"purpose": "any maskable"
},
{
"src": "/favicon.png",
"src": "/icon-512x512.png",
"type": "image/png",
"sizes": "512x512",
"type": "image/png"
"purpose": "any maskable"
}
],
"display": "standalone"
"start_url": "/",
"display": "standalone",
"theme_color": "#1e293b",
"background_color": "#0f172a",
"orientation": "portrait-primary",
"scope": "/",
"categories": ["games", "social", "beer pong"],
"prefer_related_applications": false,
"shortcuts": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

407
server.ts
View File

@@ -16,73 +16,133 @@
* - Server port number
* - Default: 3000
*
* STATIC_PRELOAD_MAX_BYTES (number)
* ASSET_PRELOAD_MAX_SIZE (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)
* - Example: ASSET_PRELOAD_MAX_SIZE=5242880 (5MB)
*
* STATIC_PRELOAD_INCLUDE (string)
* ASSET_PRELOAD_INCLUDE_PATTERNS (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"
* - Example: ASSET_PRELOAD_INCLUDE_PATTERNS="*.js,*.css,*.woff2"
*
* STATIC_PRELOAD_EXCLUDE (string)
* ASSET_PRELOAD_EXCLUDE_PATTERNS (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"
* - Example: ASSET_PRELOAD_EXCLUDE_PATTERNS="*.map,*.txt"
*
* STATIC_PRELOAD_VERBOSE (boolean)
* ASSET_PRELOAD_VERBOSE_LOGGING (boolean)
* - Enable detailed logging of loaded and skipped files
* - Default: false
* - Set to "true" to enable verbose output
*
* ASSET_PRELOAD_ENABLE_ETAG (boolean)
* - Enable ETag generation for preloaded assets
* - Default: true
* - Set to "false" to disable ETag support
*
* ASSET_PRELOAD_ENABLE_GZIP (boolean)
* - Enable Gzip compression for eligible assets
* - Default: true
* - Set to "false" to disable Gzip compression
*
* ASSET_PRELOAD_GZIP_MIN_SIZE (number)
* - Minimum file size in bytes required for Gzip compression
* - Files smaller than this will not be compressed
* - Default: 1024 (1KB)
*
* ASSET_PRELOAD_GZIP_MIME_TYPES (string)
* - Comma-separated list of MIME types eligible for Gzip compression
* - Supports partial matching for types ending with "/"
* - Default: text/,application/javascript,application/json,application/xml,image/svg+xml
*
* Usage:
* bun run server.ts
*/
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
import path from 'node:path'
// Configuration
const PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIR = './dist/client'
const SERVER_ENTRY = './dist/server/server.js'
const SERVER_PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIRECTORY = './dist/client'
const SERVER_ENTRY_POINT = './dist/server/server.js'
// Logging utilities for professional output
const log = {
info: (message: string) => {
console.log(`[INFO] ${message}`)
},
success: (message: string) => {
console.log(`[SUCCESS] ${message}`)
},
warning: (message: string) => {
console.log(`[WARNING] ${message}`)
},
error: (message: string) => {
console.log(`[ERROR] ${message}`)
},
header: (message: string) => {
console.log(`\n${message}\n`)
},
}
// Preloading configuration from environment variables
const MAX_PRELOAD_BYTES = Number(
process.env.STATIC_PRELOAD_MAX_BYTES ?? 5 * 1024 * 1024, // 5MB default
process.env.ASSET_PRELOAD_MAX_SIZE ?? 5 * 1024 * 1024, // 5MB default
)
// Parse comma-separated include patterns (no defaults)
const INCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_INCLUDE ?? '')
const INCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(globToRegExp)
.map((pattern: string) => convertGlobToRegExp(pattern))
// Parse comma-separated exclude patterns (no defaults)
const EXCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_EXCLUDE ?? '')
const EXCLUDE_PATTERNS = (process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(globToRegExp)
.map((pattern: string) => convertGlobToRegExp(pattern))
// Verbose logging flag
const VERBOSE = process.env.STATIC_PRELOAD_VERBOSE === 'true'
const VERBOSE = process.env.ASSET_PRELOAD_VERBOSE_LOGGING === 'true'
// Optional ETag feature
const ENABLE_ETAG = (process.env.ASSET_PRELOAD_ENABLE_ETAG ?? 'true') === 'true'
// Optional Gzip feature
const ENABLE_GZIP = (process.env.ASSET_PRELOAD_ENABLE_GZIP ?? 'true') === 'true'
const GZIP_MIN_BYTES = Number(process.env.ASSET_PRELOAD_GZIP_MIN_SIZE ?? 1024) // 1KB
const GZIP_TYPES = (
process.env.ASSET_PRELOAD_GZIP_MIME_TYPES ??
'text/,application/javascript,application/json,application/xml,image/svg+xml'
)
.split(',')
.map((v) => v.trim())
.filter(Boolean)
/**
* Convert a simple glob pattern to a regular expression
* Supports * wildcard for matching any characters
*/
function globToRegExp(glob: string): RegExp {
function convertGlobToRegExp(globPattern: string): RegExp {
// Escape regex special chars except *, then replace * with .*
const escaped = glob
const escapedPattern = globPattern
.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
.replace(/\*/g, '.*')
return new RegExp(`^${escaped}$`, 'i')
return new RegExp(`^${escapedPattern}$`, 'i')
}
/**
* Compute ETag for a given data buffer
*/
function computeEtag(data: Uint8Array): string {
const hash = Bun.hash(data)
return `W/"${hash.toString(16)}-${data.byteLength.toString()}"`
}
/**
@@ -95,18 +155,30 @@ interface AssetMetadata {
}
/**
* Result of static asset preloading process
* In-memory asset with ETag and Gzip support
*/
interface PreloadResult {
routes: Record<string, () => Response>
loaded: Array<AssetMetadata>
skipped: Array<AssetMetadata>
interface InMemoryAsset {
raw: Uint8Array
gz?: Uint8Array
etag?: string
type: string
immutable: boolean
size: number
}
/**
* Check if a file should be included based on configured patterns
* Result of static asset preloading process
*/
function shouldInclude(relativePath: string): boolean {
interface PreloadResult {
routes: Record<string, (req: Request) => Response | Promise<Response>>
loaded: AssetMetadata[]
skipped: AssetMetadata[]
}
/**
* Check if a file is eligible for preloading based on configured patterns
*/
function isFileEligibleForPreloading(relativePath: string): boolean {
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath
// If include patterns are specified, file must match at least one
@@ -125,38 +197,122 @@ function shouldInclude(relativePath: string): boolean {
}
/**
* Build static routes with intelligent preloading strategy
* Check if a MIME type is compressible
*/
function isMimeTypeCompressible(mimeType: string): boolean {
return GZIP_TYPES.some((type) =>
type.endsWith('/') ? mimeType.startsWith(type) : mimeType === type,
)
}
/**
* Conditionally compress data based on size and MIME type
*/
function compressDataIfAppropriate(
data: Uint8Array,
mimeType: string,
): Uint8Array | undefined {
if (!ENABLE_GZIP) return undefined
if (data.byteLength < GZIP_MIN_BYTES) return undefined
if (!isMimeTypeCompressible(mimeType)) return undefined
try {
return Bun.gzipSync(data.buffer as ArrayBuffer)
} catch {
return undefined
}
}
/**
* Create response handler function with ETag and Gzip support
*/
function createResponseHandler(
asset: InMemoryAsset,
): (req: Request) => Response {
return (req: Request) => {
const headers: Record<string, string> = {
'Content-Type': asset.type,
'Cache-Control': asset.immutable
? 'public, max-age=31536000, immutable'
: 'public, max-age=3600',
}
if (ENABLE_ETAG && asset.etag) {
const ifNone = req.headers.get('if-none-match')
if (ifNone && ifNone === asset.etag) {
return new Response(null, {
status: 304,
headers: { ETag: asset.etag },
})
}
headers.ETag = asset.etag
}
if (
ENABLE_GZIP &&
asset.gz &&
req.headers.get('accept-encoding')?.includes('gzip')
) {
headers['Content-Encoding'] = 'gzip'
headers['Content-Length'] = String(asset.gz.byteLength)
const gzCopy = new Uint8Array(asset.gz)
return new Response(gzCopy, { status: 200, headers })
}
headers['Content-Length'] = String(asset.raw.byteLength)
const rawCopy = new Uint8Array(asset.raw)
return new Response(rawCopy, { status: 200, headers })
}
}
/**
* Create composite glob pattern from include patterns
*/
function createCompositeGlobPattern(): Bun.Glob {
const raw = (process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
if (raw.length === 0) return new Bun.Glob('**/*')
if (raw.length === 1) return new Bun.Glob(raw[0])
return new Bun.Glob(`{${raw.join(',')}}`)
}
/**
* Initialize 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> = []
async function initializeStaticRoutes(
clientDirectory: string,
): Promise<PreloadResult> {
const routes: Record<string, (req: Request) => Response | Promise<Response>> =
{}
const loaded: AssetMetadata[] = []
const skipped: 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) {
log.info(`Loading static assets from ${clientDirectory}...`)
if (VERBOSE) {
console.log(
` Include patterns: ${process.env.STATIC_PRELOAD_INCLUDE ?? ''}`,
)
}
if (EXCLUDE_PATTERNS.length > 0) {
console.log(
` Exclude patterns: ${process.env.STATIC_PRELOAD_EXCLUDE ?? ''}`,
`Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
)
if (INCLUDE_PATTERNS.length > 0) {
console.log(
`Include patterns: ${process.env.ASSET_PRELOAD_INCLUDE_PATTERNS ?? ''}`,
)
}
if (EXCLUDE_PATTERNS.length > 0) {
console.log(
`Exclude patterns: ${process.env.ASSET_PRELOAD_EXCLUDE_PATTERNS ?? ''}`,
)
}
}
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
const glob = createCompositeGlobPattern()
for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
const filepath = path.join(clientDirectory, relativePath)
const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`
try {
// Get file metadata
@@ -174,20 +330,23 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
}
// Determine if file should be preloaded
const matchesPattern = shouldInclude(relativePath)
const matchesPattern = isFileEligibleForPreloading(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',
},
})
// Preload small files into memory with ETag and Gzip support
const bytes = new Uint8Array(await file.arrayBuffer())
const gz = compressDataIfAppropriate(bytes, metadata.type)
const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined
const asset: InMemoryAsset = {
raw: bytes,
gz,
etag,
type: metadata.type,
immutable: true,
size: bytes.byteLength,
}
routes[route] = createResponseHandler(asset)
loaded.push({ ...metadata, size: bytes.byteLength })
totalPreloadedBytes += bytes.byteLength
@@ -207,13 +366,13 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
}
} catch (error: unknown) {
if (error instanceof Error && error.name !== 'EISDIR') {
console.error(`Failed to load ${filepath}:`, error)
log.error(`Failed to load ${filepath}: ${error.message}`)
}
}
}
// Always show file overview in Vite-like format first
if (loaded.length > 0 || skipped.length > 0) {
// Show detailed file overview only when verbose mode is enabled
if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route),
)
@@ -224,124 +383,162 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
60,
)
// Format file size with KB and gzip estimation
const formatFileSize = (bytes: number) => {
// Format file size with KB and actual gzip size
const formatFileSize = (bytes: number, gzBytes?: number) => {
const kb = bytes / 1024
// Rough gzip estimation (typically 30-70% compression)
const sizeStr = kb < 100 ? kb.toFixed(2) : kb.toFixed(1)
if (gzBytes !== undefined) {
const gzKb = gzBytes / 1024
const gzStr = gzKb < 100 ? gzKb.toFixed(2) : gzKb.toFixed(1)
return {
size: sizeStr,
gzip: gzStr,
}
}
// Rough gzip estimation (typically 30-70% compression) if no actual gzip data
const gzipKb = kb * 0.35
return {
size: kb < 100 ? kb.toFixed(2) : kb.toFixed(1),
size: sizeStr,
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
}
}
if (loaded.length > 0) {
console.log('\n📁 Preloaded into memory:')
console.log(
'Path │ Size │ Gzip Size',
)
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}`)
const gzipStr = `${gzip.padStart(7)} kB`
console.log(`${paddedPath} ${sizeStr} ${gzipStr}`)
})
}
if (skipped.length > 0) {
console.log('\n💾 Served on-demand:')
console.log(
'Path │ Size │ Gzip Size',
)
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}`)
const gzipStr = `${gzip.padStart(7)} kB`
console.log(`${paddedPath} ${sizeStr} ${gzipStr}`)
})
}
}
// Show detailed verbose info if enabled
if (VERBOSE) {
// Show detailed verbose info if enabled
if (VERBOSE) {
if (loaded.length > 0 || skipped.length > 0) {
const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route),
)
console.log('\n📊 Detailed file information:')
console.log(
'Status │ Path │ MIME Type │ Reason',
)
allFiles.forEach((file) => {
const isPreloaded = loaded.includes(file)
const status = isPreloaded ? '[MEMORY]' : '[ON-DEMAND]'
const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
const reason =
!isPreloaded && file.size > MAX_PRELOAD_BYTES
? ' (too large)'
? 'too large'
: !isPreloaded
? ' (filtered)'
: ''
? 'filtered'
: 'preloaded'
const route =
file.route.length > 30
? file.route.substring(0, 27) + '...'
: file.route
console.log(
` ${status.padEnd(12)} ${file.route} - ${file.type}${reason}`,
`${status.padEnd(12)} ${route.padEnd(30)} ${file.type.padEnd(28)}${reason.padEnd(10)}`,
)
})
} else {
console.log('\n📊 No files found to display')
}
}
// 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`,
log.success(
`Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
)
} else {
console.log(' No files preloaded into memory')
log.info('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)`,
log.info(
`${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)
log.error(
`Failed to load static files from ${clientDirectory}: ${String(error)}`,
)
}
return { routes, loaded, skipped }
}
/**
* Start the production server
* Initialize the server
*/
async function startServer() {
console.log('🚀 Starting production server...')
async function initializeServer() {
log.header('Starting Production Server')
// Load TanStack Start server handler
let handler: { fetch: (request: Request) => Response | Promise<Response> }
try {
const serverModule = (await import(SERVER_ENTRY)) as {
const serverModule = (await import(SERVER_ENTRY_POINT)) as {
default: { fetch: (request: Request) => Response | Promise<Response> }
}
handler = serverModule.default
console.log('TanStack Start handler loaded')
log.success('TanStack Start application handler initialized')
} catch (error) {
console.error('❌ Failed to load server handler:', error)
log.error(`Failed to load server handler: ${String(error)}`)
process.exit(1)
}
// Build static routes with intelligent preloading
const { routes } = await buildStaticRoutes(CLIENT_DIR)
const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)
// Create Bun server
const server = Bun.serve({
port: PORT,
idleTimeout: 255,
port: SERVER_PORT,
routes: {
// Serve static assets (preloaded or on-demand)
...routes,
// Fallback to TanStack Start handler for all other routes
'/*': (request) => {
'/*': async (req: Request) => {
try {
return handler.fetch(request)
const h3Response = await handler.fetch(req)
const body = await h3Response.arrayBuffer()
return new Response(body, {
status: h3Response.status,
statusText: h3Response.statusText,
headers: h3Response.headers,
})
} catch (error) {
console.error('Server handler error:', error)
log.error(`Server handler error: ${String(error)}`)
return new Response('Internal Server Error', { status: 500 })
}
},
@@ -349,18 +546,18 @@ async function startServer() {
// Global error handler
error(error) {
console.error('Uncaught server error:', error)
log.error(
`Uncaught server error: ${error instanceof Error ? error.message : String(error)}`,
)
return new Response('Internal Server Error', { status: 500 })
},
})
console.log(
`\n🚀 Server running at http://localhost:${String(server.port)}\n`,
)
log.success(`Server listening on http://localhost:${String(server.port)}`)
}
// Start the server
startServer().catch((error: unknown) => {
console.error('Failed to start server:', error)
// Initialize the server
initializeServer().catch((error: unknown) => {
log.error(`Failed to start server: ${String(error)}`)
process.exit(1)
})

View File

@@ -14,6 +14,7 @@ import { Route as LogoutRouteImport } from './routes/logout'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as AuthedStatsRouteImport } from './routes/_authed/stats'
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedBadgesRouteImport } from './routes/_authed/badges'
@@ -37,11 +38,13 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
const RefreshSessionRoute = RefreshSessionRouteImport.update({
id: '/refresh-session',
@@ -67,6 +70,11 @@ const AuthedIndexRoute = AuthedIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthedRoute,
} as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedStatsRoute = AuthedStatsRouteImport.update({
id: '/stats',
path: '/stats',
@@ -185,6 +193,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedTournamentsIdGroupsRoute =
AuthedTournamentsIdGroupsRouteImport.update({
id: '/tournaments/$id/groups',
path: '/tournaments/$id/groups',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdBracketRoute =
AuthedTournamentsIdBracketRouteImport.update({
id: '/tournaments/$id/bracket',
@@ -215,8 +229,15 @@ const AuthedAdminTournamentsIdTeamsRoute =
path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdAssignPartnersRoute =
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
id: '/tournaments/$id/assign-partners',
path: '/tournaments/$id/assign-partners',
getParentRoute: () => AuthedAdminRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthedIndexRoute
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute
@@ -224,7 +245,7 @@ export interface FileRoutesByFullPath {
'/badges': typeof AuthedBadgesRoute
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute
'/api/health': typeof ApiHealthRoute
'/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
@@ -242,13 +263,15 @@ export interface FileRoutesByFullPath {
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
@@ -257,6 +280,7 @@ export interface FileRoutesByTo {
'/badges': typeof AuthedBadgesRoute
'/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute
'/api/health': typeof ApiHealthRoute
'/': typeof AuthedIndexRoute
'/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute
@@ -277,7 +301,9 @@ export interface FileRoutesByTo {
'/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
@@ -293,6 +319,7 @@ export interface FileRoutesById {
'/_authed/badges': typeof AuthedBadgesRoute
'/_authed/settings': typeof AuthedSettingsRoute
'/_authed/stats': typeof AuthedStatsRoute
'/api/health': typeof ApiHealthRoute
'/_authed/': typeof AuthedIndexRoute
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
@@ -313,7 +340,9 @@ export interface FileRoutesById {
'/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
@@ -322,6 +351,7 @@ export interface FileRoutesById {
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/logout'
| '/refresh-session'
@@ -329,7 +359,7 @@ export interface FileRouteTypes {
| '/badges'
| '/settings'
| '/stats'
| '/'
| '/api/health'
| '/admin/activities'
| '/admin/badges'
| '/admin/preview'
@@ -347,13 +377,15 @@ export interface FileRouteTypes {
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/admin/'
| '/tournaments'
| '/tournaments/'
| '/tournaments/$id/bracket'
| '/admin/tournaments'
| '/tournaments/$id/groups'
| '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id'
| '/admin/tournaments/$id/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
@@ -362,6 +394,7 @@ export interface FileRouteTypes {
| '/badges'
| '/settings'
| '/stats'
| '/api/health'
| '/'
| '/admin/activities'
| '/admin/badges'
@@ -382,7 +415,9 @@ export interface FileRouteTypes {
| '/admin'
| '/tournaments'
| '/tournaments/$id/bracket'
| '/tournaments/$id/groups'
| '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
@@ -397,6 +432,7 @@ export interface FileRouteTypes {
| '/_authed/badges'
| '/_authed/settings'
| '/_authed/stats'
| '/api/health'
| '/_authed/'
| '/_authed/admin/activities'
| '/_authed/admin/badges'
@@ -417,7 +453,9 @@ export interface FileRouteTypes {
| '/_authed/admin/'
| '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket'
| '/_authed/tournaments/$id/groups'
| '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
@@ -429,6 +467,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute
RefreshSessionRoute: typeof RefreshSessionRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
@@ -468,7 +507,7 @@ declare module '@tanstack/react-router' {
'/_authed': {
id: '/_authed'
path: ''
fullPath: ''
fullPath: '/'
preLoaderRoute: typeof AuthedRouteImport
parentRoute: typeof rootRouteImport
}
@@ -479,6 +518,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedIndexRouteImport
parentRoute: typeof AuthedRoute
}
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/stats': {
id: '/_authed/stats'
path: '/stats'
@@ -510,7 +556,7 @@ declare module '@tanstack/react-router' {
'/_authed/tournaments/': {
id: '/_authed/tournaments/'
path: '/tournaments'
fullPath: '/tournaments'
fullPath: '/tournaments/'
preLoaderRoute: typeof AuthedTournamentsIndexRouteImport
parentRoute: typeof AuthedRoute
}
@@ -636,10 +682,17 @@ declare module '@tanstack/react-router' {
'/_authed/admin/tournaments/': {
id: '/_authed/admin/tournaments/'
path: '/tournaments'
fullPath: '/admin/tournaments'
fullPath: '/admin/tournaments/'
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/tournaments/$id/groups': {
id: '/_authed/tournaments/$id/groups'
path: '/tournaments/$id/groups'
fullPath: '/tournaments/$id/groups'
preLoaderRoute: typeof AuthedTournamentsIdGroupsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/$id/bracket': {
id: '/_authed/tournaments/$id/bracket'
path: '/tournaments/$id/bracket'
@@ -650,7 +703,7 @@ declare module '@tanstack/react-router' {
'/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id'
fullPath: '/admin/tournaments/$id'
fullPath: '/admin/tournaments/$id/'
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
@@ -675,6 +728,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/$id/assign-partners': {
id: '/_authed/admin/tournaments/$id/assign-partners'
path: '/tournaments/$id/assign-partners'
fullPath: '/admin/tournaments/$id/assign-partners'
preLoaderRoute: typeof AuthedAdminTournamentsIdAssignPartnersRouteImport
parentRoute: typeof AuthedAdminRoute
}
}
}
@@ -684,6 +744,7 @@ interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
@@ -695,6 +756,8 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdAssignPartnersRoute:
AuthedAdminTournamentsIdAssignPartnersRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
@@ -715,6 +778,7 @@ interface AuthedRouteChildren {
AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
}
const AuthedRouteChildren: AuthedRouteChildren = {
@@ -728,6 +792,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
}
const AuthedRouteWithChildren =
@@ -738,6 +803,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute,
RefreshSessionRoute: RefreshSessionRoute,
ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,

View File

@@ -11,6 +11,8 @@ import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor";
import { IOSInstallPrompt } from "@/components/ios-install-prompt";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
@@ -40,30 +42,45 @@ export const Route = createRootRouteWithContext<{
content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content",
},
{ property: 'og:title', content: 'FLXN IX' },
{ property: 'og:description', content: 'Register for FLXN IX and view FLXN stats' },
{ name: 'description', content: 'Amicus meus madidus' },
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
{ name: 'theme-color', content: '#1e293b' },
{ property: 'og:title', content: 'FLXN' },
{ property: 'og:description', content: 'Amicus meus madidus' },
{ property: 'og:url', content: 'https://flexxon.app' },
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: 'FLXN IX' },
{ property: 'og:site_name', content: 'FLXN' },
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
{ property: 'og:image:width', content: '512' },
{ property: 'og:image:height', content: '512' },
{ property: 'og:image:alt', content: 'FLXN logo' },
{ property: 'og:locale', content: 'en_US' },
{ name: 'twitter:card', content: 'summary' },
{ name: 'twitter:title', content: 'FLXN' },
{ name: 'twitter:description', content: 'Amicus meus madidus' },
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
{ name: 'mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
{ name: 'apple-mobile-web-app-title', content: 'FLXN' },
],
links: [
{
rel: "apple-touch-icon",
sizes: "180x180",
href: "/favicon.png",
href: "/apple-touch-icon.png",
},
{
rel: "icon",
type: "image/png",
sizes: "32x32",
href: "/favicon.png",
href: "/favicon-32x32.png",
},
{
rel: "icon",
type: "image/png",
sizes: "16x16",
href: "/favicon.png",
href: "/favicon-16x16.png",
},
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" },
@@ -95,23 +112,35 @@ export const Route = createRootRouteWithContext<{
component: RootComponent,
notFoundComponent: () => <Navigate to="/" />,
beforeLoad: async ({ context, location }) => {
// Skip auth check for refresh-session route to avoid infinite loops
if (location.pathname === '/refresh-session') {
return {};
}
if (location.pathname === '/login' || location.pathname === '/logout') {
const publicRoutes = ['/login', '/logout', '/refresh-session'];
if (publicRoutes.some(route => location.pathname.startsWith(route))) {
return {};
}
try {
// https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
} catch (error) {
} catch (error: any) {
if (typeof window !== 'undefined') {
const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
const sessionExists = await doesSessionExist();
if (sessionExists) {
try {
await attemptRefreshingSession();
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
} catch {
return {};
}
}
}
return {};
}
},
@@ -126,6 +155,8 @@ function RootComponent() {
return (
<RootDocument>
<Providers>
<SessionMonitor />
<IOSInstallPrompt />
<Outlet />
</Providers>
</RootDocument>

View File

@@ -0,0 +1,167 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Stack, Text, Button, Alert, LoadingOverlay, Group } from "@mantine/core";
import { useState } from "react";
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments";
import TeamAssignmentPreview from "@/features/tournaments/components/team-assignment-preview";
import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { PlayerInfo } from "@/features/players/types";
import { useQueryClient } from "@tanstack/react-query";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
return { tournament };
},
loader: ({ context }) => ({
header: {
withBackButton: true,
title: `Manage ${context.tournament.name}`,
},
}),
component: RouteComponent,
});
interface TeamAssignment {
player1: PlayerInfo;
player2: PlayerInfo;
teamName: string;
}
function RouteComponent() {
const { id } = Route.useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: freeAgents } = useFreeAgents(id);
const [assignments, setAssignments] = useState<TeamAssignment[] | null>(null);
const [currentSeed, setCurrentSeed] = useState<number | undefined>(undefined);
const generateMutation = useGenerateRandomTeams();
const confirmMutation = useConfirmTeamAssignments();
const hasOddPlayers = freeAgents.length % 2 !== 0;
const hasEnoughPlayers = freeAgents.length >= 2;
const handleGenerate = () => {
generateMutation.mutate(
{ data: { tournamentId: id, seed: currentSeed } },
{
onSuccess: (result) => {
setAssignments(result.assignments);
setCurrentSeed(result.seed);
},
}
);
};
const handleReroll = () => {
if (currentSeed === undefined) return;
generateMutation.mutate(
{ data: { tournamentId: id, seed: currentSeed + 1 } },
{
onSuccess: (result) => {
setAssignments(result.assignments);
setCurrentSeed(result.seed);
},
}
);
};
const handleConfirm = () => {
if (!assignments) return;
const formattedAssignments = assignments.map((a) => ({
player1Id: a.player1.id,
player2Id: a.player2.id,
teamName: a.teamName,
}));
confirmMutation.mutate(
{ data: { tournamentId: id, assignments: formattedAssignments } },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(id).queryKey });
queryClient.invalidateQueries({ queryKey: tournamentQueries.free_agents(id).queryKey });
navigate({ to: "/admin/tournaments/$id", params: { id } });
},
}
);
};
return (
<Stack gap="lg" pos="relative">
<LoadingOverlay visible={confirmMutation.isPending} />
<Stack gap="xs">
<Group gap="xs" align="baseline">
<Text size="xl" fw={700}>
{freeAgents.length}
</Text>
<Text size="sm" c="dimmed">
{freeAgents.length === 1 ? "player enrolled" : "players enrolled"}
</Text>
</Group>
{!hasEnoughPlayers && (
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
Need at least 2 players to create teams
</Alert>
)}
{hasOddPlayers && (
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
Cannot create teams with an odd number of players. Please have one player unenroll.
</Alert>
)}
{!assignments && hasEnoughPlayers && !hasOddPlayers && (
<Button
leftSection={<ShuffleIcon size={18} />}
onClick={handleGenerate}
loading={generateMutation.isPending}
>
Generate Random Pairings
</Button>
)}
</Stack>
{assignments && (
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
Partner Assignments
</Text>
<Group gap="sm">
<Button
variant="subtle"
leftSection={<ShuffleIcon size={16} />}
onClick={handleReroll}
loading={generateMutation.isPending}
size="sm"
>
Re-roll
</Button>
<Button
leftSection={<CheckCircleIcon size={18} />}
onClick={handleConfirm}
loading={confirmMutation.isPending}
size="sm"
>
Confirm & Create Teams
</Button>
</Group>
</Group>
<TeamAssignmentPreview assignments={assignments} />
</Stack>
)}
</Stack>
);
}

View File

@@ -5,7 +5,9 @@ import {
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament";
import { Container } from "@mantine/core";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container, Stack, Divider, Title } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
@@ -43,6 +45,20 @@ function RouteComponent() {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const hasGroupStage = useMemo(() => {
return tournament.matches?.some((match) => match.round === -1) || false;
}, [tournament.matches]);
const hasKnockout = useMemo(() => {
return tournament.matches?.some((match) => match.round !== -1) || false;
}, [tournament.matches]);
const knockoutBracketPopulated = useMemo(() => {
return tournament.matches?.some((match) =>
match.round === 0 && match.lid >= 0 && (match.home || match.away)
) || false;
}, [tournament.matches]);
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
@@ -52,6 +68,7 @@ function RouteComponent() {
const losersMap = new Map<number, Match[]>();
tournament.matches
.filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
@@ -79,14 +96,51 @@ function RouteComponent() {
return (
<Container size="md" px={0}>
{ isAdmin && <SpotifyControlsBar />}
{ isAdmin && !tournament.regional && <SpotifyControlsBar />}
{tournament.matches?.length ? (
<BracketView bracket={bracket} showControls />
hasGroupStage && hasKnockout ? (
<Stack gap="xl">
<GroupStageView
groups={tournament.groups || []}
matches={tournament.matches}
showControls
tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
/>
<Divider />
<div>
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
</div>
</Stack>
) : hasGroupStage ? (
<GroupStageView
groups={tournament.groups || []}
matches={tournament.matches}
showControls
tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
/>
) : (
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
)
) : (
<SeedTournament
tournamentId={tournament.id}
teams={tournament.teams || []}
/>
tournament.regional === true ? (
<SetupGroupStage
tournamentId={tournament.id}
teams={tournament.teams || []}
/>
) : (
<SeedTournament
tournamentId={tournament.id}
teams={tournament.teams || []}
isRegional={tournament.regional}
/>
)
)}
</Container>
);

View File

@@ -4,7 +4,6 @@ import {
useTournament,
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament";
import { Container } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
@@ -18,7 +17,7 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
if (!tournament) throw redirect({ to: "/tournaments" });
return {
tournament,
};
@@ -26,7 +25,6 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
showSpotifyPanel: true,
header: {
withBackButton: true,
title: `${context.tournament.name}`,
@@ -48,6 +46,7 @@ function RouteComponent() {
const losersMap = new Map<number, Match[]>();
tournament.matches
.filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
@@ -75,7 +74,7 @@ function RouteComponent() {
return (
<Container size="md" px={0}>
<BracketView bracket={bracket} />
<BracketView bracket={bracket} groupConfig={tournament.group_config} />
</Container>
);
}

View File

@@ -0,0 +1,47 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import {
tournamentQueries,
useTournament,
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container } from "@mantine/core";
export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/tournaments" });
return {
tournament,
};
},
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
header: {
withBackButton: true,
title: `${context.tournament.name}`,
},
}),
component: RouteComponent,
});
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
return (
<Container size="md" px={0}>
<GroupStageView
groups={tournament.groups || []}
matches={tournament.matches || []}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
/>
</Container>
);
}

View File

@@ -0,0 +1,22 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/health")({
server: {
handlers: {
GET: () => {
return new Response(
JSON.stringify({
status: "ok",
timestamp: new Date().toISOString(),
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
}
);
},
},
},
});

View File

@@ -2,7 +2,8 @@ import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef } from 'react'
import FullScreenLoader from '@/components/full-screen-loader'
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
import { resetRefreshFlag } from '@/lib/supertokens/client'
import { resetRefreshFlag, getOrCreateRefreshPromise } from '@/lib/supertokens/client'
import { logger } from '@/lib/supertokens'
export const Route = createFileRoute('/refresh-session')({
component: RouteComponent,
@@ -17,23 +18,31 @@ function RouteComponent() {
const handleRefresh = async () => {
try {
resetRefreshFlag();
const refreshed = await attemptRefreshingSession()
logger.info("Refresh session route: starting refresh");
const refreshed = await getOrCreateRefreshPromise(async () => {
return await attemptRefreshingSession();
});
if (refreshed) {
const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect')
logger.info("Refresh session route: refresh successful");
const urlParams = new URLSearchParams(window.location.search);
const redirect = urlParams.get('redirect');
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
window.location.href = decodeURIComponent(redirect)
logger.info("Refresh session route: redirecting to", redirect);
window.location.href = decodeURIComponent(redirect);
} else {
logger.info("Refresh session route: redirecting to home");
window.location.href = '/';
}
} else {
window.location.href = '/login'
logger.warn("Refresh session route: refresh failed, redirecting to login");
window.location.href = '/login';
}
} catch (error) {
window.location.href = '/login'
logger.error("Refresh session route: error during refresh", error);
window.location.href = '/login';
}
}

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
export function IOSInstallPrompt() {
const [show, setShow] = useState(false)
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
useEffect(() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
const isAndroid = /Android/.test(navigator.userAgent)
const isInStandaloneMode =
window.matchMedia('(display-mode: standalone)').matches ||
('standalone' in window.navigator && (window.navigator as any).standalone)
const hasBeenDismissed = localStorage.getItem('pwa-install-prompt-dismissed') === 'true'
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
setPlatform(isIOS ? 'ios' : 'android')
const timer = setTimeout(() => setShow(true), 3000)
return () => clearTimeout(timer)
}
}, [])
const handleDismiss = () => {
localStorage.setItem('pwa-install-prompt-dismissed', 'true')
setShow(false)
}
if (!show || !platform) return null
const instructions = platform === 'ios'
? 'Tap Share → Add to Home Screen'
: 'Tap Menu (⋮) → Add to Home screen'
return (
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
Please install FLXN This will save me Twilio credits as you won't be signed out!
</Text>
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
{instructions}
</Text>
</Box>
</Group>
<ActionIcon variant="subtle" color="white" onClick={handleDismiss}>
<XIcon size={18} />
</ActionIcon>
</Group>
</Paper>
</Box>
)
}

View File

@@ -0,0 +1,85 @@
import { Paper, useMantineTheme } from "@mantine/core";
import { Facehash } from "facehash";
interface PlayerAvatarProps {
name?: string;
size?: number;
disableFullscreen?: boolean;
style?: React.CSSProperties;
}
const PlayerAvatar = ({
name = "",
size = 35,
disableFullscreen = false,
style,
}: PlayerAvatarProps) => {
const theme = useMantineTheme();
const getFacehashSize = (size: number): 32 | 48 | 64 | 80 => {
if (size <= 40) return 32;
if (size <= 56) return 48;
if (size <= 72) return 64;
return 80;
};
const facehashSize = getFacehashSize(size);
const colors = [
"hsla(314, 100%, 80%, 1)",
"hsla(58, 93%, 72%, 1)",
"hsla(218, 92%, 72%, 1)",
"hsla(19, 99%, 44%, 1)",
"hsla(156, 86%, 40%, 1)",
"hsla(314, 100%, 85%, 1)",
"hsla(58, 92%, 79%, 1)",
"hsla(218, 91%, 78%, 1)",
"hsla(19, 99%, 50%, 1)",
"hsla(156, 86%, 64%, 1)",
];
return (
<Paper
p={size / 20}
radius="100%"
withBorder
style={{
cursor: !disableFullscreen ? 'pointer' : 'default',
transition: 'transform 0.15s ease',
...style,
}}
onMouseEnter={(e) => {
if (!disableFullscreen) {
e.currentTarget.style.transform = 'scale(1.02)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<div
style={{
width: size,
height: size,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: '100%',
}}
>
<Facehash
name={name}
size={size}
variant="solid"
colors={colors}
intensity3d="dramatic"
enableBlink
style={{ borderRadius: '100%', overflow: 'hidden', color: 'black' }}
/>
</div>
</Paper>
);
};
export default PlayerAvatar;

View File

@@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
import { logger } from '@/lib/supertokens';
export function SessionMonitor() {
const navigate = useNavigate();
const lastRefreshTimeRef = useRef<number>(0);
const REFRESH_COOLDOWN = 30 * 1000;
useEffect(() => {
if (typeof window === 'undefined') return;
const handleVisibilityChange = async () => {
if (document.visibilityState !== 'visible') return;
const publicRoutes = ['/login', '/logout', '/refresh-session'];
if (publicRoutes.some(route => window.location.pathname === route)) {
return;
}
const now = Date.now();
if (now - lastRefreshTimeRef.current < REFRESH_COOLDOWN) {
logger.info('Session monitor: skipping refresh (cooldown)');
return;
}
try {
const sessionExists = await doesSessionExist();
if (!sessionExists) {
logger.info('Session monitor: no session exists, skipping refresh');
return;
}
logger.info('Session monitor: tab became visible, refreshing session');
const refreshed = await getOrCreateRefreshPromise(async () => {
return await attemptRefreshingSession();
});
if (refreshed) {
lastRefreshTimeRef.current = Date.now();
logger.info('Session monitor: session refreshed successfully');
} else {
logger.warn('Session monitor: refresh returned false');
}
} catch (error) {
logger.error('Session monitor: error refreshing session', error);
}
};
handleVisibilityChange();
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [navigate]);
return null;
}

View File

@@ -0,0 +1,129 @@
import { Box, AvatarGroup } from "@mantine/core";
import { CrownIcon } from "@phosphor-icons/react";
import { TeamInfo } from "@/features/teams/types";
import Avatar from "./avatar";
import PlayerAvatar from "./player-avatar";
interface TeamAvatarProps {
team: TeamInfo;
size?: number;
radius?: string | number;
withBorder?: boolean;
disableFullscreen?: boolean;
contain?: boolean;
style?: React.CSSProperties;
winner?: boolean;
isRegional?: boolean;
}
const TeamAvatar = ({
team,
size = 35,
radius = "sm",
withBorder = true,
disableFullscreen = false,
contain = false,
style,
winner = false,
isRegional,
}: TeamAvatarProps) => {
const hasNoLogo = !team.logo;
const hasTwoPlayers = team.players?.length === 2;
const shouldShowPlayerAvatars = isRegional === true && hasTwoPlayers && hasNoLogo;
if (shouldShowPlayerAvatars && team.players?.length === 2) {
const playerSize = size * 0.6;
const crownSize = Math.max(12, size * 0.35);
return (
<Box
style={{
position: "relative",
width: size,
height: size,
display: "flex",
alignItems: "center",
justifyContent: "center",
...style,
}}
>
<AvatarGroup spacing={size * -0.25}>
<Box style={{ position: "relative" }}>
<PlayerAvatar
name={`${team.players[0].first_name} ${team.players[0].last_name}`}
size={playerSize}
disableFullscreen={disableFullscreen}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 1.1,
left: "45%",
transform: "translateX(-50%)",
color: "gold",
rotate: "-5deg",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
<Box style={{ position: "relative" }}>
<PlayerAvatar
name={`${team.players[1].first_name} ${team.players[1].last_name}`}
size={playerSize}
disableFullscreen={disableFullscreen}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 0.95,
left: "65%",
transform: "translateX(-50%)",
color: "gold",
rotate: "10deg",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
</AvatarGroup>
</Box>
);
}
const crownSize = Math.max(14, size * 0.4);
return (
<Box style={{ position: "relative", ...style }}>
<Avatar
name={team.name}
size={size}
radius={radius}
withBorder={withBorder}
disableFullscreen={disableFullscreen}
contain={contain}
src={team.logo ? `/api/files/teams/${team.id}/${team.logo}` : undefined}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 0.6,
left: -crownSize * 0.25,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
);
};
export default TeamAvatar;

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react';
import { SpotifyAuth } from '@/lib/spotify/auth';
import { useAuth } from './auth-context';
import { useConfig } from '@/hooks/use-config';
import type {
SpotifyContextType,
SpotifyAuthState,
@@ -23,15 +24,16 @@ export const SpotifyContext = createContext<SpotifyContextType | null>(null);
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const config = useConfig();
const [authState, setAuthState] = useState<SpotifyAuthState>(defaultSpotifyState);
const [currentTrack, setCurrentTrack] = useState<SpotifyTrack | null>(null);
const [playbackState, setPlaybackState] = useState<SpotifyPlaybackState | null>(null);
const [devices, setDevices] = useState<SpotifyDevice[]>([]);
const [activeDevice, setActiveDeviceState] = useState<SpotifyDevice | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -40,8 +42,8 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const [isResumeLoading, setIsResumeLoading] = useState(false);
const spotifyAuth = new SpotifyAuth(
import.meta.env.VITE_SPOTIFY_CLIENT_ID!,
import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
config.spotifyClientId,
config.spotifyRedirectUri
);
useEffect(() => {

View File

@@ -7,10 +7,14 @@ import { Match } from "@/features/matches/types";
interface BracketViewProps {
bracket: BracketData;
showControls?: boolean
showControls?: boolean;
groupConfig?: {
num_groups: number;
advance_per_group: number;
};
}
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
const height = useAppShellHeight();
const orders = useMemo(() => {
const map: Record<number, number> = {};
@@ -32,14 +36,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
<Text fw={600} size="md" m={16}>
Winners Bracket
</Text>
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} />
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
</div>
{bracket.losers && (
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div>
<Text fw={600} size="md" m={16}>
Losers Bracket
</Text>
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} />
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} />
</div>
)}
</ScrollArea>

View File

@@ -7,12 +7,17 @@ interface BracketProps {
rounds: Match[][];
orders: Record<number, number>;
showControls?: boolean;
groupConfig?: {
num_groups: number;
advance_per_group: number;
};
}
export const Bracket: React.FC<BracketProps> = ({
rounds,
orders,
showControls,
groupConfig,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
@@ -132,6 +137,7 @@ export const Bracket: React.FC<BracketProps> = ({
match={match}
orders={orders}
showControls={showControls}
groupConfig={groupConfig}
/>
</div>
)

View File

@@ -17,16 +17,62 @@ interface MatchCardProps {
match: Match;
orders: Record<number, number>;
showControls?: boolean;
groupConfig?: {
num_groups: number;
advance_per_group: number;
};
}
export const MatchCard: React.FC<MatchCardProps> = ({
match,
orders,
showControls,
groupConfig,
}) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback();
const getGroupLabel = useCallback((seed: number | undefined) => {
if (!seed || !groupConfig) return undefined;
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const numGroups = groupConfig.num_groups;
const advancePerGroup = groupConfig.advance_per_group;
const totalQualifiedTeams = numGroups * advancePerGroup;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
const wildcardNumber = seed - totalQualifiedTeams;
return `Wildcard ${wildcardNumber}`;
}
const pairIndex = Math.floor((seed - 1) / 2);
const isFirstInPair = (seed - 1) % 2 === 0;
if (isFirstInPair) {
const groupIndex = pairIndex % numGroups;
const rankIndex = Math.floor(pairIndex / numGroups);
const rank = rankIndex + 1;
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
return `${groupName} ${rankSuffix}`;
} else {
const groupIndex = (pairIndex + 1) % numGroups;
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
const rank = rankIndex + 1;
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
return `${groupName} ${rankSuffix}`;
}
}, [groupConfig]);
const homeSlot = useMemo(
() => ({
from: orders[match.home_from_lid],
@@ -39,8 +85,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match.home_cups !== undefined &&
match.away_cups !== undefined &&
match.home_cups > match.away_cups,
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined,
}),
[match]
[match, getGroupLabel]
);
const awaySlot = useMemo(
() => ({
@@ -54,8 +101,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match.away_cups !== undefined &&
match.home_cups !== undefined &&
match.away_cups > match.home_cups,
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined,
}),
[match]
[match, getGroupLabel]
);
const showToolbar = useMemo(
@@ -179,8 +227,11 @@ export const MatchCard: React.FC<MatchCardProps> = ({
data: match.id,
});
// Play walkout sequence after starting the match
if (hasWalkoutData && match.home?.name && match.away?.name) {
// Skip announcements for regional tournaments
const isRegional = match.tournament?.regional === true;
// Play walkout sequence after starting the match (only for non-regional tournaments)
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
try {
const homeTeam = match.home as Team;
const awayTeam = match.away as Team;

View File

@@ -11,6 +11,7 @@ interface MatchSlotProps {
seed?: number;
cups?: number;
isWinner?: boolean;
groupLabel?: string;
}
export const MatchSlot: React.FC<MatchSlotProps> = ({
@@ -19,7 +20,8 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
team,
seed,
cups,
isWinner
isWinner,
groupLabel
}) => (
<Flex
align="stretch"
@@ -34,7 +36,7 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
<Flex align="center" gap={4} flex={1}>
{team ? (
<>
<Text
<Text
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
truncate
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
@@ -43,18 +45,22 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
</Text>
{isWinner && (
<CrownIcon
size={14}
size={14}
weight="fill"
style={{
style={{
color: 'gold',
marginLeft: '2px',
marginTop: '-1px',
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
flexShrink: 0
}}
}}
/>
)}
</>
) : groupLabel ? (
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
{groupLabel}
</Text>
) : from ? (
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
{from_loser ? "Loser" : "Winner"} of Match {from}

View File

@@ -1,15 +1,16 @@
import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core";
import { Link } from "@tanstack/react-router";
import { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links";
import { memo } from "react";
import { useIsPWA } from "@/hooks/use-is-pwa";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const { colorScheme } = useMantineColorScheme();
const isPWA = useIsPWA();
const links = useLinks(user?.id, roles);
@@ -19,7 +20,7 @@ const Navbar = () => {
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
if (isMobile) return (
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' 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% - 1rem)' pos='fixed' m='0.5rem' bottom={isPWA ? '1rem' : '0'} style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => (
<NavLink key={link.href} {...link} />

View File

@@ -85,6 +85,55 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
return () => void ac.abort();
}, []);
// Fix wheel scrolling over child elements
useEffect(() => {
const scrollWrapper = document.getElementById('scroll-wrapper');
if (!scrollWrapper) return;
const viewport = scrollWrapper.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
if (!viewport) return;
const handleWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
// Check if the target is inside a nested scrollable container
let element = target;
while (element && element !== viewport) {
const overflow = window.getComputedStyle(element).overflow;
const overflowY = window.getComputedStyle(element).overflowY;
const overflowX = window.getComputedStyle(element).overflowX;
// If we found a scrollable ancestor (not the main viewport), don't interfere
if (
(overflow === 'auto' || overflow === 'scroll' ||
overflowY === 'auto' || overflowY === 'scroll' ||
overflowX === 'auto' || overflowX === 'scroll') &&
element !== viewport
) {
// Check if this element can actually scroll in the wheel direction
const canScrollY = element.scrollHeight > element.clientHeight;
const canScrollX = element.scrollWidth > element.clientWidth;
if ((e.deltaY !== 0 && canScrollY) || (e.deltaX !== 0 && canScrollX)) {
return; // Let the nested scroller handle it
}
}
element = element.parentElement as HTMLElement;
}
// No nested scroller found, scroll the main viewport
viewport.scrollTop += e.deltaY;
viewport.scrollLeft += e.deltaX;
};
scrollWrapper.addEventListener('wheel', handleWheel, { passive: true });
return () => {
scrollWrapper.removeEventListener('wheel', handleWheel);
};
}, []);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (scrollAreaRef.current) {
@@ -129,8 +178,15 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
styles={{
viewport: {
'& > *': {
pointerEvents: 'auto'
}
}
}}
>
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box', pointerEvents: 'auto' }}>
{children}
</Box>
</ScrollArea>

View File

@@ -1,8 +1,8 @@
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core";
import { CrownIcon, FootballHelmetIcon } from "@phosphor-icons/react";
import { FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
import Avatar from "@/components/avatar";
import TeamAvatar from "@/components/team-avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import { Suspense } from "react";
import { useSheet } from "@/hooks/use-sheet";
@@ -113,32 +113,16 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
<Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
style={{ cursor: "pointer" }}
onClick={handleHomeTeamClick}
>
<Avatar
<TeamAvatar
team={match.home!}
size={40}
name={match.home?.name!}
radius="sm"
src={
match.home?.logo
? `/api/files/teams/${match.home?.id}/${match.home?.logo}`
: undefined
}
winner={isHomeWin}
isRegional={match.tournament.regional}
/>
{isHomeWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Tooltip
label={match.home?.name!}
@@ -175,32 +159,16 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
<Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
style={{ cursor: "pointer" }}
onClick={handleAwayTeamClick}
>
<Avatar
<TeamAvatar
team={match.away!}
size={40}
name={match.away?.name!}
radius="sm"
src={
match.away?.logo
? `/api/files/teams/${match.away?.id}/${match.away?.logo}`
: undefined
}
winner={isAwayWin}
isRegional={match.tournament.regional}
/>
{isAwayWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Tooltip
label={match.away?.name}

View File

@@ -5,7 +5,7 @@ import { logger } from "@/lib/logger";
import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import brackets from "@/features/bracket/utils";
import { MatchInput } from "@/features/matches/types";
import { Match, MatchInput } from "@/features/matches/types";
import { serverEvents } from "@/lib/events/emitter";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { PlayerInfo } from "../players/types";
@@ -164,6 +164,294 @@ export const startMatch = createServerFn()
})
);
export const populateKnockoutBracket = createServerFn()
.inputValidator(z.object({
tournamentId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId } }) =>
toServerResult(async () => {
const tournament = await pbAdmin.getTournament(tournamentId);
if (!tournament) {
throw new Error("Tournament not found");
}
if (!tournament.group_config) {
throw new Error("Tournament must have group_config");
}
return await populateKnockoutBracketInternal(tournamentId, tournament.group_config);
})
);
async function populateKnockoutBracketInternal(tournamentId: string, groupConfig: { num_groups: number; advance_per_group: number }) {
logger.info('Populating knockout bracket', { tournamentId });
const groups = await pbAdmin.getGroupsByTournament(tournamentId);
if (!groups || groups.length === 0) {
throw new Error("No groups found for tournament");
}
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
for (const group of groups) {
logger.info('Processing group', {
groupId: group.id,
groupOrder: group.order,
teamsCount: group.teams?.length,
teams: group.teams
});
const groupMatches = await pbAdmin.getMatchesByGroup(group.id);
const completedMatches = groupMatches.filter(m => m.status === "ended");
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
for (const team of group.teams || []) {
// group.teams can be either team objects or just team ID strings
const teamId = typeof team === 'string' ? team : team.id;
standings.set(teamId, {
teamId,
wins: 0,
losses: 0,
cups_for: 0,
cups_against: 0,
cup_differential: 0,
});
}
for (const match of completedMatches) {
if (!match.home || !match.away) continue;
const homeStanding = standings.get(match.home.id);
const awayStanding = standings.get(match.away.id);
if (homeStanding && awayStanding) {
homeStanding.cups_for += match.home_cups;
homeStanding.cups_against += match.away_cups;
awayStanding.cups_for += match.away_cups;
awayStanding.cups_against += match.home_cups;
if (match.home_cups > match.away_cups) {
homeStanding.wins++;
awayStanding.losses++;
} else {
awayStanding.wins++;
homeStanding.losses++;
}
homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against;
awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against;
}
}
const sortedStandings = Array.from(standings.values()).sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
return b.cups_for - a.cups_for;
});
const topTeams = sortedStandings.slice(0, groupConfig.advance_per_group);
logger.info('Top teams from group', {
groupId: group.id,
topTeams: topTeams.map(t => ({ teamId: t.teamId, wins: t.wins, cupDiff: t.cup_differential }))
});
topTeams.forEach((standing, index) => {
qualifiedTeams.push({
teamId: standing.teamId,
groupOrder: group.order,
rank: index + 1,
});
});
}
logger.info('Qualified teams', { qualifiedTeams });
const orderedTeamIds: string[] = [];
const maxRank = groupConfig.advance_per_group;
const numGroups = groupConfig.num_groups;
const teamsByGroup: string[][] = [];
for (let g = 0; g < numGroups; g++) {
teamsByGroup[g] = [];
}
for (const qualified of qualifiedTeams) {
teamsByGroup[qualified.groupOrder][qualified.rank - 1] = qualified.teamId;
}
const totalQualifiedTeams = numGroups * maxRank;
for (let i = 0; i < totalQualifiedTeams / 2; i++) {
const group1 = i % numGroups;
const rankIndex1 = Math.floor(i / numGroups);
const group2 = (i + 1) % numGroups;
const rankIndex2 = maxRank - 1 - rankIndex1;
const team1 = teamsByGroup[group1]?.[rankIndex1];
const team2 = teamsByGroup[group2]?.[rankIndex2];
if (team1) orderedTeamIds.push(team1);
if (team2) orderedTeamIds.push(team2);
}
const knockoutTeamCount = orderedTeamIds.length;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
if (wildcardsNeeded > 0) {
logger.info('Wildcards needed', { knockoutTeamCount, nextPowerOf2, wildcardsNeeded });
const allNonQualifiedTeams: Array<{ teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }> = [];
const qualifiedTeamIds = new Set(qualifiedTeams.map(t => t.teamId));
for (const group of groups) {
const groupMatches = await pbAdmin.getMatchesByGroup(group.id);
const completedMatches = groupMatches.filter(m => m.status === "ended");
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
for (const team of group.teams || []) {
const teamId = typeof team === 'string' ? team : team.id;
if (qualifiedTeamIds.has(teamId)) continue;
standings.set(teamId, {
teamId,
wins: 0,
losses: 0,
cups_for: 0,
cups_against: 0,
cup_differential: 0,
});
}
for (const match of completedMatches) {
if (!match.home || !match.away) continue;
const homeStanding = standings.get(match.home.id);
const awayStanding = standings.get(match.away.id);
if (homeStanding) {
homeStanding.cups_for += match.home_cups;
homeStanding.cups_against += match.away_cups;
homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against;
if (match.home_cups > match.away_cups) {
homeStanding.wins++;
} else {
homeStanding.losses++;
}
}
if (awayStanding) {
awayStanding.cups_for += match.away_cups;
awayStanding.cups_against += match.home_cups;
awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against;
if (match.away_cups > match.home_cups) {
awayStanding.wins++;
} else {
awayStanding.losses++;
}
}
}
allNonQualifiedTeams.push(...Array.from(standings.values()));
}
allNonQualifiedTeams.sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
return a.teamId.localeCompare(b.teamId);
});
const wildcardTeams = allNonQualifiedTeams.slice(0, wildcardsNeeded);
const wildcardTeamIds = wildcardTeams.map(t => t.teamId);
orderedTeamIds.push(...wildcardTeamIds);
logger.info('Added wildcard teams', {
wildcardTeams: wildcardTeams.map(t => ({
teamId: t.teamId,
wins: t.wins,
cupDiff: t.cup_differential,
cupsFor: t.cups_for
}))
});
}
logger.info('Ordered team IDs (with wildcards)', { orderedTeamIds, totalTeams: orderedTeamIds.length });
const tournament = await pbAdmin.getTournament(tournamentId);
const knockoutMatches = (tournament.matches || [])
.filter((m: Match) => m.round >= 0 && m.lid >= 0)
.sort((a: Match, b: Match) => a.lid - b.lid);
const seedToTeamId = new Map<number, string>();
orderedTeamIds.forEach((teamId, index) => {
seedToTeamId.set(index + 1, teamId);
});
logger.info('Seed to team mapping', {
seedToTeamId: Array.from(seedToTeamId.entries()),
orderedTeamIds
});
let updatedCount = 0;
for (const match of knockoutMatches) {
if (match.round === 0) {
const updates: any = {};
if (match.home_seed) {
const teamId = seedToTeamId.get(match.home_seed);
logger.info('Looking up home seed', {
matchId: match.id,
home_seed: match.home_seed,
teamId
});
if (teamId) {
updates.home = teamId;
}
}
if (match.away_seed) {
const teamId = seedToTeamId.get(match.away_seed);
logger.info('Looking up away seed', {
matchId: match.id,
away_seed: match.away_seed,
teamId
});
if (teamId) {
updates.away = teamId;
}
}
if (updates.home && updates.away) {
updates.status = "ready";
} else if (updates.home || updates.away) {
updates.status = "tbd";
}
if (Object.keys(updates).length > 0) {
logger.info('Updating match', { matchId: match.id, updates });
await pbAdmin.updateMatch(match.id, updates);
updatedCount++;
}
}
}
logger.info('Updated matches', { updatedCount, totalKnockoutMatches: knockoutMatches.length });
await pbAdmin.updateTournament(tournamentId, {
phase: "knockout"
});
logger.info('Knockout bracket populated successfully', { tournamentId });
}
const endMatchSchema = z.object({
matchId: z.string(),
home_cups: z.number(),
@@ -190,19 +478,25 @@ export const endMatch = createServerFn()
ot_count,
});
if (match.lid === -1) {
serverEvents.emit("match", {
type: "match",
matchId: match.id,
tournamentId: match.tournament.id
});
return match;
}
const matchWinner = home_cups > away_cups ? match.home : match.away;
const matchLoser = home_cups < away_cups ? match.home : match.away;
if (!matchWinner || !matchLoser) throw new Error("Something went wrong");
// winner -> where to send match winner to, loser same
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
// reset match check
if (winner && winner.reset) {
const awayTeamWon = match.away === matchWinner;
if (!awayTeamWon) {
// Reset match is not necessary
logger.info("Deleting reset match", {
resetMatchId: winner.id,
currentMatchId: match.id,
@@ -214,7 +508,6 @@ export const endMatch = createServerFn()
}
}
// advance bracket
if (winner) {
await pbAdmin.updateMatch(winner.id, {
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,

View File

@@ -3,6 +3,7 @@ import { TeamInfo, Team } from "../teams/types";
import { TournamentInfo } from "../tournaments/types";
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
export type MatchType = "group_stage" | "knockout" | "winners" | "losers" | "bracket";
export interface Match {
id: string;
@@ -29,6 +30,8 @@ export interface Match {
updated: string;
home_seed?: number;
away_seed?: number;
match_type?: MatchType;
group?: string;
}
export const matchInputSchema = z.object({
@@ -53,6 +56,8 @@ export const matchInputSchema = z.object({
away: z.string().min(1).optional(),
home_seed: z.number().int().min(1).optional(),
away_seed: z.number().int().min(1).optional(),
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
group: z.string().optional(),
});
export type MatchInput = z.infer<typeof matchInputSchema>;

View File

@@ -5,7 +5,7 @@ import { useAllPlayerStats } from "../queries";
import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
import Avatar from "@/components/avatar";
import PlayerAvatar from "@/components/player-avatar";
const LeagueHeadToHead = () => {
const [player1Id, setPlayer1Id] = useState<string | null>(null);
@@ -89,7 +89,7 @@ const LeagueHeadToHead = () => {
{player1Id ? (
<>
<Stack gap={4} align="center" style={{ flex: 1 }}>
<Avatar name={player1Name} size={36} />
<PlayerAvatar name={player1Name} size={36} disableFullscreen />
<Text size="xs" fw={600} ta="center" lineClamp={1}>
{player1Name}
</Text>
@@ -110,7 +110,7 @@ const LeagueHeadToHead = () => {
</>
) : (
<Stack gap={4} align="center">
<Avatar size={36} />
<PlayerAvatar size={36} disableFullscreen />
<Text size="xs" c="dimmed" fw={500}>
Player 1
</Text>
@@ -145,7 +145,7 @@ const LeagueHeadToHead = () => {
{player2Id ? (
<>
<Stack gap={4} align="center" style={{ flex: 1 }}>
<Avatar name={player2Name} size={36} />
<PlayerAvatar name={player2Name} size={36} disableFullscreen />
<Text size="xs" fw={600} ta="center" lineClamp={1}>
{player2Name}
</Text>
@@ -166,7 +166,7 @@ const LeagueHeadToHead = () => {
</>
) : (
<Stack gap={4} align="center">
<Avatar size={36} />
<PlayerAvatar size={36} disableFullscreen />
<Text size="xs" c="dimmed" fw={500}>
Player 2
</Text>
@@ -241,7 +241,7 @@ const LeagueHeadToHead = () => {
},
}}
>
<Avatar name={player.player_name} size={44} />
<PlayerAvatar name={player.player_name} size={44} disableFullscreen />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{player.player_name}

View File

@@ -1,6 +1,6 @@
import { List, ListItem, Skeleton, Text } from "@mantine/core";
import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar";
import PlayerAvatar from "@/components/player-avatar";
import { Player } from "@/features/players/types";
import { useCallback } from "react";
@@ -29,7 +29,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
{players?.map((player) => (
<ListItem key={player.id}
py='xs'
icon={<Avatar size={40} name={`${player.first_name} ${player.last_name}`} />}
icon={<PlayerAvatar size={40} name={`${player.first_name} ${player.last_name}`} disableFullscreen />}
style={{ cursor: 'pointer' }}
onClick={() => handleClick(player.id)}
>

View File

@@ -22,7 +22,7 @@ import {
InfoIcon,
} from "@phosphor-icons/react";
import { PlayerStats } from "../types";
import Avatar from "@/components/avatar";
import PlayerAvatar from "@/components/player-avatar";
import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries";
@@ -93,7 +93,7 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU
}}
>
<Group p={0} gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
<Avatar name={stat.player_name} size={40} style={{ flexShrink: 0 }} />
<PlayerAvatar name={stat.player_name} size={40} style={{ flexShrink: 0 }} disableFullscreen />
<Stack gap={2} style={{ flexGrow: 1, overflow: 'hidden', minWidth: 0 }}>
<Group gap='xs'>
<Text size="sm" fw={600}>

View File

@@ -4,7 +4,7 @@ import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import NameUpdateForm from "./name-form";
import Avatar from "@/components/avatar";
import PlayerAvatar from "@/components/player-avatar";
import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types";
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
@@ -41,7 +41,7 @@ const Header = ({ player }: HeaderProps) => {
<>
<Stack gap="sm" align="center" pt="md">
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={name} size={100} />
<PlayerAvatar name={name} size={100} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
<ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={nameSheet.open}>

View File

@@ -1,5 +1,8 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server";
import { logger } from '@/lib/supertokens';
let queryRefreshRedirect: Promise<void> | null = null;
export const playerKeys = {
auth: ['auth'],
@@ -54,24 +57,45 @@ export const playerQueries = {
export const useMe = () => {
const { queryKey, queryFn } = playerQueries.auth();
return useServerSuspenseQuery({
queryKey,
return useServerSuspenseQuery({
queryKey,
queryFn,
options: {
staleTime: 0,
refetchOnMount: true,
staleTime: 30 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: true,
retry: (failureCount, error: any) => {
if (error?.response?.status === 401) {
const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
const currentUrl = window.location.pathname + window.location.search;
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
if (errorData?.error === 'SESSION_REFRESH_REQUIRED') {
logger.warn("Query detected SESSION_REFRESH_REQUIRED");
if (!queryRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search;
logger.info("Query initiating refresh redirect to:", currentUrl);
queryRefreshRedirect = new Promise<void>((resolve) => {
setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
resolve();
}, 100);
});
queryRefreshRedirect.finally(() => {
setTimeout(() => {
queryRefreshRedirect = null;
}, 1000);
});
} else {
logger.info("Query: refresh redirect already in progress");
}
return false;
}
}
return failureCount < 3;
}
}
},
},
});
};

View File

@@ -0,0 +1,59 @@
import {
Paper,
Text,
Group,
Box,
Title
} from "@mantine/core";
import { useTeam } from "../queries";
import TeamAvatar from "@/components/team-avatar";
interface RegionalTeamCardProps {
teamId: string;
}
const RegionalTeamCard = ({ teamId }: RegionalTeamCardProps) => {
const { data: team, error } = useTeam(teamId);
if (error || !team) {
return (
<Paper p="sm" withBorder radius="md">
<Text c="red" ta="center" size="sm">
Failed to load team
</Text>
</Paper>
);
}
return (
<Paper
withBorder
radius="lg"
shadow="xs"
p="xs"
>
<Group gap="md" align="center">
<TeamAvatar
team={team}
size={40}
radius="md"
isRegional={true}
style={{
backgroundColor: team.primary_color || undefined,
color: team.accent_color || undefined,
}}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<Title order={5} lineClamp={1}>
{team.name}
</Title>
<Text size="sm" c="dimmed" lineClamp={1}>
{team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')}
</Text>
</Box>
</Group>
</Paper>
);
};
export default RegionalTeamCard;

View File

@@ -8,7 +8,7 @@ import {
Title
} from "@mantine/core";
import { useTeam } from "../queries";
import Avatar from "@/components/avatar";
import TeamAvatar from "@/components/team-avatar";
import SongSummary from "./team-form/song-summary";
interface TeamCardProps {
@@ -46,11 +46,10 @@ const TeamCard = ({ teamId }: TeamCardProps) => {
>
<Stack gap={2}>
<Group gap="md" align="center" p="xs">
<Avatar
name={team.name}
<TeamAvatar
team={team}
size={40}
radius="md"
src={team.logo ? `/api/files/teams/${team.id}/${team.logo}` : undefined}
style={{
backgroundColor: team.primary_color || undefined,
color: team.accent_color || undefined,

View File

@@ -7,7 +7,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import Avatar from "@/components/avatar";
import TeamAvatar from "@/components/team-avatar";
import { TeamInfo } from "@/features/teams/types";
import { useNavigate } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
@@ -31,7 +31,7 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
return (
<Group justify="space-between" w="100%" wrap="nowrap">
<Text fw={500} size={teamNameSize} style={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Text fw={500} size={teamNameSize} style={{ flexShrink: 1, minWidth: 0, maxWidth: 170, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{`${team.name}`}
</Text>
<Stack ml="auto" gap={0} style={{ flexShrink: 0 }}>
@@ -49,9 +49,10 @@ interface TeamListProps {
teams: TeamInfo[];
loading?: boolean;
onTeamClick?: (teamId: string) => void;
isRegional?: boolean;
}
const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
const TeamList = ({ teams, loading = false, onTeamClick, isRegional }: TeamListProps) => {
const navigate = useNavigate();
const handleClick = useCallback(
@@ -88,15 +89,11 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
key={`team-list-${team.id}`}
p="xs"
icon={
<Avatar
<TeamAvatar
team={team}
radius="sm"
size={40}
name={`${team.name}`}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
isRegional={isRegional}
/>
}
style={{ cursor: "pointer" }}

View File

@@ -19,7 +19,7 @@ const Header = ({ name, logo, id }: HeaderProps) => {
src={logo && id ? `/api/files/teams/${id}/${logo}` : undefined}
/>
<Flex align="center" justify="center" gap={4} pb={20} w="100%">
<Title ta="center" order={1}>
<Title ta="center" order={name.length > 25 ? 2 : 1}>
{name}
</Title>
</Flex>

View File

@@ -51,6 +51,7 @@ export const teamInputSchema = z
song_start: z.number().int().optional(),
song_end: z.number().int().optional(),
song_image_url: z.url("Invalid song image URL").optional(),
private: z.boolean().optional(),
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
})
.refine(

View File

@@ -0,0 +1,162 @@
import {
Stack,
ActionIcon,
Text,
Group,
Loader,
} from "@mantine/core";
import { TrashIcon } from "@phosphor-icons/react";
import { useCallback, memo } from "react";
import { useFreeAgents } from "../queries";
import PlayerAvatar from "@/components/player-avatar";
import { PlayerInfo, Player } from "@/features/players/types";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { usePlayers } from "@/features/players/queries";
import useAdminEnrollPlayer from "../hooks/use-admin-enroll-player";
import useAdminUnenrollPlayer from "../hooks/use-admin-unenroll-player";
interface EditEnrolledPlayersProps {
tournamentId: string;
}
interface PlayerItemProps {
player: PlayerInfo;
onRemove: (playerId: string) => void;
disabled: boolean;
}
const PlayerItem = memo(({ player, onRemove, disabled }: PlayerItemProps) => {
return (
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<PlayerAvatar
name={`${player.first_name} ${player.last_name}`}
size={32}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} truncate>
{player.first_name} {player.last_name}
</Text>
</Stack>
<ActionIcon
variant="subtle"
color="red"
onClick={() => onRemove(player.id)}
disabled={disabled}
size="sm"
>
<TrashIcon size={14} />
</ActionIcon>
</Group>
);
});
const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
const { data: allPlayers = [] } = usePlayers();
const { mutate: removeFreeAgent, isPending: isRemoving } = useAdminUnenrollPlayer();
const { mutate: enrollPlayer, isPending: isEnrolling } = useAdminEnrollPlayer();
const handleRemovePlayer = useCallback(
(playerId: string) => {
removeFreeAgent({ tournamentId, playerId });
},
[removeFreeAgent, tournamentId]
);
const handleEnrollPlayer = useCallback(
(option: TypeaheadOption<Player>) => {
enrollPlayer({ tournamentId, playerId: option.data.id });
},
[enrollPlayer, tournamentId]
);
const enrolledPlayers = freeAgents.map(agent => agent.player).filter((p): p is PlayerInfo => p !== undefined);
const enrolledPlayerIds = new Set(enrolledPlayers.map(p => p.id));
const hasEnrolledPlayers = enrolledPlayers.length > 0;
const searchPlayers = async (query: string): Promise<TypeaheadOption<Player>[]> => {
if (!query.trim()) return [];
const filtered = allPlayers.filter((player: Player) => {
const fullName = `${player.first_name} ${player.last_name}`.toLowerCase();
return fullName.includes(query.toLowerCase()) && !enrolledPlayerIds.has(player.id);
});
return filtered.map((player: Player) => ({
id: player.id,
data: player
}));
};
const renderPlayerOption = (option: TypeaheadOption<Player>) => {
const player = option.data;
return (
<Group py="xs" px="sm" gap="sm" align="center">
<PlayerAvatar
name={`${player.first_name} ${player.last_name}`}
size={32}
/>
<Text fw={500} truncate>
{player.first_name} {player.last_name}
</Text>
</Group>
);
};
const formatPlayer = (option: TypeaheadOption<Player>) => {
return `${option.data.first_name} ${option.data.last_name}`;
};
return (
<Stack gap="lg" w="100%">
<Stack gap="xs">
<Text fw={600} size="sm">
Add Player
</Text>
<Typeahead
placeholder="Search for players to enroll..."
onSelect={handleEnrollPlayer}
searchFn={searchPlayers}
renderOption={renderPlayerOption}
format={formatPlayer}
disabled={isEnrolling}
/>
</Stack>
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">
Enrolled Players
</Text>
<Text size="xs" c="dimmed">
{enrolledPlayers.length} players
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : !hasEnrolledPlayers ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
No players enrolled yet
</Text>
) : (
<Stack gap="xs" w="100%">
{enrolledPlayers.map((player) => (
<PlayerItem
key={player.id}
player={player}
onRemove={handleRemovePlayer}
disabled={isRemoving}
/>
))}
</Stack>
)}
</Stack>
</Stack>
);
};
export default EditEnrolledPlayers;

View File

@@ -11,7 +11,7 @@ import { useState, useCallback, useMemo, memo } from "react";
import { useTournament, useUnenrolledTeams } from "../queries";
import useEnrollTeam from "../hooks/use-enroll-team";
import useUnenrollTeam from "../hooks/use-unenroll-team";
import Avatar from "@/components/avatar";
import TeamAvatar from "@/components/team-avatar";
import { Team, TeamInfo } from "@/features/teams/types";
interface EditEnrolledTeamsProps {
@@ -22,9 +22,10 @@ interface TeamItemProps {
team: TeamInfo;
onUnenroll: (teamId: string) => void;
disabled: boolean;
isRegional?: boolean;
}
const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
const TeamItem = memo(({ team, onUnenroll, disabled, isRegional }: TeamItemProps) => {
const playerNames = useMemo(
() =>
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
@@ -34,15 +35,11 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
return (
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<Avatar
<TeamAvatar
team={team}
size={32}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
isRegional={isRegional}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} truncate>
@@ -73,6 +70,8 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
useUnenrolledTeams(tournamentId);
const isRegional = tournament?.regional;
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
@@ -107,15 +106,11 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const team = option.data;
return (
<Group py="xs" px="sm" gap="sm" align="center">
<Avatar
<TeamAvatar
team={team as any}
size={32}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
isRegional={isRegional}
/>
<Text fw={500} truncate>
{team.name}
@@ -174,6 +169,7 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
team={team}
onUnenroll={handleUnenrollTeam}
disabled={isUnenrolling}
isRegional={isRegional}
/>
))}
</Stack>

View File

@@ -0,0 +1,201 @@
import React, { useCallback } from "react";
import { Card, Group, Stack, Text, ActionIcon, Indicator, Flex, Box } from "@mantine/core";
import { PlayIcon, PencilIcon } from "@phosphor-icons/react";
import { Match } from "@/features/matches/types";
import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { endMatch, startMatch } from "@/features/matches/server";
import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query";
import { MatchForm } from "@/features/bracket/components/match-form";
import TeamAvatar from "@/components/team-avatar";
interface GroupMatchCardProps {
match: Match;
showControls?: boolean;
}
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls }) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
const isReady = match.status === "ready";
const isStarted = match.status === "started";
const isEnded = match.status === "ended";
const homeWon = isEnded && match.home_cups !== undefined && match.away_cups !== undefined && match.home_cups > match.away_cups;
const awayWon = isEnded && match.away_cups !== undefined && match.home_cups !== undefined && match.away_cups > match.home_cups;
const start = useServerMutation({
mutationFn: startMatch,
successMessage: "Match started!",
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id),
});
},
});
const end = useServerMutation({
mutationFn: endMatch,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id),
});
editSheet.close();
},
});
const handleFormSubmit = useCallback(
async (data: {
home_cups: number;
away_cups: number;
ot_count: number;
}) => {
end.mutate({
data: {
...data,
matchId: match.id,
},
});
},
[end, match.id]
);
const handleStartMatch = () => {
start.mutate({ data: match.id });
};
const showStartButton = isReady && showControls;
const showEditButton = isStarted && showControls;
return (
<>
<Flex direction="row" align="stretch">
<Indicator
inline
processing={isStarted}
color="red"
size={16}
disabled={!isStarted || showEditButton}
style={{ flex: 1 }}
>
<Card
withBorder
radius="md"
p="md"
style={{
position: 'relative',
width: '100%',
}}
>
<Stack gap="sm">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
{match.home && <TeamAvatar team={match.home} size={32} radius="sm" isRegional={match.tournament.regional} />}
<Text
size="sm"
fw={homeWon ? 700 : 500}
style={{ flex: 1, minWidth: 0 }}
lineClamp={1}
>
{match.home?.name || "TBD"}
</Text>
</Group>
{isEnded && match.home_cups !== undefined && (
<Text
size="xl"
fw={700}
c={homeWon ? "green" : "dimmed"}
style={{ minWidth: 32, textAlign: 'center' }}
>
{match.home_cups}
</Text>
)}
</Group>
<Box
style={{
height: 1,
backgroundColor: 'var(--mantine-color-default-border)',
}}
/>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
{match.away && <TeamAvatar team={match.away} size={32} radius="sm" isRegional={match.tournament.regional} />}
<Text
size="sm"
fw={awayWon ? 700 : 500}
style={{ flex: 1, minWidth: 0 }}
lineClamp={1}
>
{match.away?.name || "TBD"}
</Text>
</Group>
{isEnded && match.away_cups !== undefined && (
<Text
size="xl"
fw={700}
c={awayWon ? "green" : "dimmed"}
style={{ minWidth: 32, textAlign: 'center' }}
>
{match.away_cups}
</Text>
)}
</Group>
</Stack>
</Card>
</Indicator>
{showStartButton && (
<ActionIcon
color="green"
onClick={handleStartMatch}
loading={start.isPending}
size="md"
h="100%"
radius="sm"
ml={-4}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
>
<PlayIcon size={16} weight="fill" />
</ActionIcon>
)}
{showEditButton && (
<ActionIcon
color="blue"
onClick={editSheet.open}
size="md"
h="100%"
radius="sm"
ml={-4}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
>
<PencilIcon size={16} />
</ActionIcon>
)}
</Flex>
{showControls && (
<Sheet title="End Match" opened={editSheet.isOpen} onChange={editSheet.toggle}>
<MatchForm
match={match}
onSubmit={handleFormSubmit}
onCancel={editSheet.close}
/>
</Sheet>
)}
</>
);
};
export default GroupMatchCard;

View File

@@ -0,0 +1,50 @@
import { Stack, Text, Card, Group, Box } from "@mantine/core";
import { TeamInfo } from "@/features/teams/types";
import TeamAvatar from "@/components/team-avatar";
interface GroupAssignment {
groupIndex: number;
groupName: string;
teams: TeamInfo[];
}
interface GroupPreviewProps {
groups: GroupAssignment[];
}
const GroupPreview: React.FC<GroupPreviewProps> = ({ groups }) => {
return (
<Stack gap="md">
{groups.map((group) => (
<Card key={group.groupIndex} withBorder radius="md" p="md" w="fit-content">
<Stack gap="sm">
<Group gap="xs" align="center">
<Text fw={600} size="sm">
Group {group.groupName}
</Text>
<Text size="xs" c="dimmed">
({group.teams.length} teams)
</Text>
</Group>
<Stack gap="xs">
{group.teams.map((team, index) => (
<Group key={team.id} gap="sm" align="center" wrap="nowrap">
<Text size="xs" c="dimmed" w={20} ta="right">
{index + 1}
</Text>
<TeamAvatar team={team} size={24} radius="sm" isRegional />
<Text size="sm" truncate style={{ flex: 1 }}>
{team.name}
</Text>
</Group>
))}
</Stack>
</Stack>
</Card>
))}
</Stack>
);
};
export default GroupPreview;

View File

@@ -0,0 +1,474 @@
import React, { useMemo, useState } from "react";
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert, Badge } from "@mantine/core";
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
import { Match } from "@/features/matches/types";
import { Group, GroupConfig } from "../types";
import GroupMatchCard from "./group-match-card";
import TeamAvatar from "@/components/team-avatar";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { populateKnockoutBracket } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../queries";
interface GroupStageViewProps {
groups: Group[];
matches: Match[];
showControls?: boolean;
tournamentId?: string;
hasKnockoutBracket?: boolean;
isRegional?: boolean;
groupConfig?: GroupConfig;
}
interface TeamStanding {
teamId: string;
teamName: string;
team: any;
wins: number;
losses: number;
cupsFor: number;
cupsAgainst: number;
cupDifference: number;
}
const GroupStageView: React.FC<GroupStageViewProps> = ({
groups,
matches,
showControls,
tournamentId,
hasKnockoutBracket,
isRegional,
groupConfig,
}) => {
const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
const populateKnockoutMutation = useServerMutation({
mutationFn: populateKnockoutBracket,
successMessage: "Knockout bracket populated successfully!",
onSuccess: () => {
if (tournamentId) {
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
}
},
});
const allGroupMatchesCompleted = useMemo(() => {
const groupMatches = matches.filter((match) => match.round === -1);
if (groupMatches.length === 0) return false;
return groupMatches.every((match) => match.status === "ended");
}, [matches]);
const handlePopulateKnockout = () => {
if (!tournamentId) return;
populateKnockoutMutation.mutate({ data: { tournamentId } });
};
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
if (matches.length <= 1) return matches;
const ordered: Match[] = [];
const remaining = [...matches];
ordered.push(remaining.shift()!);
while (remaining.length > 0) {
const lastMatch = ordered[ordered.length - 1];
const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
let bestMatchIndex = remaining.findIndex((match) => {
const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
for (const teamId of currentTeams) {
if (lastTeams.has(teamId)) return false;
}
return true;
});
if (bestMatchIndex === -1) {
bestMatchIndex = 0;
}
ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
}
return ordered;
};
const matchesByGroup = useMemo(() => {
const map = new Map<string, Match[]>();
matches
.filter((match) => match.round === -1 && match.group)
.forEach((match) => {
if (!map.has(match.group!)) {
map.set(match.group!, []);
}
map.get(match.group!)!.push(match);
});
map.forEach((groupMatches, groupId) => {
map.set(groupId, orderMatchesWithSpacing(groupMatches));
});
return map;
}, [matches]);
const sortedGroups = useMemo(() => {
return [...groups].sort((a, b) => a.order - b.order);
}, [groups]);
const toggleTeams = (groupId: string) => {
setExpandedTeams((prev) => ({
...prev,
[groupId]: !prev[groupId],
}));
};
const getTeamStandings = (groupId: string, teams: any[]): TeamStanding[] => {
const groupMatches = matchesByGroup.get(groupId) || [];
const standings: Map<string, TeamStanding> = new Map();
teams.forEach((team) => {
standings.set(team.id, {
teamId: team.id,
teamName: team.name,
team: team,
wins: 0,
losses: 0,
cupsFor: 0,
cupsAgainst: 0,
cupDifference: 0,
});
});
const completedMatches = groupMatches.filter((match) => match.status === "ended");
completedMatches.forEach((match) => {
const homeId = match.home?.id;
const awayId = match.away?.id;
if (!homeId || !awayId) return;
const homeStanding = standings.get(homeId);
const awayStanding = standings.get(awayId);
if (!homeStanding || !awayStanding) return;
const homeCups = match.home_cups || 0;
const awayCups = match.away_cups || 0;
homeStanding.cupsFor += homeCups;
homeStanding.cupsAgainst += awayCups;
awayStanding.cupsFor += awayCups;
awayStanding.cupsAgainst += homeCups;
homeStanding.cupDifference += homeCups - awayCups;
awayStanding.cupDifference += awayCups - homeCups;
if (homeCups > awayCups) {
homeStanding.wins++;
awayStanding.losses++;
} else if (awayCups > homeCups) {
awayStanding.wins++;
homeStanding.losses++;
}
});
const h2hRecords = new Map<string, Map<string, { wins: number; cupDiff: number }>>();
completedMatches.forEach((match) => {
const homeId = match.home?.id;
const awayId = match.away?.id;
if (!homeId || !awayId) return;
if (!h2hRecords.has(homeId)) {
h2hRecords.set(homeId, new Map());
}
if (!h2hRecords.has(awayId)) {
h2hRecords.set(awayId, new Map());
}
const homeH2H = h2hRecords.get(homeId)!;
const awayH2H = h2hRecords.get(awayId)!;
if (!homeH2H.has(awayId)) {
homeH2H.set(awayId, { wins: 0, cupDiff: 0 });
}
if (!awayH2H.has(homeId)) {
awayH2H.set(homeId, { wins: 0, cupDiff: 0 });
}
const homeRecord = homeH2H.get(awayId)!;
const awayRecord = awayH2H.get(homeId)!;
const homeCups = match.home_cups || 0;
const awayCups = match.away_cups || 0;
const cupDiff = homeCups - awayCups;
homeRecord.cupDiff += cupDiff;
awayRecord.cupDiff -= cupDiff;
if (homeCups > awayCups) {
homeRecord.wins++;
} else if (awayCups > homeCups) {
awayRecord.wins++;
}
});
return Array.from(standings.values()).sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor;
const aH2H = h2hRecords.get(a.teamId);
const bH2H = h2hRecords.get(b.teamId);
if (aH2H && bH2H) {
const aVsB = aH2H.get(b.teamId);
const bVsA = bH2H.get(a.teamId);
if (aVsB && bVsA) {
if (aVsB.wins !== bVsA.wins) return bVsA.wins - aVsB.wins;
if (aVsB.cupDiff !== -bVsA.cupDiff) return aVsB.cupDiff - (-bVsA.cupDiff);
}
}
return a.teamId.localeCompare(b.teamId);
});
};
const allGroupStandings = useMemo(() => {
return sortedGroups.map((group) => ({
groupId: group.id,
groupOrder: group.order,
standings: getTeamStandings(group.id, group.teams || []),
}));
}, [sortedGroups, matchesByGroup]);
const advancingTeams = useMemo(() => {
if (!groupConfig) return { qualifiedTeams: new Set<string>(), wildcardTeams: new Set<string>() };
const advancePerGroup = groupConfig.advance_per_group;
const qualifiedTeams = new Set<string>();
const wildcardTeams = new Set<string>();
allGroupStandings.forEach(({ standings }) => {
standings.slice(0, advancePerGroup).forEach((standing) => {
qualifiedTeams.add(standing.teamId);
});
});
const totalQualified = qualifiedTeams.size;
const knockoutTeamCount = totalQualified;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
if (wildcardsNeeded > 0) {
const allNonQualifiedTeams: TeamStanding[] = [];
allGroupStandings.forEach(({ standings }) => {
standings.slice(advancePerGroup).forEach((standing) => {
allNonQualifiedTeams.push(standing);
});
});
allNonQualifiedTeams.sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor;
return a.teamId.localeCompare(b.teamId);
});
allNonQualifiedTeams.slice(0, wildcardsNeeded).forEach((standing) => {
wildcardTeams.add(standing.teamId);
});
}
return { qualifiedTeams, wildcardTeams };
}, [allGroupStandings, groupConfig]);
if (sortedGroups.length === 0) {
return (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
No groups have been created yet
</Text>
</Box>
);
}
const showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted;
return (
<Box p="md">
<Stack gap="md">
{showGenerateKnockoutButton && (
<Alert color="blue" title="Group Stage Complete" icon={<CaretCircleUpIcon size={20} />}>
<Stack gap="xs">
<Text size="sm">All group matches are finished! Populate the knockout bracket to advance qualified teams.</Text>
<Button
onClick={handlePopulateKnockout}
loading={populateKnockoutMutation.isPending}
size="sm"
>
Populate Knockout Bracket
</Button>
</Stack>
</Alert>
)}
<Tabs defaultValue={sortedGroups[0]?.id}>
<Tabs.List mb="md" grow>
{sortedGroups.map((group) => {
const groupMatches = matchesByGroup.get(group.id) || [];
const completedMatches = groupMatches.filter((m) => m.status === "ended").length;
const totalMatches = groupMatches.length;
return (
<Tabs.Tab
key={group.id}
value={group.id}
style={{
padding: '12px 20px',
}}
>
<MantineGroup gap="xs" justify="center" wrap="nowrap">
<Text fw={600} size="sm">Group {group.name}</Text>
<Text size="xs" c="dimmed" style={{ opacity: 0.7 }}>
({completedMatches}/{totalMatches})
</Text>
</MantineGroup>
</Tabs.Tab>
);
})}
</Tabs.List>
{sortedGroups.map((group) => {
const groupMatches = matchesByGroup.get(group.id) || [];
const standings = getTeamStandings(group.id, group.teams || []);
return (
<Tabs.Panel key={group.id} value={group.id}>
<Stack gap="md">
<Card withBorder radius="md" p={0}>
<MantineGroup
justify="space-between"
p="sm"
style={{
cursor: 'pointer',
backgroundColor: 'var(--mantine-color-default-hover)',
}}
onClick={() => toggleTeams(group.id)}
>
<Text fw={600} size="sm">
Standings ({standings.length})
</Text>
<ActionIcon variant="subtle" size="sm">
{expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />}
</ActionIcon>
</MantineGroup>
<Collapse in={expandedTeams[group.id]}>
<Stack gap={0}>
{standings.length > 0 ? (
standings.map((standing, index) => {
const isQualified = advancingTeams.qualifiedTeams.has(standing.teamId);
const isWildcard = advancingTeams.wildcardTeams.has(standing.teamId);
const isAdvancing = isQualified || isWildcard;
return (
<MantineGroup
key={standing.teamId}
gap="sm"
align="center"
wrap="nowrap"
px="md"
py="xs"
style={{
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
backgroundColor: isAdvancing ? 'var(--mantine-primary-color-light)' : undefined,
borderLeft: isAdvancing ? '3px solid var(--mantine-primary-color-filled)' : '3px solid transparent',
}}
>
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
{index + 1}
</Text>
<TeamAvatar team={standing.team} size={28} radius="sm" isRegional={isRegional} />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{standing.teamName}
</Text>
</Box>
<MantineGroup gap="xs" wrap="nowrap">
{isWildcard && (
<Badge size="xs" color="yellow" variant="light">
WC
</Badge>
)}
{isQualified && (
<Badge size="xs" variant="light">
Q
</Badge>
)}
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
{standing.wins}-{standing.losses}
</Text>
<Text
size="xs"
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
fw={600}
miw={30}
ta="center"
>
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
</Text>
<Text size="xs" c="dimmed" fw={400} miw={40} ta="center">
{standing.cupsFor}/{standing.cupsAgainst}
</Text>
</MantineGroup>
</MantineGroup>
);
})
) : (
<Text size="sm" c="dimmed" ta="center" py="md">
No teams assigned
</Text>
)}
</Stack>
</Collapse>
</Card>
{groupMatches.length === 0 ? (
<Card withBorder radius="md" p="xl">
<Text size="sm" c="dimmed" ta="center">
No matches scheduled
</Text>
</Card>
) : (
<SimpleGrid
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="md"
>
{groupMatches.map((match) => (
<GroupMatchCard
key={match.id}
match={match}
showControls={showControls}
/>
))}
</SimpleGrid>
)}
</Stack>
</Tabs.Panel>
);
})}
</Tabs>
</Stack>
</Box>
);
};
export default GroupStageView;

View File

@@ -9,11 +9,12 @@ import {
TreeStructureIcon,
UsersThreeIcon,
UsersIcon,
ShuffleIcon,
} from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams";
import EditEnrolledPlayers from "./edit-enrolled-players";
import ListLink from "@/components/list-link";
import { RichTextEditor } from "@/components/rich-text-editor";
import React from "react";
import EditRules from "./edit-rules";
@@ -61,11 +62,20 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
<ListLink
label="Manage Team Songs/Logos"
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
{tournament.regional && (
<ListLink
label="Assign Partners"
Icon={ShuffleIcon}
to={`/admin/tournaments/${tournamentId}/assign-partners`}
/>
)}
{!tournament.regional && (
<ListLink
label="Manage Team Songs/Logos"
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
)}
<ListLink
label="Run Tournament"
Icon={TreeStructureIcon}
@@ -87,6 +97,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
start_time: tournament.start_time,
enroll_time: tournament.enroll_time,
end_time: tournament.end_time,
regional: tournament.regional,
}}
close={closeEditTournament}
/>
@@ -101,11 +112,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
</Sheet>
<Sheet
title="Edit Enrolled Teams"
title={tournament.regional === true ? "Manage Enrollments" : "Edit Enrolled Teams"}
opened={editTeamsOpened}
onChange={closeEditTeams}
>
<EditEnrolledTeams tournamentId={tournamentId} />
{tournament.regional === true ? (
<EditEnrolledPlayers tournamentId={tournamentId} />
) : (
<EditEnrolledTeams tournamentId={tournamentId} />
)}
</Sheet>
</>
);

View File

@@ -27,7 +27,7 @@ const Profile = ({ id }: ProfileProps) => {
{
label: "Teams",
content: <>
<TeamList teams={tournament.teams || []} />
<TeamList teams={tournament.teams || []} isRegional={tournament.regional} />
</>
}
], [tournament]);

View File

@@ -13,7 +13,7 @@ import { DotsNineIcon } from "@phosphor-icons/react";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { generateTournamentBracket } from "../../matches/server";
import { TeamInfo } from "@/features/teams/types";
import Avatar from "@/components/avatar";
import TeamAvatar from "@/components/team-avatar";
import { useBracketPreview } from "@/features/bracket/queries";
import { BracketData } from "@/features/bracket/types";
import BracketView from "@/features/bracket/components/bracket-view";
@@ -23,11 +23,13 @@ import { tournamentKeys } from "../queries";
interface SeedTournamentProps {
tournamentId: string;
teams: TeamInfo[];
isRegional?: boolean;
}
const SeedTournament: React.FC<SeedTournamentProps> = ({
tournamentId,
teams,
isRegional,
}) => {
const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams);
const { data: bracketPreview } = useBracketPreview(teams.length);
@@ -171,15 +173,11 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
}}
/>
<Avatar
<TeamAvatar
team={team}
size={24}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
isRegional={isRegional}
/>
<Text fw={500} size="sm" style={{ flex: 1 }}>

View File

@@ -0,0 +1,305 @@
import React, { useState, useMemo } from "react";
import {
Stack,
Text,
Group,
Button,
Select,
LoadingOverlay,
Alert,
Title,
Divider,
Box,
} from "@mantine/core";
import { InfoIcon } from "@phosphor-icons/react";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { generateGroupStage } from "../server";
import { TeamInfo } from "@/features/teams/types";
import {
calculateGroupConfigurations,
assignTeamsToGroups,
getGroupName,
GroupConfigOption,
} from "../utils/group-config";
import GroupPreview from "./group-preview";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../queries";
import brackets from "@/features/bracket/utils";
import { Bracket } from "@/features/bracket/components/bracket";
import { Match } from "@/features/matches/types";
import { generateSingleEliminationBracket } from "../utils/bracket-generator";
interface SetupGroupStageProps {
tournamentId: string;
teams: TeamInfo[];
}
const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
tournamentId,
teams,
}) => {
const queryClient = useQueryClient();
const [selectedConfigIndex, setSelectedConfigIndex] = useState<string>("0");
const [seed, setSeed] = useState<number>(Date.now());
const configurations = useMemo(() => {
try {
return calculateGroupConfigurations(teams.length);
} catch (error) {
return [];
}
}, [teams.length]);
const selectedConfig: GroupConfigOption | null = useMemo(() => {
const index = parseInt(selectedConfigIndex);
return configurations[index] || null;
}, [selectedConfigIndex, configurations]);
const groupAssignments = useMemo(() => {
if (!selectedConfig) return [];
const teamIds = teams.map((t) => t.id);
const assignments = assignTeamsToGroups(teamIds, selectedConfig, seed);
return assignments.map((teamIds, index) => ({
groupIndex: index,
groupName: getGroupName(index),
teams: teamIds.map((id) => teams.find((t) => t.id === id)!).filter(Boolean),
teamIds,
}));
}, [selectedConfig, teams, seed]);
const knockoutTeamCount = useMemo(() => {
if (!selectedConfig) return 0;
return selectedConfig.num_groups * selectedConfig.advance_per_group;
}, [selectedConfig]);
const bracketPreview = useMemo(() => {
if (!knockoutTeamCount || !selectedConfig) {
return null;
}
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const bracketSize = nextPowerOf2;
const wildcardsNeeded = bracketSize - knockoutTeamCount;
let bracketTemplate: any;
if (Object.keys(brackets).includes(bracketSize.toString())) {
bracketTemplate = brackets[bracketSize as keyof typeof brackets];
} else {
try {
bracketTemplate = generateSingleEliminationBracket(bracketSize);
} catch (error) {
return null;
}
}
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const seedLabels: Record<number, string> = {};
const totalTeams = selectedConfig.num_groups * selectedConfig.advance_per_group;
let seedIndex = 1;
for (let i = 0; i < totalTeams / 2; i++) {
const group1 = i % selectedConfig.num_groups;
const rankIndex1 = Math.floor(i / selectedConfig.num_groups);
const rank1 = rankIndex1 + 1;
const groupName1 = groupNames[group1] || `Group ${group1 + 1}`;
const rankSuffix1 = rank1 === 1 ? '1st' : rank1 === 2 ? '2nd' : rank1 === 3 ? '3rd' : `${rank1}th`;
seedLabels[seedIndex++] = `${groupName1} ${rankSuffix1}`;
const group2 = (i + 1) % selectedConfig.num_groups;
const rankIndex2 = selectedConfig.advance_per_group - 1 - rankIndex1;
const rank2 = rankIndex2 + 1;
const groupName2 = groupNames[group2] || `Group ${group2 + 1}`;
const rankSuffix2 = rank2 === 1 ? '1st' : rank2 === 2 ? '2nd' : rank2 === 3 ? '3rd' : `${rank2}th`;
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
}
for (let i = 0; i < wildcardsNeeded; i++) {
seedLabels[seedIndex++] = `Wildcard ${i + 1}`;
}
const ordersMap: Record<number, number> = {};
bracketTemplate.winners.forEach((round: any[]) => {
round.forEach((match: any) => {
ordersMap[match.lid] = match.order;
});
});
const placeholderMatches: Match[][] = bracketTemplate.winners.map((round: any[], roundIndex: number) =>
round.map((match: any) => {
const matchData: any = {
...match,
id: `preview-${match.lid}`,
home_from_lid: match.home_from_lid !== null && match.home_from_lid !== undefined ? match.home_from_lid : -1,
away_from_lid: match.away_from_lid !== null && match.away_from_lid !== undefined ? match.away_from_lid : -1,
home_cups: 0,
away_cups: 0,
status: "tbd" as const,
tournament: { id: "", name: "" },
};
if (roundIndex === 0) {
matchData.home = match.home_seed && !match.bye ? { id: `seed-${match.home_seed}`, name: seedLabels[match.home_seed] } : null;
matchData.away = match.away_seed ? { id: `seed-${match.away_seed}`, name: seedLabels[match.away_seed] } : null;
} else {
matchData.home = null;
matchData.away = null;
}
return matchData;
})
);
return { matches: placeholderMatches, orders: ordersMap };
}, [knockoutTeamCount, selectedConfig]);
const generateGroups = useServerMutation({
mutationFn: generateGroupStage,
successMessage: "Group stage generated successfully!",
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(tournamentId),
});
},
});
const handleGenerateGroups = () => {
if (!selectedConfig) return;
generateGroups.mutate({
data: {
tournamentId,
groupConfig: {
num_groups: selectedConfig.num_groups,
teams_per_group: selectedConfig.teams_per_group,
advance_per_group: selectedConfig.advance_per_group,
matches_guaranteed: selectedConfig.matches_guaranteed,
seeding_method: selectedConfig.seeding_method,
},
teamAssignments: groupAssignments.map((g) => ({
groupIndex: g.groupIndex,
groupName: g.groupName,
teamIds: g.teamIds,
})),
seed,
},
});
};
const handleShuffle = () => {
setSeed(Date.now());
};
if (configurations.length === 0) {
return (
<Alert color="red" title="Cannot create groups" icon={<InfoIcon />}>
Need at least 4 teams to create a group stage format. Current team count: {teams.length}
</Alert>
);
}
return (
<div style={{ display: "flex", gap: "2rem", alignItems: "flex-start" }}>
<Stack gap="lg" style={{ flexShrink: 0, width: 400 }}>
<Stack gap={0} pos="relative">
<LoadingOverlay visible={generateGroups.isPending} />
<Group gap="xs" p="md" pb="sm" align="center">
<Text fw={600} size="lg">
Group Stage Setup
</Text>
<Text size="sm" c="dimmed" ml="auto">
{teams.length} teams
</Text>
</Group>
<Stack gap="md" p="md" pt={0}>
<Stack gap="xs">
<Text size="sm" fw={500}>
Group Configuration
</Text>
<Select
value={selectedConfigIndex}
onChange={(value) => setSelectedConfigIndex(value || "0")}
data={configurations.map((config, index) => ({
value: index.toString(),
label: config.description,
}))}
styles={{
dropdown: { maxHeight: 300, overflowY: "auto" },
}}
/>
</Stack>
{selectedConfig && (
<Stack gap="xs">
<Text size="xs" c="dimmed">
{selectedConfig.total_group_matches} total group stage matches
</Text>
{selectedConfig.wildcards_needed > 0 && (
<Text size="xs" c="yellow">
{selectedConfig.wildcards_needed} wildcard spot{selectedConfig.wildcards_needed > 1 ? 's' : ''} needed for knockout bracket
</Text>
)}
</Stack>
)}
<Button size="sm" variant="light" onClick={handleShuffle}>
Shuffle Groups
</Button>
<Button
size="sm"
onClick={handleGenerateGroups}
loading={generateGroups.isPending}
disabled={!selectedConfig}
>
Confirm Setup
</Button>
</Stack>
</Stack>
</Stack>
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
<Stack gap="xl">
{groupAssignments.length > 0 && (
<GroupPreview groups={groupAssignments} />
)}
{bracketPreview && knockoutTeamCount > 0 && (
<Box>
<Divider mb="lg" />
<Title order={3} ta="center" mb="md">
Knockout Bracket Preview ({selectedConfig?.knockout_size} Teams)
</Title>
<Text size="sm" c="dimmed" ta="center" mb="lg">
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
{selectedConfig?.wildcards_needed ? ` + ${selectedConfig.wildcards_needed} wildcard${selectedConfig.wildcards_needed > 1 ? 's' : ''}` : ''}
</Text>
<Box
style={{
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
padding: "1rem",
borderRadius: "8px",
}}
>
<Bracket
rounds={bracketPreview.matches}
orders={bracketPreview.orders}
showControls={false}
/>
</Box>
</Box>
)}
</Stack>
</div>
</div>
);
};
export default SetupGroupStage;

View File

@@ -4,7 +4,7 @@ import { useAuth } from "@/contexts/auth-context";
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import ListLink from "@/components/list-link";
import { TreeStructureIcon, UsersIcon, ClockIcon, TrophyIcon } from "@phosphor-icons/react";
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
import TeamListButton from "../upcoming-tournament/team-list-button";
import RulesListButton from "../upcoming-tournament/rules-list-button";
import MatchCard from "@/features/matches/components/match-card";
@@ -37,6 +37,10 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
return finalsMatch?.status === 'ended';
}, [tournament.matches]);
const hasGroupStage = useMemo(() => {
return tournament.matches?.some((match) => match.round === -1) || false;
}, [tournament.matches]);
return (
<Stack gap="lg">
<Header tournament={tournament} />
@@ -83,12 +87,19 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
Icon={UsersIcon}
/>
)}
{hasGroupStage && (
<ListLink
label={`View Groups`}
to={`/tournaments/${tournament.id}/groups`}
Icon={ListDashes}
/>
)}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
<TeamListButton teams={tournament.teams || []} />
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
<RulesListButton tournamentId={tournament.id} />
</Box>
</Stack>

View File

@@ -0,0 +1,49 @@
import { Card, Group, Stack, Text, Avatar } from "@mantine/core";
import { PlayerInfo } from "@/features/players/types";
import PlayerAvatar from "@/components/player-avatar";
interface TeamAssignment {
player1: PlayerInfo;
player2: PlayerInfo;
teamName: string;
}
interface TeamAssignmentPreviewProps {
assignments: TeamAssignment[];
}
const TeamAssignmentPreview: React.FC<TeamAssignmentPreviewProps> = ({ assignments }) => {
return (
<Stack gap="sm">
{assignments.map((assignment, index) => (
<Card key={index} withBorder radius="md" p="md">
<Group gap="md" align="center" wrap="nowrap">
<Text size="sm" fw={600} c="dimmed" w={40}>
#{index + 1}
</Text>
<Group gap="sm" style={{ flex: 1 }} align="center">
<PlayerAvatar name={`${assignment.player1.first_name} ${assignment.player1.last_name}`} size={32} />
<Text size="sm" fw={500}>
{assignment.player1.first_name} {assignment.player1.last_name}
</Text>
</Group>
<Text size="lg" c="dimmed">
&
</Text>
<Group gap="sm" style={{ flex: 1 }} align="center">
<PlayerAvatar name={`${assignment.player2.first_name} ${assignment.player2.last_name}`} size={32} />
<Text size="sm" fw={500}>
{assignment.player2.first_name} {assignment.player2.last_name}
</Text>
</Group>
</Group>
</Card>
))}
</Stack>
);
};
export default TeamAssignmentPreview;

View File

@@ -1,4 +1,4 @@
import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
import { FileInput, Stack, TextInput, Textarea, Checkbox } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
@@ -35,6 +35,7 @@ const TournamentForm = ({
enroll_time: initialValues?.enroll_time || "",
end_time: initialValues?.end_time || "",
logo: undefined,
regional: initialValues?.regional || false,
},
onSubmitPreventDefault: "always",
validate: {
@@ -150,6 +151,12 @@ const TournamentForm = ({
minRows={3}
/>
<Checkbox
label="Regional Tournament"
key={form.key("regional")}
{...form.getInputProps("regional", { type: "checkbox" })}
/>
<FileInput
key={form.key("logo")}
accept="image/png,image/jpeg,image/gif,image/jpg"

View File

@@ -12,8 +12,8 @@ import {
Alert,
} from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
import { CrownIcon, TreeStructureIcon, InfoIcon } from "@phosphor-icons/react";
import Avatar from "@/components/avatar";
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
import TeamAvatar from "@/components/team-avatar";
import ListLink from "@/components/list-link";
import { Podium } from "./podium";
@@ -33,6 +33,10 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
[nonByeMatches]
);
const hasGroupStage = useMemo(() => {
return tournament.matches?.some((match) => match.round === -1) || false;
}, [tournament.matches]);
const sortedTeamStats = useMemo(() => {
return [...(tournament.team_stats || [])].sort((a, b) => {
if (b.wins !== a.wins) {
@@ -68,6 +72,8 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
<Text px="md" size="lg" fw={600}>Results</Text>
<Text px="md" c="dimmed" size="xs" fw={500}>Sorted by win percentage</Text>
{teamStatsWithCalculations.map((stat, index) => {
const team = tournament.teams?.find(t => t.id === stat.team_id);
return (
<Box key={stat.id}>
<UnstyledButton
@@ -77,7 +83,11 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
>
<Group justify="space-between" align="center" w="100%">
<Group gap="sm" align="center">
<Avatar name={stat.team_name} size={40} radius="sm" />
{team ? (
<TeamAvatar team={team} size={40} radius="sm" isRegional={tournament.regional} />
) : (
<TeamAvatar team={{ id: stat.team_id, name: stat.team_name, players: [] } as any} size={40} radius="sm" isRegional={tournament.regional} />
)}
<Stack gap={2}>
<Group gap='xs'>
<Text size="xs" c="dimmed">
@@ -157,12 +167,19 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
return (
<Container size="100%" px={0}>
<Stack gap="md">
{tournament.regional && (
{tournament.regional && !hasGroupStage && (
<Alert px="md" variant="light" title="Regional Tournament" icon={<InfoIcon size={16} />}>
Regional tournaments are a work in progress. Some features might not work as expected.
Earlier regional formats aren't supported in the app and order of matches or displayed winners may be unreliable.
</Alert>
)}
{!tournament.regional && <Podium tournament={tournament} />}
{hasGroupStage && (
<ListLink
label={`View Groups`}
to={`/tournaments/${tournament.id}/groups`}
Icon={ListDashes}
/>
)}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}

View File

@@ -5,13 +5,12 @@ import { useSheet } from "@/hooks/use-sheet";
import { Stack, Text } from "@mantine/core";
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
const EnrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
const { open, isOpen, toggle } = useSheet();
const { user, phone } = useAuth();
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent(isRegional);
const handleEnroll = () => {
console.log('enrolling...')
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
onSuccess: () => {
toggle();
@@ -22,21 +21,31 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
Enroll As Free Agent
{isRegional ? "Enroll" : "Enroll As Free Agent"}
</Button>
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Sheet title={isRegional ? "Enrollment" : "Free Agent Enrollment"} opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<Text size="md">
Enrolling as a free agent adds you to a pool of players looking for teammates.
</Text>
<Text size="sm" c='dimmed'>
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
</Text>
<Text size="xs" c="dimmed">
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
</Text>
<Button onClick={handleEnroll}>Confirm</Button>
{isRegional ? (
<>
<Text size="md">
Enroll in this regional tournament to be assigned a random partner.
</Text>
</>
) : (
<>
<Text size="md">
Enrolling as a free agent adds you to a pool of players looking for teammates.
</Text>
<Text size="sm" c='dimmed'>
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
</Text>
<Text size="xs" c="dimmed">
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
</Text>
</>
)}
<Button onClick={handleEnroll} loading={isEnrolling}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>

View File

@@ -1,13 +1,17 @@
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
import { UserIcon, PhoneIcon } from "@phosphor-icons/react";
import { PhoneIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { useFreeAgents } from "../../queries";
import UnenrollFreeAgent from "./unenroll-free-agent";
import toast from "@/lib/sonner";
import { useAuth } from "@/contexts/auth-context";
import PlayerAvatar from "@/components/player-avatar";
const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
tournamentId
const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }> = ({
tournamentId,
isRegional
}) => {
const { data: freeAgents } = useFreeAgents(tournamentId);
const { user } = useAuth();
const copyToClipboard = async (phone: string) => {
try {
@@ -38,33 +42,66 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
}
};
if (isRegional) {
return (
<Stack gap="sm">
<Card withBorder radius="md" p="md">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center">
<PlayerAvatar name={`${user?.first_name} ${user?.last_name}`} size={48} />
<Box>
<Text size="sm" fw={600}>
{user?.first_name} {user?.last_name}
</Text>
<Group gap={4} align="center">
<CheckCircleIcon size={14} weight="fill" color="var(--mantine-color-green-6)" />
<Text size="xs" c="green" fw={500}>
Enrolled
</Text>
</Group>
</Box>
</Group>
</Group>
</Card>
<Text size="xs" c="dimmed" ta="center">
Partners will be randomly assigned when enrollment closes
</Text>
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<UserIcon size={16} />
<Text size="sm" fw={500}>
Enrolled as Free Agent
</Text>
</Group>
</Group>
<Stack gap="sm">
<Text size="sm" fw={600} c="green">
Enrolled as Free Agent
</Text>
<Text size="xs" c="dimmed">
You're on the free agent list. Other free agents looking for teams:
Other players looking for teammates:
</Text>
{freeAgents.length > 1 ? (
<Card withBorder radius="md" p="sm">
<Stack gap="xs">
<Group gap="xs" align="center">
<Text size="xs" fw={500} c="dimmed">
Free Agents
</Text>
<Badge variant="light" size="xs" color="blue">
{freeAgents.length}
</Badge>
</Group>
<Stack gap="xs">
{freeAgents
.filter(agent => agent.player)
.map((agent) => (
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{agent.player?.first_name} {agent.player?.last_name}
</Text>
</Box>
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap" p="xs" style={{ borderRadius: '8px', backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Text size="sm" fw={500} truncate>
{agent.player?.first_name} {agent.player?.last_name}
</Text>
{agent.phone && (
<Group gap={4} align="center" style={{ flexShrink: 0 }}>
<ActionIcon
@@ -87,27 +124,15 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
)}
</Group>
))}
{freeAgents.length > 1 && (
<Badge
variant="light"
size="xs"
color="blue"
style={{ alignSelf: 'flex-start', marginTop: '4px' }}
>
{freeAgents.length} free agents total
</Badge>
)}
</Stack>
</Card>
</Stack>
) : (
<Card withBorder radius="md" p="sm">
<Text size="sm" c="dimmed" ta="center">
You're the only free agent so far
</Text>
</Card>
<Text size="xs" c="dimmed" py="sm">
You're the first free agent!
</Text>
)}
<UnenrollFreeAgent tournamentId={tournamentId} />
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
</Stack>
);
};

View File

@@ -0,0 +1,46 @@
import ListButton from "@/components/list-button";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import { UserListIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useFreeAgents } from "../../queries";
import { Text } from "@mantine/core";
import PlayerList from "@/features/players/components/player-list";
import { Player } from "@/features/players/types";
interface EnrolledPlayersListButtonProps {
tournamentId: string;
}
const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ tournamentId }) => {
const { data: freeAgents } = useFreeAgents(tournamentId);
const count = useMemo(() => freeAgents.length, [freeAgents]);
const { open, isOpen, toggle } = useSheet();
const players = useMemo(() =>
freeAgents.map(agent => agent.player).filter((player): player is Player => player !== undefined),
[freeAgents]
);
return (
<>
<ListButton
label={`View Enrolled Players (${count})`}
Icon={UserListIcon}
onClick={open}
/>
<Sheet title="Enrolled Players" opened={isOpen} onChange={toggle}>
{count === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No players enrolled yet
</Text>
) : (
<PlayerList players={players} />
)}
</Sheet>
</>
);
};
export default EnrolledPlayersListButton;

View File

@@ -37,7 +37,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
>
<TrophyIcon size={32} />
</GlitchAvatar>
<Flex gap="xs" direction="column" justify="space-around">
<Flex gap="xs" direction="column" justify="space-around" align="center">
{tournament.location && (
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm">

View File

@@ -1,17 +1,18 @@
import { Suspense, useCallback, useMemo } from "react";
import { Suspense, useMemo } from "react";
import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context";
import { Box, Button, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
import { Box, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
import Countdown from "@/components/countdown";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button";
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
import EnrollTeam from "./enroll-team";
import EnrollFreeAgent from "./enroll-free-agent";
import TeamListButton from "./team-list-button";
import EnrolledPlayersListButton from "./enrolled-players-list-button";
import Header from "./header";
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
import TeamCard from "@/features/teams/components/team-card";
import RegionalTeamCard from "@/features/teams/components/regional-team-card";
import UpdateTeam from "./update-team";
import UnenrollTeam from "./unenroll-team";
import { useQueryClient } from "@tanstack/react-query";
@@ -52,7 +53,7 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
<Header tournament={tournament} />
<Stack px="xs">
{tournament.desc && <Text px="md" size="sm">{tournament.desc}</Text>}
{tournament.desc && <Text px="md" ta="center" size="sm" style={{ whiteSpace: 'pre-wrap' }}>{tournament.desc}</Text>}
<Card withBorder radius="lg" p="lg">
<Stack gap="xs">
@@ -80,34 +81,52 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
<>
<EnrollTeam
{!tournament.regional && (
<>
<EnrollTeam
tournamentId={tournament.id}
onSubmit={handleSubmit}
/>
<Divider my={0} label="or" />
</>
)}
<EnrollFreeAgent
tournamentId={tournament.id}
onSubmit={handleSubmit}
isRegional={tournament.regional}
/>
<Divider my={0} label="or" />
<EnrollFreeAgent tournamentId={tournament.id} />
</>
)}
{isUserEnrolled && (
<>
<Suspense fallback={<TeamCardSkeleton />}>
<TeamCard teamId={userTeam.id} />
{tournament.regional === true ? (
<RegionalTeamCard teamId={userTeam.id} />
) : (
<TeamCard teamId={userTeam.id} />
)}
</Suspense>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{isEnrollmentOpen && (
<UnenrollTeam
tournamentId={tournament.id}
teamId={userTeam.id}
onSubmit={handleSubmit}
/>
{tournament.regional !== true && (
<>
<UpdateTeam tournamentId={tournament.id} teamId={userTeam.id} />
{isEnrollmentOpen && (
<UnenrollTeam
tournamentId={tournament.id}
teamId={userTeam.id}
onSubmit={handleSubmit}
/>
)}
</>
)}
</>
)}
{
isFreeAgent && isEnrollmentOpen && (
<EnrolledFreeAgent tournamentId={tournament.id} />
<EnrolledFreeAgent
tournamentId={tournament.id}
isRegional={tournament.regional}
/>
)
}
@@ -130,7 +149,15 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
Icon={TreeStructureIcon}
disabled
/>
<TeamListButton teams={tournament.teams || []} />
{tournament.regional === true ? (
(tournament.teams && tournament.teams.length > 0) ? (
<TeamListButton teams={tournament.teams} isRegional={true} />
) : (
<EnrolledPlayersListButton tournamentId={tournament.id} />
)
) : (
<TeamListButton teams={tournament.teams || []} isRegional={false} />
)}
<RulesListButton tournamentId={tournament.id} />
</Box>
</Stack>

View File

@@ -8,9 +8,10 @@ import { useMemo } from "react"
interface TeamListButtonProps {
teams: TeamInfo[]
isRegional?: boolean
}
const TeamListButton: React.FC<TeamListButtonProps> = ({ teams }) => {
const TeamListButton: React.FC<TeamListButtonProps> = ({ teams, isRegional }) => {
const count = useMemo(() => teams.length, [teams]);
const { open, isOpen, toggle } = useSheet();
return (
@@ -22,7 +23,7 @@ const TeamListButton: React.FC<TeamListButtonProps> = ({ teams }) => {
/>
<Sheet title="Enrolled Teams" opened={isOpen} onChange={toggle}>
<TeamList teams={teams} />
<TeamList teams={teams} isRegional={isRegional} />
</Sheet>
</>
)

View File

@@ -5,11 +5,11 @@ import { useSheet } from "@/hooks/use-sheet";
import { Stack, Text } from "@mantine/core";
import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent";
const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
const UnenrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent();
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent(isRegional);
const handleUnenroll = () => {
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
onSuccess: () => {
@@ -20,17 +20,21 @@ const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
<Button variant="subtle" size="sm" color="red" onClick={open}>
Unenroll
</Button>
<Sheet title="Are you sure?" opened={isOpen} onChange={toggle}>
<Sheet title="Unenroll from tournament?" opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<Text size="md">
This will remove you from the free agent list.
{isRegional
? "This will remove you from the tournament enrollment."
: "This will remove you from the free agent list."}
</Text>
<Button onClick={handleUnenroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
<Button color="red" onClick={handleUnenroll} loading={isEnrolling}>
Confirm Unenrollment
</Button>
<Button variant="subtle" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>
</>

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