23 Commits

Author SHA1 Message Date
a54a74d7de Merge pull request 'development' (#4) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 53s
Reviewed-on: #4
2026-02-10 14:03:25 -06:00
yohlo
236fcda671 session fixes
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m52s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-02-09 23:53:54 -06:00
yohlo
9ed054e5d0 regionals on tournament form 2026-02-09 23:44:07 -06:00
yohlo
63853f22de regional teams 2026-02-09 23:36:04 -06:00
yohlo
5dd41d8022 fix scroll bug 2026-02-09 22:20:00 -06:00
yohlo
937758bd49 facehash avatars 2026-02-09 14:31:55 -06:00
7b95998b05 Merge pull request 'new prod env' (#3) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m43s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 46s
Reviewed-on: #3
2026-02-09 12:59:07 -06:00
yohlo
f069ba3827 include supertokens api key
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m5s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 49s
2026-02-09 12:14:38 -06:00
yohlo
70a032db20 more env stuff
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m18s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 12:07:45 -06:00
yohlo
243fb094e1 env
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m19s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
2026-02-09 01:03:30 -06:00
yohlo
84202cdbe9 hopefully fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 00:46:36 -06:00
yohlo
6370ebe48a hopefully fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m15s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 43s
2026-02-09 00:42:44 -06:00
yohlo
8b271f93ac hopefully fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m15s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 41s
2026-02-09 00:37:01 -06:00
yohlo
2326693bfb hopefully fix
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 43s
2026-02-09 00:06:47 -06:00
yohlo
b209bbf4ef copy server.ts
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 58s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 6s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m12s
2026-02-08 23:30:51 -06:00
yohlo
c4bf13744c fix migrations?
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m45s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m9s
2026-02-08 23:21:21 -06:00
yohlo
1e3eaf0c35 server.ts 2026-02-08 23:19:48 -06:00
yohlo
d4b52e762b dist
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m47s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Failing after 5m9s
2026-02-08 23:05:31 -06:00
yohlo
a4e618f327 fix
Some checks failed
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Failing after 16m45s
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 19m23s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been cancelled
2026-02-08 22:32:54 -06:00
yohlo
d1951afb3c testing cicd
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m30s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-02-08 16:01:21 -06:00
yohlo
dce31905fc fixes 2025-10-18 23:09:57 -05:00
yohlo
e58ed86d3b fixes 2025-10-17 19:22:07 -05:00
yohlo
d833e5f1a1 Merge branch 'caro/badges-stats' 2025-10-16 15:41:36 -05:00
68 changed files with 2120 additions and 880 deletions

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

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

29
Dockerfile Normal file
View File

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

View File

@@ -1,16 +1,23 @@
FROM alpine:latest FROM alpine:latest
ARG PB_VERSION=0.29.2 ARG PB_VERSION=0.26.5
RUN apk add --no-cache \ RUN apk add --no-cache \
unzip \ unzip \
ca-certificates 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 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 EXPOSE 8090
# start PocketBase HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"] CMD wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations"]

Binary file not shown.

626
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,124 @@
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
resources:
requests:
memory: "768Mi"
cpu: "250m"
limits:
memory: "1536Mi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3

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

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

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
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"

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

407
server.ts
View File

@@ -16,73 +16,133 @@
* - Server port number * - Server port number
* - Default: 3000 * - Default: 3000
* *
* STATIC_PRELOAD_MAX_BYTES (number) * ASSET_PRELOAD_MAX_SIZE (number)
* - Maximum file size in bytes to preload into memory * - Maximum file size in bytes to preload into memory
* - Files larger than this will be served on-demand from disk * - Files larger than this will be served on-demand from disk
* - Default: 5242880 (5MB) * - 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 * - Comma-separated list of glob patterns for files to include
* - If specified, only matching files are eligible for preloading * - If specified, only matching files are eligible for preloading
* - Patterns are matched against filenames only, not full paths * - 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 * - Comma-separated list of glob patterns for files to exclude
* - Applied after include patterns * - Applied after include patterns
* - Patterns are matched against filenames only, not full paths * - 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 * - Enable detailed logging of loaded and skipped files
* - Default: false * - Default: false
* - Set to "true" to enable verbose output * - 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: * Usage:
* bun run server.ts * bun run server.ts
*/ */
import { readdir } from 'node:fs/promises' import path from 'node:path'
import { join } from 'node:path'
// Configuration // Configuration
const PORT = Number(process.env.PORT ?? 3000) const SERVER_PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIR = './dist/client' const CLIENT_DIRECTORY = './dist/client'
const SERVER_ENTRY = './dist/server/server.js' 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 // Preloading configuration from environment variables
const MAX_PRELOAD_BYTES = Number( 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) // 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(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
.map(globToRegExp) .map((pattern: string) => convertGlobToRegExp(pattern))
// Parse comma-separated exclude patterns (no defaults) // 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(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
.map(globToRegExp) .map((pattern: string) => convertGlobToRegExp(pattern))
// Verbose logging flag // 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 * Convert a simple glob pattern to a regular expression
* Supports * wildcard for matching any characters * Supports * wildcard for matching any characters
*/ */
function globToRegExp(glob: string): RegExp { function convertGlobToRegExp(globPattern: string): RegExp {
// Escape regex special chars except *, then replace * with .* // Escape regex special chars except *, then replace * with .*
const escaped = glob const escapedPattern = globPattern
.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&') .replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
.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 { interface InMemoryAsset {
routes: Record<string, () => Response> raw: Uint8Array
loaded: Array<AssetMetadata> gz?: Uint8Array
skipped: Array<AssetMetadata> 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 const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath
// If include patterns are specified, file must match at least one // 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 * Small files are loaded into memory, large files are served on-demand
*/ */
async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> { async function initializeStaticRoutes(
const routes: Record<string, () => Response> = {} clientDirectory: string,
const loaded: Array<AssetMetadata> = [] ): Promise<PreloadResult> {
const skipped: Array<AssetMetadata> = [] const routes: Record<string, (req: Request) => Response | Promise<Response>> =
{}
const loaded: AssetMetadata[] = []
const skipped: AssetMetadata[] = []
console.log(`📦 Loading static assets from ${clientDir}...`) log.info(`Loading static assets from ${clientDirectory}...`)
console.log( if (VERBOSE) {
` Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
)
if (INCLUDE_PATTERNS.length > 0) {
console.log( console.log(
` Include patterns: ${process.env.STATIC_PRELOAD_INCLUDE ?? ''}`, `Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
)
}
if (EXCLUDE_PATTERNS.length > 0) {
console.log(
` Exclude patterns: ${process.env.STATIC_PRELOAD_EXCLUDE ?? ''}`,
) )
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 let totalPreloadedBytes = 0
try { try {
// Read all files recursively const glob = createCompositeGlobPattern()
const files = await readdir(clientDir, { recursive: true }) for await (const relativePath of glob.scan({ cwd: clientDirectory })) {
const filepath = path.join(clientDirectory, relativePath)
for (const relativePath of files) { const route = `/${relativePath.split(path.sep).join(path.posix.sep)}`
const filepath = join(clientDir, relativePath)
const route = '/' + relativePath.replace(/\\/g, '/') // Handle Windows paths
try { try {
// Get file metadata // Get file metadata
@@ -174,20 +330,23 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
} }
// Determine if file should be preloaded // Determine if file should be preloaded
const matchesPattern = shouldInclude(relativePath) const matchesPattern = isFileEligibleForPreloading(relativePath)
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES
if (matchesPattern && withinSizeLimit) { if (matchesPattern && withinSizeLimit) {
// Preload small files into memory // Preload small files into memory with ETag and Gzip support
const bytes = await file.bytes() const bytes = new Uint8Array(await file.arrayBuffer())
const gz = compressDataIfAppropriate(bytes, metadata.type)
routes[route] = () => const etag = ENABLE_ETAG ? computeEtag(bytes) : undefined
new Response(bytes, { const asset: InMemoryAsset = {
headers: { raw: bytes,
'Content-Type': metadata.type, gz,
'Cache-Control': 'public, max-age=31536000, immutable', etag,
}, type: metadata.type,
}) immutable: true,
size: bytes.byteLength,
}
routes[route] = createResponseHandler(asset)
loaded.push({ ...metadata, size: bytes.byteLength }) loaded.push({ ...metadata, size: bytes.byteLength })
totalPreloadedBytes += bytes.byteLength totalPreloadedBytes += bytes.byteLength
@@ -207,13 +366,13 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
} }
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error && error.name !== 'EISDIR') { 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 // Show detailed file overview only when verbose mode is enabled
if (loaded.length > 0 || skipped.length > 0) { if (VERBOSE && (loaded.length > 0 || skipped.length > 0)) {
const allFiles = [...loaded, ...skipped].sort((a, b) => const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route), a.route.localeCompare(b.route),
) )
@@ -224,124 +383,162 @@ async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
60, 60,
) )
// Format file size with KB and gzip estimation // Format file size with KB and actual gzip size
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number, gzBytes?: number) => {
const kb = bytes / 1024 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 const gzipKb = kb * 0.35
return { return {
size: kb < 100 ? kb.toFixed(2) : kb.toFixed(1), size: sizeStr,
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1), gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
} }
} }
if (loaded.length > 0) { if (loaded.length > 0) {
console.log('\n📁 Preloaded into memory:') console.log('\n📁 Preloaded into memory:')
console.log(
'Path │ Size │ Gzip Size',
)
loaded loaded
.sort((a, b) => a.route.localeCompare(b.route)) .sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => { .forEach((file) => {
const { size, gzip } = formatFileSize(file.size) const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength) const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB` const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `gzip: ${gzip.padStart(6)} kB` const gzipStr = `${gzip.padStart(7)} kB`
console.log(` ${paddedPath} ${sizeStr}${gzipStr}`) console.log(`${paddedPath} ${sizeStr} ${gzipStr}`)
}) })
} }
if (skipped.length > 0) { if (skipped.length > 0) {
console.log('\n💾 Served on-demand:') console.log('\n💾 Served on-demand:')
console.log(
'Path │ Size │ Gzip Size',
)
skipped skipped
.sort((a, b) => a.route.localeCompare(b.route)) .sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => { .forEach((file) => {
const { size, gzip } = formatFileSize(file.size) const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength) const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB` const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `gzip: ${gzip.padStart(6)} kB` const gzipStr = `${gzip.padStart(7)} kB`
console.log(` ${paddedPath} ${sizeStr}${gzipStr}`) console.log(`${paddedPath} ${sizeStr} ${gzipStr}`)
}) })
} }
}
// Show detailed verbose info if enabled // Show detailed verbose info if enabled
if (VERBOSE) { 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('\n📊 Detailed file information:')
console.log(
'Status │ Path │ MIME Type │ Reason',
)
allFiles.forEach((file) => { allFiles.forEach((file) => {
const isPreloaded = loaded.includes(file) const isPreloaded = loaded.includes(file)
const status = isPreloaded ? '[MEMORY]' : '[ON-DEMAND]' const status = isPreloaded ? 'MEMORY' : 'ON-DEMAND'
const reason = const reason =
!isPreloaded && file.size > MAX_PRELOAD_BYTES !isPreloaded && file.size > MAX_PRELOAD_BYTES
? ' (too large)' ? 'too large'
: !isPreloaded : !isPreloaded
? ' (filtered)' ? 'filtered'
: '' : 'preloaded'
const route =
file.route.length > 30
? file.route.substring(0, 27) + '...'
: file.route
console.log( 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 // Log summary after the file list
console.log() // Empty line for separation console.log() // Empty line for separation
if (loaded.length > 0) { if (loaded.length > 0) {
console.log( log.success(
`Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`, `Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
) )
} else { } else {
console.log(' No files preloaded into memory') log.info('No files preloaded into memory')
} }
if (skipped.length > 0) { if (skipped.length > 0) {
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
const filtered = skipped.length - tooLarge const filtered = skipped.length - tooLarge
console.log( log.info(
` ${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`, `${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`,
) )
} }
} catch (error) { } 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 } return { routes, loaded, skipped }
} }
/** /**
* Start the production server * Initialize the server
*/ */
async function startServer() { async function initializeServer() {
console.log('🚀 Starting production server...') log.header('Starting Production Server')
// Load TanStack Start server handler // Load TanStack Start server handler
let handler: { fetch: (request: Request) => Response | Promise<Response> } let handler: { fetch: (request: Request) => Response | Promise<Response> }
try { try {
const serverModule = (await import(SERVER_ENTRY)) as { const serverModule = (await import(SERVER_ENTRY_POINT)) as {
default: { fetch: (request: Request) => Response | Promise<Response> } default: { fetch: (request: Request) => Response | Promise<Response> }
} }
handler = serverModule.default handler = serverModule.default
console.log('TanStack Start handler loaded') log.success('TanStack Start application handler initialized')
} catch (error) { } catch (error) {
console.error('❌ Failed to load server handler:', error) log.error(`Failed to load server handler: ${String(error)}`)
process.exit(1) process.exit(1)
} }
// Build static routes with intelligent preloading // Build static routes with intelligent preloading
const { routes } = await buildStaticRoutes(CLIENT_DIR) const { routes } = await initializeStaticRoutes(CLIENT_DIRECTORY)
// Create Bun server // Create Bun server
const server = Bun.serve({ const server = Bun.serve({
port: PORT, port: SERVER_PORT,
idleTimeout: 255,
routes: { routes: {
// Serve static assets (preloaded or on-demand) // Serve static assets (preloaded or on-demand)
...routes, ...routes,
// Fallback to TanStack Start handler for all other routes // Fallback to TanStack Start handler for all other routes
'/*': (request) => { '/*': async (req: Request) => {
try { 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) { } catch (error) {
console.error('Server handler error:', error) log.error(`Server handler error: ${String(error)}`)
return new Response('Internal Server Error', { status: 500 }) return new Response('Internal Server Error', { status: 500 })
} }
}, },
@@ -349,18 +546,18 @@ async function startServer() {
// Global error handler // Global error handler
error(error) { 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 }) return new Response('Internal Server Error', { status: 500 })
}, },
}) })
console.log( log.success(`Server listening on http://localhost:${String(server.port)}`)
`\n🚀 Server running at http://localhost:${String(server.port)}\n`,
)
} }
// Start the server // Initialize the server
startServer().catch((error: unknown) => { initializeServer().catch((error: unknown) => {
console.error('Failed to start server:', error) log.error(`Failed to start server: ${String(error)}`)
process.exit(1) process.exit(1)
}) })

View File

@@ -14,6 +14,7 @@ import { Route as LogoutRouteImport } from './routes/logout'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthedRouteImport } from './routes/_authed' import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AuthedIndexRouteImport } from './routes/_authed/index' 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 AuthedStatsRouteImport } from './routes/_authed/stats'
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings' import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedBadgesRouteImport } from './routes/_authed/badges' import { Route as AuthedBadgesRouteImport } from './routes/_authed/badges'
@@ -67,6 +68,11 @@ const AuthedIndexRoute = AuthedIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => AuthedRoute, getParentRoute: () => AuthedRoute,
} as any) } as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedStatsRoute = AuthedStatsRouteImport.update({ const AuthedStatsRoute = AuthedStatsRouteImport.update({
id: '/stats', id: '/stats',
path: '/stats', path: '/stats',
@@ -217,6 +223,7 @@ const AuthedAdminTournamentsIdTeamsRoute =
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof AuthedIndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/logout': typeof LogoutRoute '/logout': typeof LogoutRoute
'/refresh-session': typeof RefreshSessionRoute '/refresh-session': typeof RefreshSessionRoute
@@ -224,7 +231,7 @@ export interface FileRoutesByFullPath {
'/badges': typeof AuthedBadgesRoute '/badges': typeof AuthedBadgesRoute
'/settings': typeof AuthedSettingsRoute '/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute '/stats': typeof AuthedStatsRoute
'/': typeof AuthedIndexRoute '/api/health': typeof ApiHealthRoute
'/admin/activities': typeof AuthedAdminActivitiesRoute '/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute '/admin/badges': typeof AuthedAdminBadgesRoute
'/admin/preview': typeof AuthedAdminPreviewRoute '/admin/preview': typeof AuthedAdminPreviewRoute
@@ -242,13 +249,13 @@ export interface FileRoutesByFullPath {
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute '/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute '/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -257,6 +264,7 @@ export interface FileRoutesByTo {
'/badges': typeof AuthedBadgesRoute '/badges': typeof AuthedBadgesRoute
'/settings': typeof AuthedSettingsRoute '/settings': typeof AuthedSettingsRoute
'/stats': typeof AuthedStatsRoute '/stats': typeof AuthedStatsRoute
'/api/health': typeof ApiHealthRoute
'/': typeof AuthedIndexRoute '/': typeof AuthedIndexRoute
'/admin/activities': typeof AuthedAdminActivitiesRoute '/admin/activities': typeof AuthedAdminActivitiesRoute
'/admin/badges': typeof AuthedAdminBadgesRoute '/admin/badges': typeof AuthedAdminBadgesRoute
@@ -293,6 +301,7 @@ export interface FileRoutesById {
'/_authed/badges': typeof AuthedBadgesRoute '/_authed/badges': typeof AuthedBadgesRoute
'/_authed/settings': typeof AuthedSettingsRoute '/_authed/settings': typeof AuthedSettingsRoute
'/_authed/stats': typeof AuthedStatsRoute '/_authed/stats': typeof AuthedStatsRoute
'/api/health': typeof ApiHealthRoute
'/_authed/': typeof AuthedIndexRoute '/_authed/': typeof AuthedIndexRoute
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute '/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute '/_authed/admin/badges': typeof AuthedAdminBadgesRoute
@@ -322,6 +331,7 @@ export interface FileRoutesById {
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths:
| '/'
| '/login' | '/login'
| '/logout' | '/logout'
| '/refresh-session' | '/refresh-session'
@@ -329,7 +339,7 @@ export interface FileRouteTypes {
| '/badges' | '/badges'
| '/settings' | '/settings'
| '/stats' | '/stats'
| '/' | '/api/health'
| '/admin/activities' | '/admin/activities'
| '/admin/badges' | '/admin/badges'
| '/admin/preview' | '/admin/preview'
@@ -347,13 +357,13 @@ export interface FileRouteTypes {
| '/api/teams/upload-logo' | '/api/teams/upload-logo'
| '/api/tournaments/upload-logo' | '/api/tournaments/upload-logo'
| '/admin/' | '/admin/'
| '/tournaments' | '/tournaments/'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments/'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id' | '/admin/tournaments/$id/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/login' | '/login'
@@ -362,6 +372,7 @@ export interface FileRouteTypes {
| '/badges' | '/badges'
| '/settings' | '/settings'
| '/stats' | '/stats'
| '/api/health'
| '/' | '/'
| '/admin/activities' | '/admin/activities'
| '/admin/badges' | '/admin/badges'
@@ -397,6 +408,7 @@ export interface FileRouteTypes {
| '/_authed/badges' | '/_authed/badges'
| '/_authed/settings' | '/_authed/settings'
| '/_authed/stats' | '/_authed/stats'
| '/api/health'
| '/_authed/' | '/_authed/'
| '/_authed/admin/activities' | '/_authed/admin/activities'
| '/_authed/admin/badges' | '/_authed/admin/badges'
@@ -429,6 +441,7 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute LogoutRoute: typeof LogoutRoute
RefreshSessionRoute: typeof RefreshSessionRoute RefreshSessionRoute: typeof RefreshSessionRoute
ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiEventsSplatRoute: typeof ApiEventsSplatRoute ApiEventsSplatRoute: typeof ApiEventsSplatRoute
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
@@ -468,7 +481,7 @@ declare module '@tanstack/react-router' {
'/_authed': { '/_authed': {
id: '/_authed' id: '/_authed'
path: '' path: ''
fullPath: '' fullPath: '/'
preLoaderRoute: typeof AuthedRouteImport preLoaderRoute: typeof AuthedRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
@@ -479,6 +492,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedIndexRouteImport preLoaderRoute: typeof AuthedIndexRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/stats': { '/_authed/stats': {
id: '/_authed/stats' id: '/_authed/stats'
path: '/stats' path: '/stats'
@@ -510,7 +530,7 @@ declare module '@tanstack/react-router' {
'/_authed/tournaments/': { '/_authed/tournaments/': {
id: '/_authed/tournaments/' id: '/_authed/tournaments/'
path: '/tournaments' path: '/tournaments'
fullPath: '/tournaments' fullPath: '/tournaments/'
preLoaderRoute: typeof AuthedTournamentsIndexRouteImport preLoaderRoute: typeof AuthedTournamentsIndexRouteImport
parentRoute: typeof AuthedRoute parentRoute: typeof AuthedRoute
} }
@@ -636,7 +656,7 @@ declare module '@tanstack/react-router' {
'/_authed/admin/tournaments/': { '/_authed/admin/tournaments/': {
id: '/_authed/admin/tournaments/' id: '/_authed/admin/tournaments/'
path: '/tournaments' path: '/tournaments'
fullPath: '/admin/tournaments' fullPath: '/admin/tournaments/'
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
@@ -650,7 +670,7 @@ declare module '@tanstack/react-router' {
'/_authed/admin/tournaments/$id/': { '/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id/' id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id' path: '/tournaments/$id'
fullPath: '/admin/tournaments/$id' fullPath: '/admin/tournaments/$id/'
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
@@ -738,6 +758,7 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute, LogoutRoute: LogoutRoute,
RefreshSessionRoute: RefreshSessionRoute, RefreshSessionRoute: RefreshSessionRoute,
ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute, ApiEventsSplatRoute: ApiEventsSplatRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute, ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,

View File

@@ -11,6 +11,7 @@ import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { AuthContextType } from "@/contexts/auth-context"; import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers"; import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config"; import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
@@ -126,6 +127,7 @@ function RootComponent() {
return ( return (
<RootDocument> <RootDocument>
<Providers> <Providers>
<SessionMonitor />
<Outlet /> <Outlet />
</Providers> </Providers>
</RootDocument> </RootDocument>

View File

@@ -86,6 +86,7 @@ function RouteComponent() {
<SeedTournament <SeedTournament
tournamentId={tournament.id} tournamentId={tournament.id}
teams={tournament.teams || []} teams={tournament.teams || []}
isRegional={tournament.regional}
/> />
)} )}
</Container> </Container>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef } from 'react';
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 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);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return null;
}

View File

@@ -0,0 +1,139 @@
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;
let shouldShowPlayerAvatars = false;
if (isRegional !== undefined) {
shouldShowPlayerAvatars = isRegional && hasTwoPlayers && hasNoLogo;
} else {
const tournaments = (team as any).tournaments;
const hasTournaments = tournaments && tournaments.length > 0;
const allTournamentsAreRegional = hasTournaments && tournaments.every((t: any) => t.regional === true);
shouldShowPlayerAvatars = hasTwoPlayers && hasNoLogo && (allTournamentsAreRegional || !hasTournaments);
}
if (shouldShowPlayerAvatars && team.players?.length === 2) {
const playerSize = size * 0.6;
const crownSize = Math.max(12, size * 0.35);
return (
<Box
style={{
position: "relative",
width: size,
height: size,
display: "flex",
alignItems: "center",
justifyContent: "center",
...style,
}}
>
<AvatarGroup spacing={size * -0.25}>
<Box style={{ position: "relative" }}>
<PlayerAvatar
name={`${team.players[0].first_name} ${team.players[0].last_name}`}
size={playerSize}
disableFullscreen={disableFullscreen}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 1.1,
left: "45%",
transform: "translateX(-50%)",
color: "gold",
rotate: "-5deg",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
<Box style={{ position: "relative" }}>
<PlayerAvatar
name={`${team.players[1].first_name} ${team.players[1].last_name}`}
size={playerSize}
disableFullscreen={disableFullscreen}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 0.95,
left: "65%",
transform: "translateX(-50%)",
color: "gold",
rotate: "10deg",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
</AvatarGroup>
</Box>
);
}
const crownSize = Math.max(14, size * 0.4);
return (
<Box style={{ position: "relative", ...style }}>
<Avatar
name={team.name}
size={size}
radius={radius}
withBorder={withBorder}
disableFullscreen={disableFullscreen}
contain={contain}
src={team.logo ? `/api/files/teams/${team.id}/${team.logo}` : undefined}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 0.6,
left: -crownSize * 0.25,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
);
};
export default TeamAvatar;

View File

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

View File

@@ -85,6 +85,55 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
return () => void ac.abort(); 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(() => { useEffect(() => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (scrollAreaRef.current) { if (scrollAreaRef.current) {
@@ -129,8 +178,15 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
onScrollPositionChange={onScrollPositionChange} onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%' type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY} 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} {children}
</Box> </Box>
</ScrollArea> </ScrollArea>

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ import {
InfoIcon, InfoIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { PlayerStats } from "../types"; import { PlayerStats } from "../types";
import Avatar from "@/components/avatar"; import PlayerAvatar from "@/components/player-avatar";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries"; 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' }}> <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 }}> <Stack gap={2} style={{ flexGrow: 1, overflow: 'hidden', minWidth: 0 }}>
<Group gap='xs'> <Group gap='xs'>
<Text size="sm" fw={600}> <Text size="sm" fw={600}>

View File

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

View File

@@ -1,5 +1,8 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server"; 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 = { export const playerKeys = {
auth: ['auth'], auth: ['auth'],
@@ -58,20 +61,41 @@ export const useMe = () => {
queryKey, queryKey,
queryFn, queryFn,
options: { options: {
staleTime: 0, staleTime: 30 * 1000,
refetchOnMount: true, refetchOnMount: false,
refetchOnWindowFocus: true,
retry: (failureCount, error: any) => { retry: (failureCount, error: any) => {
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
const errorData = error?.response?.data; const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === 'SESSION_REFRESH_REQUIRED') {
const currentUrl = window.location.pathname + window.location.search; logger.warn("Query detected SESSION_REFRESH_REQUIRED");
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
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 false;
} }
} }
return failureCount < 3; return failureCount < 3;
} },
} },
}); });
}; };

View File

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

View File

@@ -7,7 +7,7 @@ import {
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import Avatar from "@/components/avatar"; import TeamAvatar from "@/components/team-avatar";
import { TeamInfo } from "@/features/teams/types"; import { TeamInfo } from "@/features/teams/types";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
@@ -31,7 +31,7 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
return ( return (
<Group justify="space-between" w="100%" wrap="nowrap"> <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}`} {`${team.name}`}
</Text> </Text>
<Stack ml="auto" gap={0} style={{ flexShrink: 0 }}> <Stack ml="auto" gap={0} style={{ flexShrink: 0 }}>
@@ -88,15 +88,10 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
key={`team-list-${team.id}`} key={`team-list-${team.id}`}
p="xs" p="xs"
icon={ icon={
<Avatar <TeamAvatar
team={team}
radius="sm" radius="sm"
size={40} size={40}
name={`${team.name}`}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
/> />
} }
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}

View File

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

View File

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

View File

@@ -87,6 +87,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
start_time: tournament.start_time, start_time: tournament.start_time,
enroll_time: tournament.enroll_time, enroll_time: tournament.enroll_time,
end_time: tournament.end_time, end_time: tournament.end_time,
regional: tournament.regional,
}} }}
close={closeEditTournament} close={closeEditTournament}
/> />

View File

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

View File

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

View File

@@ -63,6 +63,7 @@ export const tournamentInputSchema = z.object({
enroll_time: z.string(), enroll_time: z.string(),
start_time: z.string(), start_time: z.string(),
end_time: z.string().optional(), end_time: z.string().optional(),
regional: z.boolean().optional().default(false),
}); });
export type TournamentInput = z.infer<typeof tournamentInputSchema>; export type TournamentInput = z.infer<typeof tournamentInputSchema>;

12
src/hooks/use-config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { getConfig } from '@/lib/config'
export function useConfig() {
const { data } = useSuspenseQuery({
queryKey: ['config'],
queryFn: () => getConfig(),
staleTime: Infinity,
})
return data
}

19
src/lib/config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { createServerFn } from '@tanstack/react-start'
export const getConfig = createServerFn({ method: 'GET' }).handler(async () => {
return {
apiDomain: process.env.VITE_API_DOMAIN || 'http://localhost:3000',
websiteDomain: process.env.VITE_WEBSITE_DOMAIN || 'http://localhost:3000',
spotifyClientId: process.env.VITE_SPOTIFY_CLIENT_ID || '',
spotifyRedirectUri: process.env.VITE_SPOTIFY_REDIRECT_URI || '',
}
})
export const serverConfig = {
apiDomain: process.env.VITE_API_DOMAIN || 'http://localhost:3000',
websiteDomain: process.env.VITE_WEBSITE_DOMAIN || 'http://localhost:3000',
supertokensUri: process.env.SUPERTOKENS_URI || 'http://localhost:3567',
pocketbaseUrl: process.env.POCKETBASE_URL || 'http://localhost:8090',
spotifyClientId: process.env.VITE_SPOTIFY_CLIENT_ID || '',
spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || '',
}

View File

@@ -175,6 +175,20 @@ export function createBadgesService(pb: PocketBase) {
const tournamentsAttended = tournamentIds.size; const tournamentsAttended = tournamentIds.size;
if (criteria.tournaments_attended !== undefined) { if (criteria.tournaments_attended !== undefined) {
if (tournamentsAttended === 0 && criteria.tournaments_attended === 0) {
const teams = await pb.collection("teams").getFullList({
filter: `players.id ?~ "${playerId}"`,
expand: 'tournaments',
});
const hasEnrollment = teams.some((team: any) => {
const tournaments = team.tournaments || [];
return tournaments.length > 0;
});
return 0;
}
return tournamentsAttended; return tournamentsAttended;
} }
@@ -363,9 +377,31 @@ export function createBadgesService(pb: PocketBase) {
for (const tournament of tournaments) { for (const tournament of tournaments) {
if (!tournamentIds.has(tournament.id)) continue; if (!tournamentIds.has(tournament.id)) continue;
if (tournament.winner_id === playerId) { const tournamentMatches = await pb.collection("matches").getFullList({
consecutiveWins++; filter: `tournament = "${tournament.id}" && status = "ended"`,
maxConsecutiveWins = Math.max(maxConsecutiveWins, consecutiveWins); expand: 'home,away,home.players,away.players',
});
const winnersMatches = tournamentMatches.filter(m => !m.is_losers_bracket);
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
(!highest || current.lid > highest.lid) ? current : highest, null);
if (finalsMatch && finalsMatch.status === 'ended') {
const finalsWinnerId = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
const winningTeam = finalsMatch.expand?.[finalsWinnerId === finalsMatch.home ? 'home' : 'away'];
const winningPlayers = winningTeam?.expand?.players || winningTeam?.players || [];
const playerWon = winningPlayers.some((p: any) =>
(typeof p === 'string' ? p : p.id) === playerId
);
if (playerWon) {
consecutiveWins++;
maxConsecutiveWins = Math.max(maxConsecutiveWins, consecutiveWins);
} else {
consecutiveWins = 0;
}
} else { } else {
consecutiveWins = 0; consecutiveWins = 0;
} }
@@ -433,8 +469,8 @@ export function createBadgesService(pb: PocketBase) {
const badges = await this.listBadges(); const badges = await this.listBadges();
const playerStats = await pb.collection("player_stats").getFullList<PlayerStats>(); const allPlayers = await pb.collection("players").getFullList();
const uniquePlayers = new Set(playerStats.map(s => s.player_id)); const uniquePlayers = new Set(allPlayers.map((p: any) => p.id));
let totalProgressRecords = 0; let totalProgressRecords = 0;
let totalBadgesEarned = 0; let totalBadgesEarned = 0;

View File

@@ -7,7 +7,7 @@ export function createMatchesService(pb: PocketBase) {
return { return {
async getMatch(id: string): Promise<Match | null> { async getMatch(id: string): Promise<Match | null> {
const result = await pb.collection("matches").getOne(id, { const result = await pb.collection("matches").getOne(id, {
expand: "tournament, home, away", expand: "tournament, home, away, home.players, away.players",
}); });
return transformMatch(result); return transformMatch(result);
}, },
@@ -19,7 +19,7 @@ export function createMatchesService(pb: PocketBase) {
const result = await pb.collection("matches").getFullList({ const result = await pb.collection("matches").getFullList({
filter: `tournament="${match.tournament.id}" && (home_from_lid = ${match.lid} || away_from_lid = ${match.lid}) && bye = false`, filter: `tournament="${match.tournament.id}" && (home_from_lid = ${match.lid} || away_from_lid = ${match.lid}) && bye = false`,
expand: "tournament, home, away", expand: "tournament, home, away, home.players, away.players",
}); });
const winnerMatch = result.find(m => (m.home_from_lid === match.lid && !m.home_from_loser) || (m.away_from_lid === match.lid && !m.away_from_loser)); const winnerMatch = result.find(m => (m.home_from_lid === match.lid && !m.home_from_loser) || (m.away_from_lid === match.lid && !m.away_from_loser));
@@ -50,7 +50,7 @@ export function createMatchesService(pb: PocketBase) {
async updateMatch(id: string, data: Partial<MatchInput>): Promise<Match> { async updateMatch(id: string, data: Partial<MatchInput>): Promise<Match> {
logger.info("PocketBase | Updating match", { id, data }); logger.info("PocketBase | Updating match", { id, data });
const result = await pb.collection("matches").update<Match>(id, data, { const result = await pb.collection("matches").update<Match>(id, data, {
expand: 'home, away, tournament' expand: 'home, away, tournament, home.players, away.players'
}); });
return transformMatch(result); return transformMatch(result);
}, },

View File

@@ -126,7 +126,7 @@ export function createPlayersService(pb: PocketBase) {
const result = await pb.collection("matches").getFullList({ const result = await pb.collection("matches").getFullList({
filter: `(${teamFilter}) && (status = "ended" || status = "started")`, filter: `(${teamFilter}) && (status = "ended" || status = "started")`,
sort: "-created", sort: "-created",
expand: "tournament,home,away", expand: "tournament,home,away,home.players,away.players",
}); });
return result.map((match) => transformMatch(match)); return result.map((match) => transformMatch(match));

View File

@@ -105,7 +105,7 @@ export function createTeamsService(pb: PocketBase) {
const result = await pb.collection("matches").getFullList({ const result = await pb.collection("matches").getFullList({
filter: `(${teamFilter}) && (status = "ended" || status = "started")`, filter: `(${teamFilter}) && (status = "ended" || status = "started")`,
sort: "-start_time", sort: "-start_time",
expand: "tournament,home,away", expand: "tournament,home,away,home.players,away.players",
}); });
return result.map((match) => transformMatch(match)); return result.map((match) => transformMatch(match));

View File

@@ -4,30 +4,34 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
import { appInfo } from "./config"; import { appInfo } from "./config";
import { logger } from "./"; import { logger } from "./";
let refreshAttemptCount = 0; let refreshPromise: Promise<boolean> | null = null;
export const resetRefreshFlag = () => { export const resetRefreshFlag = () => {
refreshAttemptCount = 0; refreshPromise = null;
}; };
const setupFetchInterceptor = () => { export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => {
if (typeof window === 'undefined') return; if (refreshPromise) {
logger.info("Reusing existing refresh promise");
return refreshPromise;
}
const originalFetch = window.fetch; logger.info("Creating new refresh promise");
//@ts-ignore refreshPromise = refreshFn()
window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => { .then((result) => {
const url = typeof resource === 'string' ? resource : logger.info("Refresh completed successfully:", result);
resource instanceof URL ? resource.toString() : resource.url; setTimeout(() => {
refreshPromise = null;
}, 500);
return result;
})
.catch((error) => {
logger.error("Refresh failed:", error);
refreshPromise = null;
throw error;
});
if (url.includes('/api/auth/session/refresh')) { return refreshPromise;
refreshAttemptCount++;
if (refreshAttemptCount > 1) {
throw new Error('Duplicate refresh attempt blocked');
}
}
return originalFetch.call(window, resource, options);
};
}; };
export const frontendConfig = () => { export const frontendConfig = () => {
@@ -53,7 +57,6 @@ export function ensureSuperTokensFrontend() {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (!initialized) { if (!initialized) {
setupFetchInterceptor();
SuperTokens.init(frontendConfig()); SuperTokens.init(frontendConfig());
initialized = true; initialized = true;
logger.info("SuperTokens initialized"); logger.info("SuperTokens initialized");

View File

@@ -1,7 +1,14 @@
const getOrigin = (): string => {
if (typeof window !== 'undefined') {
return window.location.origin;
}
return process.env.VITE_API_DOMAIN || 'http://localhost:3000';
};
export const appInfo = { export const appInfo = {
appName: 'FLXN', appName: 'FLXN',
apiDomain: import.meta.env.VITE_API_DOMAIN || 'http://localhost:3000', apiDomain: getOrigin(),
websiteDomain: import.meta.env.VITE_WEBSITE_DOMAIN || 'http://localhost:3000', websiteDomain: getOrigin(),
apiBasePath: '/api/auth', apiBasePath: '/api/auth',
websiteBasePath: '/auth', websiteBasePath: '/auth',
} }

View File

@@ -1,12 +1,12 @@
import SuperTokens from "supertokens-node"; import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session"; import Session from "supertokens-node/recipe/session";
import { TypeInput } from "supertokens-node/types";
import Dashboard from "supertokens-node/recipe/dashboard"; import Dashboard from "supertokens-node/recipe/dashboard";
import UserRoles from "supertokens-node/recipe/userroles"; import UserRoles from "supertokens-node/recipe/userroles";
import { appInfo } from "./config"; import { appInfo } from "./config";
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode"; import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
import { logger } from "./";
import PasswordlessTwilioVerify from "./recipes/passwordless-twilio-verify"; import PasswordlessTwilioVerify from "./recipes/passwordless-twilio-verify";
import { logger } from "./";
import type { TypeInput } from "supertokens-node/types";
export const backendConfig = (): TypeInput => { export const backendConfig = (): TypeInput => {
return { return {
@@ -14,25 +14,26 @@ export const backendConfig = (): TypeInput => {
supertokens: { supertokens: {
connectionURI: connectionURI:
process.env.SUPERTOKENS_URI || "https://try.supertokens.io", process.env.SUPERTOKENS_URI || "https://try.supertokens.io",
apiKey: process.env.SUPERTOKENS_API_KEY || undefined,
}, },
appInfo, appInfo,
recipeList: [ recipeList: [
//PasswordlessTwilioVerify.init(), process.env.NODE_ENV === 'production'
PasswordlessDevelopmentMode.init(), ? PasswordlessTwilioVerify.init()
: PasswordlessDevelopmentMode.init(),
Session.init({ Session.init({
cookieSameSite: "lax", cookieSameSite: "lax",
cookieSecure: import.meta.env.NODE_ENV === "production", cookieSecure: process.env.NODE_ENV === "production",
cookieDomain: cookieDomain: process.env.COOKIE_DOMAIN || undefined,
import.meta.env.NODE_ENV === "production" ? ".example.com" : undefined, antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
antiCsrf: import.meta.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
// Debug only // Debug only
exposeAccessTokenToFrontendInCookieBasedAuth: true, exposeAccessTokenToFrontendInCookieBasedAuth: process.env.NODE_ENV !== "production",
}), }),
Dashboard.init(), Dashboard.init(),
UserRoles.init(), UserRoles.init(),
], ],
telemetry: import.meta.env.NODE_ENV !== "production", telemetry: process.env.NODE_ENV !== "production",
}; };
}; };

View File

@@ -1,8 +1,10 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query"; import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { logger } from '@/lib/supertokens'
let isMutationRefreshingSession = false;
let sessionRefreshRedirect: Promise<void> | null = null;
export function useServerMutation<TData, TVariables = unknown>( export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & { options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
@@ -44,14 +46,29 @@ export function useServerMutation<TData, TVariables = unknown>(
: error.response.data; : error.response.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
if (!isMutationRefreshingSession) { logger.warn("Mutation detected SESSION_REFRESH_REQUIRED");
isMutationRefreshingSession = true;
if (!sessionRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search; const currentUrl = window.location.pathname + window.location.search;
setTimeout(() => { logger.info("Mutation initiating refresh redirect to:", currentUrl);
isMutationRefreshingSession = false;
}, 1000); sessionRefreshRedirect = new Promise<void>((resolve) => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; setTimeout(() => {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
resolve();
}, 100);
});
sessionRefreshRedirect.finally(() => {
setTimeout(() => {
sessionRefreshRedirect = null;
}, 1000);
});
} else {
logger.info("Mutation: refresh redirect already in progress, waiting...");
await sessionRefreshRedirect;
} }
throw new Error("SESSION_REFRESH_REQUIRED"); throw new Error("SESSION_REFRESH_REQUIRED");
} }
} catch (parseError) {} } catch (parseError) {}

View File

@@ -4,10 +4,12 @@ import { getRequest } from "@tanstack/react-start/server";
export const serverFnLoggingMiddleware = createMiddleware({ export const serverFnLoggingMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next, data, functionId, context }) => { }).server(async ({ next, data, context }) => {
const request = getRequest(); const request = getRequest();
const serverFnName = functionId.split('--')[1]?.split('_')[0] || 'unknown'; const url = new URL(request.url);
const pathParts = url.pathname.split('/').filter(Boolean);
const serverFnName = pathParts[pathParts.length - 1] || 'unknown';
const userId = (context as any)?.metadata?.player_id || 'unknown'; const userId = (context as any)?.metadata?.player_id || 'unknown';
const startTime = Date.now(); const startTime = Date.now();

269
test.js
View File

@@ -1,269 +0,0 @@
import PocketBase from "pocketbase";
import * as xlsx from "xlsx";
import { nanoid } from "nanoid";
import { createTeamsService } from "./src/lib/pocketbase/services/teams.ts";
import { createPlayersService } from "./src/lib/pocketbase/services/players.ts";
import { createMatchesService } from "./src/lib/pocketbase/services/matches.ts";
import { createTournamentsService } from "./src/lib/pocketbase/services/tournaments.ts";
const POCKETBASE_URL = "http://127.0.0.1:8090";
const EXCEL_FILE_PATH = "./Teams-2.xlsx";
const ADMIN_EMAIL = "kyle.yohler@gmail.com";
const ADMIN_PASSWORD = "xj44aqz9CWrNNM0o";
// --- Helpers ---
async function createPlayerIfMissing(playersService, nameColumn, idColumn) {
const playerId = idColumn?.trim();
if (playerId) return playerId;
let firstName, lastName;
if (!nameColumn || !nameColumn.trim()) {
firstName = `Player_${nanoid(4)}`;
lastName = "(Regional)";
} else {
const parts = nameColumn.trim().split(" ");
firstName = parts[0];
lastName = parts[1] || "(Regional)";
}
const newPlayer = await playersService.createPlayer({ first_name: firstName, last_name: lastName });
return newPlayer.id;
}
async function handleTeamsSheet(rows, teamsService, playersService, pb, tournamentIdMap = {}) {
console.log(`📥 Importing ${rows.length} teams...`);
const teamIdMap = {}; // spreadsheet ID -> PocketBase ID
for (const [i, row] of rows.entries()) {
try {
const spreadsheetTeamId = row["ID"]?.toString().trim();
if (!spreadsheetTeamId) {
console.warn(`⚠️ [${i + 1}] Team row missing spreadsheet ID, skipping.`);
continue;
}
const p1Id = await createPlayerIfMissing(playersService, row["P1 Name"], row["P1 ID"]);
const p2Id = await createPlayerIfMissing(playersService, row["P2 Name"], row["P2 ID"]);
let name = row["Name"]?.trim();
if (!name) {
const p1First = row["P1 Name"]?.split(" ")[0] || "Player1";
const p2First = row["P2 Name"]?.split(" ")[0] || "Player2";
name = `${p1First} and ${p2First}`;
console.warn(`⚠️ [${i + 1}] No team name found. Using generated name: ${name}`);
}
const existing = await pb.collection("teams").getFullList({
filter: `name = "${name}"`,
fields: "id",
});
if (existing.length > 0) {
console.log(` [${i + 1}] Team "${name}" already exists, skipping.`);
teamIdMap[spreadsheetTeamId] = existing[0].id;
continue;
}
// If there's a tournament for this team, get its PB ID
const tournamentSpreadsheetId = row["Tournament ID"]?.toString().trim();
const tournamentId = tournamentSpreadsheetId ? tournamentIdMap[tournamentSpreadsheetId] : undefined;
const teamInput = {
name,
primary_color: row.primary_color || "",
accent_color: row.accent_color || "",
logo: row.logo || "",
players: [p1Id, p2Id],
tournament: tournamentId, // single tournament relation,
private: true
};
const team = await teamsService.createTeam(teamInput);
teamIdMap[spreadsheetTeamId] = team.id;
console.log(`✅ [${i + 1}] Created team: ${team.name} with players: ${[p1Id, p2Id].join(", ")}`);
// Add the team to the tournament's "teams" relation
if (tournamentId) {
await pb.collection("tournaments").update(tournamentId, {
"teams+": [team.id],
});
console.log(`✅ Added team "${team.name}" to tournament ${tournamentId}`);
}
} catch (err) {
console.error(`❌ [${i + 1}] Failed to create team: ${err.message}`);
}
}
return teamIdMap;
}
async function handleTournamentSheet(rows, tournamentsService, teamIdMap, pb) {
console.log(`📥 Importing ${rows.length} tournaments...`);
const tournamentIdMap = {};
const validFormats = ["double_elim", "single_elim", "groups", "swiss", "swiss_bracket"];
for (const [i, row] of rows.entries()) {
try {
const spreadsheetId = row["ID"]?.toString().trim();
if (!spreadsheetId) {
console.warn(`⚠️ [${i + 1}] Tournament missing spreadsheet ID, skipping.`);
continue;
}
if (!row["Name"]) {
console.warn(`⚠️ [${i + 1}] Tournament name missing, skipping.`);
continue;
}
const format = validFormats.includes(row["Format"]) ? row["Format"] : "double_elim";
// Convert start_time to ISO datetime string
let startTime = null;
if (row["Start Time"]) {
try {
startTime = new Date(row["Start Time"]).toISOString();
} catch (e) {
console.warn(`⚠️ [${i + 1}] Invalid start time format, using null`);
}
}
const tournamentInput = {
name: row["Name"],
start_time: startTime,
format,
regional: true,
teams: Object.values(teamIdMap), // Add all created teams
};
const tournament = await tournamentsService.createTournament(tournamentInput);
tournamentIdMap[spreadsheetId] = tournament.id;
console.log(`✅ [${i + 1}] Created tournament: ${tournament.name} with ${Object.values(teamIdMap).length} teams`);
} catch (err) {
console.error(`❌ [${i + 1}] Failed to create tournament: ${err.message}`);
}
}
return tournamentIdMap;
}
async function handleMatchesSheet(rows, matchesService, teamIdMap, tournamentIdMap, pb) {
console.log(`📥 Importing ${rows.length} matches...`);
const tournamentMatchesMap = {};
for (const [i, row] of rows.entries()) {
try {
const homeId = teamIdMap[row["Home ID"]];
const awayId = teamIdMap[row["Away ID"]];
const tournamentId = tournamentIdMap[row["Tournament ID"]];
if (!homeId || !awayId || !tournamentId) {
console.warn(`⚠️ [${i + 1}] Could not find mapping for Home, Away, or Tournament, skipping.`);
continue;
}
// --- Ensure the teams are linked to the tournament ---
for (const teamId of [homeId, awayId]) {
const team = await pb.collection("teams").getOne(teamId, { fields: "tournaments" });
const tournaments = team.tournaments || [];
if (!tournaments.includes(tournamentId)) {
// Add tournament to team
await pb.collection("teams").update(teamId, { "tournaments+": [tournamentId] });
// Add team to tournament
await pb.collection("tournaments").update(tournamentId, { "teams+": [teamId] });
console.log(`✅ Linked team ${team.name} to tournament ${tournamentId}`);
}
}
// --- Create match ---
const data = {
tournament: tournamentId,
home: homeId,
away: awayId,
home_cups: Number(row["Home cups"] || 0),
away_cups: Number(row["Away cups"] || 0),
status: "ended",
lid: i+1
};
const match = await matchesService.createMatch(data);
console.log(`✅ [${i + 1}] Created match ID: ${match.id}`);
if (!tournamentMatchesMap[tournamentId]) tournamentMatchesMap[tournamentId] = [];
tournamentMatchesMap[tournamentId].push(match.id);
} catch (err) {
console.error(`❌ [${i + 1}] Failed to create match: ${err.message}`);
}
}
// Update each tournament with the created match IDs
for (const [tournamentId, matchIds] of Object.entries(tournamentMatchesMap)) {
try {
await pb.collection("tournaments").update(tournamentId, { "matches+": matchIds });
console.log(`✅ Updated tournament ${tournamentId} with ${matchIds.length} matches`);
} catch (err) {
console.error(`❌ Failed to update tournament ${tournamentId} with matches: ${err.message}`);
}
}
}
// --- Main Import ---
export async function importExcel() {
const pb = new PocketBase(POCKETBASE_URL);
await pb.admins.authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD);
const teamsService = createTeamsService(pb);
const playersService = createPlayersService(pb);
const tournamentsService = createTournamentsService(pb);
const matchesService = createMatchesService(pb);
const workbook = xlsx.readFile(EXCEL_FILE_PATH);
let teamIdMap = {};
let tournamentIdMap = {};
// Process sheets in correct order: Tournaments -> Teams -> Matches
const sheetOrder = ["tournament", "tournaments", "teams", "matches"];
const processedSheets = new Set();
for (const sheetNamePattern of sheetOrder) {
for (const sheetName of workbook.SheetNames) {
if (processedSheets.has(sheetName)) continue;
if (sheetName.toLowerCase() !== sheetNamePattern) continue;
const worksheet = workbook.Sheets[sheetName];
const rows = xlsx.utils.sheet_to_json(worksheet);
console.log(`\n📘 Processing sheet: ${sheetName}`);
switch (sheetName.toLowerCase()) {
case "teams":
teamIdMap = await handleTeamsSheet(rows, teamsService, playersService, pb, tournamentIdMap);
break;
case "tournament":
case "tournaments":
tournamentIdMap = await handleTournamentSheet(rows, tournamentsService, teamIdMap, pb);
break;
case "matches":
await handleMatchesSheet(rows, matchesService, teamIdMap, tournamentIdMap, pb);
break;
default:
console.log(`⚠️ No handler found for sheet '${sheetName}', skipping.`);
}
processedSheets.add(sheetName);
}
}
console.log("\n🎉 All sheets imported successfully!");
}
// --- Run ---
importExcel().catch(console.error);

View File

@@ -3,10 +3,13 @@ import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths' import tsConfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig(({ mode }) => ({
server: { server: {
port: 3000, port: 3000,
allowedHosts: ["dev.flexxon.app"] allowedHosts: ["dev.flexxon.app", "flexxon.app"]
},
ssr: {
noExternal: mode === 'production' ? true : ['facehash'],
}, },
plugins: [ plugins: [
tsConfigPaths({ tsConfigPaths({
@@ -17,4 +20,4 @@ export default defineConfig({
}), }),
react() react()
] ]
}) }))