Compare commits
53 Commits
caro/badge
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67f6b073c | ||
| 569ea8833b | |||
|
|
74d83da466 | ||
| aff5fa2ea4 | |||
|
|
299f32a2b7 | ||
| 3f125e5761 | |||
|
|
fb4f50b851 | ||
| 5305dc37e7 | |||
|
|
3e5ef04c9f | ||
| e51ff24944 | |||
|
|
c138442530 | ||
| 3baec5ac0f | |||
|
|
8f84dddc64 | ||
|
|
9571980898 | ||
|
|
1ce8a6a982 | ||
|
|
f0d8297f34 | ||
|
|
f49234702c | ||
|
|
f6da7be404 | ||
|
|
c7487e37f2 | ||
|
|
873ca3e4c4 | ||
|
|
c5b3ad50ca | ||
|
|
938418cec5 | ||
|
|
5d6136a445 | ||
|
|
9a64f316b9 | ||
|
|
6199afc687 | ||
|
|
f83a7d69c8 | ||
|
|
2dd3e5b170 | ||
|
|
b9e16e2b64 | ||
|
|
7f60b4d200 | ||
|
|
6e9e014fcc | ||
| a54a74d7de | |||
|
|
236fcda671 | ||
|
|
9ed054e5d0 | ||
|
|
63853f22de | ||
|
|
5dd41d8022 | ||
|
|
937758bd49 | ||
| 7b95998b05 | |||
|
|
f069ba3827 | ||
|
|
70a032db20 | ||
|
|
243fb094e1 | ||
|
|
84202cdbe9 | ||
|
|
6370ebe48a | ||
|
|
8b271f93ac | ||
|
|
2326693bfb | ||
|
|
b209bbf4ef | ||
|
|
c4bf13744c | ||
|
|
1e3eaf0c35 | ||
|
|
d4b52e762b | ||
|
|
a4e618f327 | ||
|
|
d1951afb3c | ||
|
|
dce31905fc | ||
|
|
e58ed86d3b | ||
|
|
d833e5f1a1 |
143
.gitea/workflows/ci-cd.yaml
Normal 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
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
BIN
Teams-2.xlsx
@@ -13,7 +13,6 @@ services:
|
||||
- .env.docker
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./.docker-postgres-init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
|
||||
129
k8s/base/app-deployment.yaml
Normal 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
@@ -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
|
||||
12
k8s/base/kustomization.yaml
Normal 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
@@ -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
|
||||
57
k8s/base/pocketbase-deployment.yaml
Normal 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
|
||||
18
k8s/base/pocketbase-service.yaml
Normal 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
|
||||
15
k8s/overlays/dev/configmap.yaml
Normal 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"
|
||||
17
k8s/overlays/dev/ingress.yaml
Normal 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
|
||||
50
k8s/overlays/dev/kustomization.yaml
Normal 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
|
||||
4
k8s/overlays/dev/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: flxn-dev
|
||||
15
k8s/overlays/prod/configmap.yaml
Normal 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"
|
||||
17
k8s/overlays/prod/ingress.yaml
Normal 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
|
||||
50
k8s/overlays/prod/kustomization.yaml
Normal 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
|
||||
4
k8s/overlays/prod/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: flxn-prod
|
||||
5
k8s/overlays/shared/configmap.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: supertokens-config
|
||||
namespace: flxn-shared
|
||||
16
k8s/overlays/shared/kustomization.yaml
Normal 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
|
||||
4
k8s/overlays/shared/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: flxn-shared
|
||||
13
k8s/overlays/shared/supertokens-db-pvc.yaml
Normal 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
|
||||
71
k8s/overlays/shared/supertokens-deployment.yaml
Normal 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
|
||||
68
k8s/overlays/shared/supertokens-postgres-deployment.yaml
Normal 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
|
||||
17
k8s/overlays/shared/supertokens-postgres-service.yaml
Normal 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
|
||||
18
k8s/overlays/shared/supertokens-service.yaml
Normal 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
|
||||
17
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
109
pb_migrations/1771294794_created_groups.js
Normal 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);
|
||||
})
|
||||
52
pb_migrations/1771294861_updated_tournaments.js
Normal 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)
|
||||
})
|
||||
25
pb_migrations/1771294883_updated_tournaments.js
Normal 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)
|
||||
})
|
||||
29
pb_migrations/1771294898_updated_tournaments.js
Normal 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)
|
||||
})
|
||||
47
pb_migrations/1771295070_updated_matches.js
Normal 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
|
After Width: | Height: | Size: 64 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
@@ -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": []
|
||||
}
|
||||
|
||||
BIN
public/static/img/duncer_cap_badge.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
public/static/img/flip_cup_badge.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/static/img/gets_around_badge.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
public/static/img/king_of_the_hill_badge.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
public/static/img/one_up_badge.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
407
server.ts
@@ -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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
167
src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/app/routes/_authed/tournaments/$id.groups.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/routes/api/health.ts
Normal 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",
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
59
src/components/ios-install-prompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
src/components/player-avatar.tsx
Normal 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;
|
||||
64
src/components/session-monitor.tsx
Normal 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;
|
||||
}
|
||||
129
src/components/team-avatar.tsx
Normal 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;
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
59
src/features/teams/components/regional-team-card.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
162
src/features/tournaments/components/edit-enrolled-players.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
201
src/features/tournaments/components/group-match-card.tsx
Normal 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;
|
||||
50
src/features/tournaments/components/group-preview.tsx
Normal 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;
|
||||
474
src/features/tournaments/components/group-stage-view.tsx
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ const Profile = ({ id }: ProfileProps) => {
|
||||
{
|
||||
label: "Teams",
|
||||
content: <>
|
||||
<TeamList teams={tournament.teams || []} />
|
||||
<TeamList teams={tournament.teams || []} isRegional={tournament.regional} />
|
||||
</>
|
||||
}
|
||||
], [tournament]);
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
305
src/features/tournaments/components/setup-group-stage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||