Compare commits

..

1 Commits

Author SHA1 Message Date
owenrees 2f973e9991 initial commit 2026-05-29 20:36:55 +02:00
93 changed files with 6116 additions and 8414 deletions
-9
View File
@@ -1,9 +0,0 @@
VITE_API_BASE_URL=
VITE_MATOMO_URL=
VITE_MATOMO_SITE_ID=
VITE_MAINTENANCE_MODE= # nothing = false, anything else = true
VITE_MAINTENANCE_MODE_MESSAGE="ChessScribe is currently undergoing maintenance. Some features may be unavailable."
DISCORD_CONTACT_WEBHOOK=
DISCORD_ERROR_LOG_WEBHOOK=
LICHESS_CLIENT_ID=
-59
View File
@@ -1,59 +0,0 @@
name: Production Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
GH_USER: therealowenrees
IMAGE_NAME: therealowenrees/chess-scribe:latest
jobs:
publish:
name: publish image
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Login to Github Container Registry
run: echo "${{ secrets.GHCR_SECRET }}" | docker login ghcr.io -u ${{ env.GH_USER }} --password-stdin
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image and publish with Cache
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VITE_API_BASE_URL=https://api.chess-scribe.org/api/v1
VITE_MATOMO_URL=https://analytics.owenrees.eu
VITE_MATOMO_SITE_ID="3"
VITE_MAINTENANCE_MODE=
VITE_MAINTENANCE_MODE_MESSAGE="ChessScribe is currently undergoing maintenance. Some features may be unavailable."
deploy:
needs: publish
name: deploy image
runs-on: ubuntu-24.04
steps:
- name: Connect and pull image from GHCR
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.PORT }}
script: |
cd /home/${{ secrets.USERNAME }}
echo "${{ secrets.GHCR_SECRET }}" | docker login ghcr.io -u ${{ env.GH_USER }} --password-stdin
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
docker compose up -d
-1
View File
@@ -11,4 +11,3 @@ dist-ssr
.vinxi
__unconfig*
todos.json
data
-9
View File
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="BiomeSettings">
<option name="applySafeFixesOnSave" value="true" />
<option name="enableLspFormat" value="true" />
<option name="formatOnSave" value="true" />
<option name="sortImportOnSave" value="true" />
</component>
</project>
-15
View File
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>
-38
View File
@@ -1,38 +0,0 @@
# --- Stage 1: Build Stage ---
FROM node:24-alpine AS builder
WORKDIR /app
# 1. Declare the build-time arguments Vite needs
ARG VITE_API_BASE_URL
ARG VITE_MATOMO_URL
ARG VITE_MATOMO_SITE_ID
ARG VITE_MAINTENANCE_MODE
ARG VITE_MAINTENANCE_MODE_MESSAGE
# 2. Assign them to environment variables so Vite detects them
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_MATOMO_URL=$VITE_MATOMO_URL
ENV VITE_MATOMO_SITE_ID=$VITE_MATOMO_SITE_ID
ENV VITE_MAINTENANCE_MODE=$VITE_MAINTENANCE_MODE
ENV VITE_MAINTENANCE_MODE_MESSAGE=$VITE_MAINTENANCE_MODE_MESSAGE
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile
COPY . .
RUN pnpm run build
# --- Stage 2: Production Stage ---
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# Nitro compiles everything needed into the .output directory
COPY --from=builder /app/.output ./.output
EXPOSE 3000
# Run the compiled Nitro server
CMD ["node", ".output/server/index.mjs"]
-13
View File
@@ -1,13 +0,0 @@
- og / json-sd
- logger
- set game current position on game load to ply 0 and whatever the FEN is (starting position probably, but could be custom position)
- privacy policy - have no access to anything you generate, only keep a count of success/failed pdf generations with timestamp and error message
- check wcag compliance - footer colour contrast
- 429 lichess error handling - retry after 10 seconds
- try/catch where needed in client code
- generate state and check it against the state returned from lichess https://lichess.org/api#tag/oauth/GET/oauth
- checkbox and toggle disabled states
- react query on for study and chapter load, to allow caching
- https://specification.website/checklist/
- react query to cache the study and chapters
+3 -7
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
@@ -13,12 +13,7 @@
"**/index.html",
"**/vite.config.ts",
"!**/src/routeTree.gen.ts",
"!**/src/styles.css",
"!**/.tanstack/*",
"!**/.output/*",
"!**/.nitro/*",
"!**/node_modules/*",
"!**/styles/vendor/*"
"!**/src/styles.css"
]
},
"formatter": {
@@ -38,3 +33,4 @@
}
}
}
-13
View File
@@ -1,13 +0,0 @@
services:
chess-scribe-frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
env_file:
- .env
restart: unless-stopped
-2
View File
@@ -1,2 +0,0 @@
[tools]
node = "24"
+5439
View File
File diff suppressed because it is too large Load Diff
+9 -12
View File
@@ -6,20 +6,15 @@
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 3000 --host",
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview",
"start": "node .output/server/index.mjs",
"test": "vitest run",
"lint:fix": "biome lint --write",
"format": "biome format",
"format:fix": "biome format --write",
"lint": "biome lint",
"check": "biome check"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@socialgouv/matomo-next": "^1.13.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router": "latest",
@@ -27,15 +22,11 @@
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/router-plugin": "^1.132.0",
"@unpic/react": "^1.0.2",
"lichess-pgn-viewer": "^2.4.5",
"lucide-react": "^0.545.0",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.6.0",
"react-toastify": "^11.1.0",
"tailwindcss": "^4.1.18",
"zod": "^4.4.3"
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
@@ -51,5 +42,11 @@
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}
-4147
View File
File diff suppressed because it is too large Load Diff
-9
View File
@@ -1,9 +0,0 @@
# pnpm v11 configuration file
packages:
- "."
# This replaces 'onlyBuiltDependencies'
allowBuilds:
"sharp": true
"esbuild": true
"lightningcss": true
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

-44
View File
@@ -1,44 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="64.000000pt" height="64.000000pt" viewBox="0 0 64.000000 64.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,64.000000) scale(0.100000,-0.100000)"
fill="#6f884d" stroke="black">
<path d="M460 605 c0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 5 -30 15
-30 20 0 19 -16 -1 -24 -12 -4 -15 -14 -11 -34 3 -15 1 -34 -4 -44 -16 -29 0
-48 41 -48 42 0 44 -2 55 -80 4 -25 9 -53 11 -62 5 -16 -2 -18 -46 -18 -40 0
-51 3 -47 13 12 32 19 130 9 123 -6 -3 -14 -32 -18 -62 -4 -36 -13 -62 -25
-72 -11 -9 -19 -22 -19 -28 0 -7 -7 -14 -15 -18 -8 -3 -15 -17 -15 -31 0 -25
0 -25 90 -25 53 0 90 4 90 10 0 6 -33 10 -80 10 -47 0 -80 4 -80 10 0 6 43 10
110 10 67 0 110 -4 110 -10 0 -5 -9 -10 -20 -10 -11 0 -20 -4 -20 -10 0 -5 14
-10 30 -10 25 0 30 4 30 25 0 14 -7 28 -15 31 -8 4 -15 12 -15 20 0 7 -7 17
-16 22 -11 6 -21 35 -29 83 -11 64 -11 76 2 91 9 10 12 22 6 30 -4 7 -8 29 -8
48 0 32 -3 35 -32 38 -25 2 -33 8 -33 23 0 10 -7 19 -15 19 -8 0 -15 5 -15 10
0 6 7 10 15 10 8 0 15 7 15 15 0 8 5 15 10 15 6 0 10 -7 10 -15 0 -8 7 -15 15
-15 8 0 15 -4 15 -10 0 -5 -7 -10 -15 -10 -8 0 -15 -4 -15 -10 0 -5 11 -10 25
-10 21 0 25 5 25 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 10 -10 15 -30 15
-20 0 -30 -5 -30 -15z m66 -131 c-3 -9 -6 -20 -6 -25 0 -5 -13 -9 -30 -9 -16
0 -30 4 -30 9 0 5 -3 16 -6 25 -5 13 2 16 36 16 34 0 41 -3 36 -16z m14 -64
c0 -5 -22 -10 -50 -10 -27 0 -50 5 -50 10 0 6 23 10 50 10 28 0 50 -4 50 -10z
m30 -220 c0 -6 -33 -10 -80 -10 -47 0 -80 4 -80 10 0 6 33 10 80 10 47 0 80
-4 80 -10z"/>
<path d="M118 519 c-56 -29 -78 -80 -78 -179 0 -47 4 -80 10 -80 6 0 10 34 10
83 0 64 4 89 20 115 11 17 24 32 30 32 14 0 13 -5 -10 -42 -17 -28 -20 -51
-20 -160 0 -79 4 -128 10 -128 6 0 10 49 10 126 0 135 9 169 41 159 22 -7 23
-2 8 27 -16 29 -2 34 20 7 22 -25 28 -11 11 22 -9 16 -7 19 14 19 29 0 76 -19
76 -32 0 -10 -3 -10 -34 2 -16 6 -27 6 -31 0 -4 -6 -2 -10 3 -10 5 0 30 -16
56 -35 48 -37 64 -78 26 -68 -22 6 -28 -12 -7 -20 24 -10 -21 -7 -47 3 -34 13
-55 4 -62 -27 -6 -21 2 -33 50 -77 45 -42 56 -58 56 -84 0 -27 -4 -32 -24 -32
-17 0 -27 9 -36 30 -13 32 -37 56 -46 47 -4 -3 3 -18 15 -33 11 -15 21 -31 21
-35 0 -5 -31 -9 -68 -9 -70 0 -92 -10 -92 -41 0 -9 -7 -19 -15 -23 -17 -6 -21
-56 -5 -56 6 0 10 9 10 20 0 19 7 20 150 20 143 0 150 -1 150 -20 0 -11 5 -20
10 -20 16 0 12 50 -5 56 -8 4 -15 14 -15 24 0 10 -7 23 -15 30 -8 7 -15 28
-15 48 0 30 -8 43 -57 87 -41 38 -54 57 -49 70 4 11 12 15 24 10 46 -18 85
-18 98 0 22 30 17 71 -11 94 -20 15 -24 25 -20 44 5 20 1 27 -28 41 -46 21
-93 20 -139 -5z m190 -421 c3 -16 -8 -18 -117 -18 -94 0 -121 3 -121 13 0 25
14 28 124 25 96 -3 111 -5 114 -20z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+2 -3
View File
@@ -1,4 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Allow: /
Sitemap: https://chess-scribe.org/sitemap.xml
Disallow:
-1
View File
@@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

-44
View File
@@ -1,44 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="64.000000pt" height="64.000000pt" viewBox="0 0 64.000000 64.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,64.000000) scale(0.100000,-0.100000)"
fill="#ffffff" stroke="white">
<path d="M460 605 c0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 5 -30 15
-30 20 0 19 -16 -1 -24 -12 -4 -15 -14 -11 -34 3 -15 1 -34 -4 -44 -16 -29 0
-48 41 -48 42 0 44 -2 55 -80 4 -25 9 -53 11 -62 5 -16 -2 -18 -46 -18 -40 0
-51 3 -47 13 12 32 19 130 9 123 -6 -3 -14 -32 -18 -62 -4 -36 -13 -62 -25
-72 -11 -9 -19 -22 -19 -28 0 -7 -7 -14 -15 -18 -8 -3 -15 -17 -15 -31 0 -25
0 -25 90 -25 53 0 90 4 90 10 0 6 -33 10 -80 10 -47 0 -80 4 -80 10 0 6 43 10
110 10 67 0 110 -4 110 -10 0 -5 -9 -10 -20 -10 -11 0 -20 -4 -20 -10 0 -5 14
-10 30 -10 25 0 30 4 30 25 0 14 -7 28 -15 31 -8 4 -15 12 -15 20 0 7 -7 17
-16 22 -11 6 -21 35 -29 83 -11 64 -11 76 2 91 9 10 12 22 6 30 -4 7 -8 29 -8
48 0 32 -3 35 -32 38 -25 2 -33 8 -33 23 0 10 -7 19 -15 19 -8 0 -15 5 -15 10
0 6 7 10 15 10 8 0 15 7 15 15 0 8 5 15 10 15 6 0 10 -7 10 -15 0 -8 7 -15 15
-15 8 0 15 -4 15 -10 0 -5 -7 -10 -15 -10 -8 0 -15 -4 -15 -10 0 -5 11 -10 25
-10 21 0 25 5 25 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 10 -10 15 -30 15
-20 0 -30 -5 -30 -15z m66 -131 c-3 -9 -6 -20 -6 -25 0 -5 -13 -9 -30 -9 -16
0 -30 4 -30 9 0 5 -3 16 -6 25 -5 13 2 16 36 16 34 0 41 -3 36 -16z m14 -64
c0 -5 -22 -10 -50 -10 -27 0 -50 5 -50 10 0 6 23 10 50 10 28 0 50 -4 50 -10z
m30 -220 c0 -6 -33 -10 -80 -10 -47 0 -80 4 -80 10 0 6 33 10 80 10 47 0 80
-4 80 -10z"/>
<path d="M118 519 c-56 -29 -78 -80 -78 -179 0 -47 4 -80 10 -80 6 0 10 34 10
83 0 64 4 89 20 115 11 17 24 32 30 32 14 0 13 -5 -10 -42 -17 -28 -20 -51
-20 -160 0 -79 4 -128 10 -128 6 0 10 49 10 126 0 135 9 169 41 159 22 -7 23
-2 8 27 -16 29 -2 34 20 7 22 -25 28 -11 11 22 -9 16 -7 19 14 19 29 0 76 -19
76 -32 0 -10 -3 -10 -34 2 -16 6 -27 6 -31 0 -4 -6 -2 -10 3 -10 5 0 30 -16
56 -35 48 -37 64 -78 26 -68 -22 6 -28 -12 -7 -20 24 -10 -21 -7 -47 3 -34 13
-55 4 -62 -27 -6 -21 2 -33 50 -77 45 -42 56 -58 56 -84 0 -27 -4 -32 -24 -32
-17 0 -27 9 -36 30 -13 32 -37 56 -46 47 -4 -3 3 -18 15 -33 11 -15 21 -31 21
-35 0 -5 -31 -9 -68 -9 -70 0 -92 -10 -92 -41 0 -9 -7 -19 -15 -23 -17 -6 -21
-56 -5 -56 6 0 10 9 10 20 0 19 7 20 150 20 143 0 150 -1 150 -20 0 -11 5 -20
10 -20 16 0 12 50 -5 56 -8 4 -15 14 -15 24 0 10 -7 23 -15 30 -8 7 -15 28
-15 48 0 30 -8 43 -57 87 -41 38 -54 57 -49 70 4 11 12 15 24 10 46 -18 85
-18 98 0 22 30 17 71 -11 94 -20 15 -24 25 -20 44 5 20 1 27 -28 41 -46 21
-93 20 -139 -5z m190 -421 c3 -16 -8 -18 -117 -18 -94 0 -121 3 -121 13 0 25
14 28 124 25 96 -3 111 -5 114 -20z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-7
View File
@@ -1,7 +0,0 @@
<svg
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<path d="M38.956.5c-3.53.418-6.452.902-9.286 2.984C5.534 1.786-.692 18.533.68 29.364 3.493 50.214 31.918 55.785 41.329 41.7c-7.444 7.696-19.276 8.752-28.323 3.084C3.959 39.116-.506 27.392 4.683 17.567 9.873 7.742 18.996 4.535 29.03 6.405c2.43-1.418 5.225-3.22 7.655-3.187l-1.694 4.86 12.752 21.37c-.439 5.654-5.459 6.112-5.459 6.112-.574-1.47-1.634-2.942-4.842-6.036-3.207-3.094-17.465-10.177-15.788-16.207-2.001 6.967 10.311 14.152 14.04 17.663 3.73 3.51 5.426 6.04 5.795 6.756 0 0 9.392-2.504 7.838-8.927L37.4 7.171z" />
</svg>

Before

Width:  |  Height:  |  Size: 631 B

-44
View File
@@ -1,44 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="64.000000pt" height="64.000000pt" viewBox="0 0 64.000000 64.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,64.000000) scale(0.100000,-0.100000)"
fill="#6f884d" stroke="black">
<path d="M460 605 c0 -8 -7 -15 -15 -15 -10 0 -15 -10 -15 -30 0 -20 5 -30 15
-30 20 0 19 -16 -1 -24 -12 -4 -15 -14 -11 -34 3 -15 1 -34 -4 -44 -16 -29 0
-48 41 -48 42 0 44 -2 55 -80 4 -25 9 -53 11 -62 5 -16 -2 -18 -46 -18 -40 0
-51 3 -47 13 12 32 19 130 9 123 -6 -3 -14 -32 -18 -62 -4 -36 -13 -62 -25
-72 -11 -9 -19 -22 -19 -28 0 -7 -7 -14 -15 -18 -8 -3 -15 -17 -15 -31 0 -25
0 -25 90 -25 53 0 90 4 90 10 0 6 -33 10 -80 10 -47 0 -80 4 -80 10 0 6 43 10
110 10 67 0 110 -4 110 -10 0 -5 -9 -10 -20 -10 -11 0 -20 -4 -20 -10 0 -5 14
-10 30 -10 25 0 30 4 30 25 0 14 -7 28 -15 31 -8 4 -15 12 -15 20 0 7 -7 17
-16 22 -11 6 -21 35 -29 83 -11 64 -11 76 2 91 9 10 12 22 6 30 -4 7 -8 29 -8
48 0 32 -3 35 -32 38 -25 2 -33 8 -33 23 0 10 -7 19 -15 19 -8 0 -15 5 -15 10
0 6 7 10 15 10 8 0 15 7 15 15 0 8 5 15 10 15 6 0 10 -7 10 -15 0 -8 7 -15 15
-15 8 0 15 -4 15 -10 0 -5 -7 -10 -15 -10 -8 0 -15 -4 -15 -10 0 -5 11 -10 25
-10 21 0 25 5 25 30 0 20 -5 30 -15 30 -8 0 -15 7 -15 15 0 10 -10 15 -30 15
-20 0 -30 -5 -30 -15z m66 -131 c-3 -9 -6 -20 -6 -25 0 -5 -13 -9 -30 -9 -16
0 -30 4 -30 9 0 5 -3 16 -6 25 -5 13 2 16 36 16 34 0 41 -3 36 -16z m14 -64
c0 -5 -22 -10 -50 -10 -27 0 -50 5 -50 10 0 6 23 10 50 10 28 0 50 -4 50 -10z
m30 -220 c0 -6 -33 -10 -80 -10 -47 0 -80 4 -80 10 0 6 33 10 80 10 47 0 80
-4 80 -10z"/>
<path d="M118 519 c-56 -29 -78 -80 -78 -179 0 -47 4 -80 10 -80 6 0 10 34 10
83 0 64 4 89 20 115 11 17 24 32 30 32 14 0 13 -5 -10 -42 -17 -28 -20 -51
-20 -160 0 -79 4 -128 10 -128 6 0 10 49 10 126 0 135 9 169 41 159 22 -7 23
-2 8 27 -16 29 -2 34 20 7 22 -25 28 -11 11 22 -9 16 -7 19 14 19 29 0 76 -19
76 -32 0 -10 -3 -10 -34 2 -16 6 -27 6 -31 0 -4 -6 -2 -10 3 -10 5 0 30 -16
56 -35 48 -37 64 -78 26 -68 -22 6 -28 -12 -7 -20 24 -10 -21 -7 -47 3 -34 13
-55 4 -62 -27 -6 -21 2 -33 50 -77 45 -42 56 -58 56 -84 0 -27 -4 -32 -24 -32
-17 0 -27 9 -36 30 -13 32 -37 56 -46 47 -4 -3 3 -18 15 -33 11 -15 21 -31 21
-35 0 -5 -31 -9 -68 -9 -70 0 -92 -10 -92 -41 0 -9 -7 -19 -15 -23 -17 -6 -21
-56 -5 -56 6 0 10 9 10 20 0 19 7 20 150 20 143 0 150 -1 150 -20 0 -11 5 -20
10 -20 16 0 12 50 -5 56 -8 4 -15 14 -15 24 0 10 -7 23 -15 30 -8 7 -15 28
-15 48 0 30 -8 43 -57 87 -41 38 -54 57 -49 70 4 11 12 15 24 10 46 -18 85
-18 98 0 22 30 17 71 -11 94 -20 15 -24 25 -20 44 5 20 1 27 -28 41 -46 21
-93 20 -139 -5z m190 -421 c3 -16 -8 -18 -117 -18 -94 0 -121 3 -121 13 0 25
14 28 124 25 96 -3 111 -5 114 -20z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-31
View File
@@ -1,31 +0,0 @@
import type { ReactNode } from "react";
interface IProps {
children: ReactNode;
title: string;
id: string;
}
const AccordionItem = ({ title, id, children }: IProps) => (
<div className="hero__accordion-item">
<details className="hero__accordion-details" name="linked">
<summary className="hero__accordion-item__summary" id={`${id}-summary`}>
<span className="hero__accordion-item__title-container">
<span className="hero__accordion-item__title">{title}</span>
</span>
</summary>
</details>
<div className="hero__accordion-item__content">
<p
className="hero__accordion-item__content__inner"
role="definition"
id={id}
>
{children}
</p>
</div>
</div>
);
export default AccordionItem;
-29
View File
@@ -1,29 +0,0 @@
import { Image } from "@unpic/react";
import buyMeACoffeeLogo from "@/assets/images/buymeacoffeelogo.svg?url";
interface IProps {
height: number;
width: number;
}
const CoffeeWidget = ({ height, width }: IProps) => {
return (
<a
href="https://buymeacoffee.com/owenreesdev"
target="_blank"
className="ml-auto flex items-center gap-2 text-sm"
rel="noopener"
aria-label="Buy Me A Coffee"
>
<Image
src={buyMeACoffeeLogo}
alt="Buy Me A Coffee Logo"
width={width}
height={height}
/>
<p>Support This Project</p>
</a>
);
};
export default CoffeeWidget;
-54
View File
@@ -1,54 +0,0 @@
import type { ChangeEvent } from "react";
import FormField from "#/components/FormField.tsx";
import type { IHeader } from "#/interfaces.ts";
interface IProps {
headers: IHeader;
updateHeaders: (e: ChangeEvent<HTMLInputElement>, fieldName: string) => void;
}
const CustomHeaders = ({ headers, updateHeaders }: IProps) => {
return (
<div className="mt-4 w-full max-w-150">
<details className="bg-(--neutral-content)/20 p-4 rounded-lg">
<summary>
Custom Headers (PDF)
<p className="text-sm font-normal">
Text in these fields will overwrite the PGN headers in the generated
PDF
</p>
</summary>
<div>
<form className="flex flex-wrap mt-2 justify-center gap-4">
<FormField
fieldName="title"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="subtitle"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="date"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="author"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
</form>
</div>
</details>
</div>
);
};
export default CustomHeaders;
-17
View File
@@ -1,17 +0,0 @@
import type { ReactNode } from "react";
interface IProps {
title: string;
children?: ReactNode;
}
const Feature = ({ title, children }: IProps) => (
<div className="mb-auto">
<h5 className="text-(--neutral-content) text-2xl font-bold">{title}</h5>
<p className="text-(--neutral-content) font-medium" data-testid="text">
{children}
</p>
</div>
);
export default Feature;
+38 -45
View File
@@ -1,51 +1,44 @@
import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react";
import { LuGithub, LuMail } from "react-icons/lu";
import CoffeeWidget from "#/components/CoffeeWidget.tsx";
import { GITHUB_URL } from "#/config.ts";
import footerLogo from "@/assets/images/footerLogo.svg?url";
export default function Footer() {
return (
<footer className="text-white w-full grid-cols-2 items-center bg-(--accent) px-10 py-5">
<div className="flex w-full items-center gap-2">
<Link to="/" aria-label="Home">
<Image
src={footerLogo}
alt="ChessScribe Logo"
width={10}
height={10}
className="hidden h-10 w-10 sm:block"
aria-label="ChessScribe Logo"
/>
</Link>
<div>
<p>© 2023 - {new Date().getFullYear()}</p>
</div>
<div className="ml-auto flex gap-2">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out cursor-pointer"
>
<LuGithub />
</a>
const year = new Date().getFullYear()
<Link
to="/contact"
rel="noopener noreferrer"
aria-label="Contact"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out"
>
<LuMail />
</Link>
return (
<footer className="mt-20 border-t border-[var(--line)] px-4 pb-14 pt-10 text-[var(--sea-ink-soft)]">
<div className="page-wrap flex flex-col items-center justify-between gap-4 text-center sm:flex-row sm:text-left">
<p className="m-0 text-sm">
&copy; {year} Your name here. All rights reserved.
</p>
<p className="island-kicker m-0">Built with TanStack Start</p>
</div>
<CoffeeWidget width={20} height={50} />
<div className="mt-4 flex justify-center gap-4">
<a
href="https://x.com/tan_stack"
target="_blank"
rel="noreferrer"
className="rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
>
<span className="sr-only">Follow TanStack on X</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
<path
fill="currentColor"
d="M12.6 1h2.2L10 6.48 15.64 15h-4.41L7.78 9.82 3.23 15H1l5.14-5.84L.72 1h4.52l3.12 4.73L12.6 1zm-.77 12.67h1.22L4.57 2.26H3.26l8.57 11.41z"
/>
</svg>
</a>
<a
href="https://github.com/TanStack"
target="_blank"
rel="noreferrer"
className="rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
>
<span className="sr-only">Go to TanStack GitHub</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
/>
</svg>
</a>
</div>
</footer>
);
)
}
-29
View File
@@ -1,29 +0,0 @@
import type { ChangeEvent } from "react";
import type { IHeader } from "#/interfaces.ts";
export interface IFormField {
fieldName: string;
type: string;
headers: IHeader;
updateHeaders: (e: ChangeEvent<HTMLInputElement>, fieldName: string) => void;
}
const FormField = ({ fieldName, type, headers, updateHeaders }: IFormField) => {
return (
<div className="flex flex-col gap-2">
<label htmlFor={fieldName} className="text-sm capitalize">
{fieldName}
</label>
<input
className="headers__input"
type={type}
id={fieldName}
name={fieldName}
value={headers[fieldName]}
onChange={(e) => updateHeaders(e, fieldName)}
/>
</div>
);
};
export default FormField;
-68
View File
@@ -1,68 +0,0 @@
import { useNavigate, useRouter } from "@tanstack/react-router";
interface GenericErrorViewProps {
error?: Error | unknown;
resetErrorBoundary?: () => void;
}
const GenericErrorView = ({
error,
resetErrorBoundary,
}: GenericErrorViewProps) => {
const router = useRouter();
const navigate = useNavigate();
const errorMessage =
error instanceof Error
? error.message
: "An unexpected system error occurred.";
const handleTryAgain = async () => {
if (resetErrorBoundary) {
resetErrorBoundary();
}
await router.invalidate();
const currentSearch = router.state.location.search;
await navigate({ to: ".", search: currentSearch });
};
return (
<div className="flex min-h-[calc(100vh-194px)] flex-col items-center justify-center p-6">
<div className="relative flex max-w-md flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight mb-2 text-(--accent)">
Something went wrong
</h1>
<p className="text-sm text-(--neutral-content) mb-6 max-w-sm">
The application encountered an issue it couldn't recover from.
</p>
<div className="w-full mb-8 rounded-md bg-slate-300/60 border border-slate-800/80 p-4 text-left font-mono text-xs text-red-400 max-h-32 overflow-y-auto">
<span className="text-(--base-content) block mb-1">Error Logs:</span>
{errorMessage}
</div>
<div className="flex w-full gap-3 justify-center">
{resetErrorBoundary && (
<button
type="button"
onClick={handleTryAgain}
className="btn btn-secondary"
>
Try Again
</button>
)}
<button
type="button"
onClick={() => navigate({ to: "/" })}
className="btn btn-primary"
>
Go Home
</button>
</div>
</div>
</div>
);
};
export default GenericErrorView;
+68 -50
View File
@@ -1,60 +1,78 @@
import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react";
import { z } from "zod";
import MaintenanceModeBanner from "#/components/MaintenanceModeBanner.tsx";
import { env } from "#/env.ts";
import logo from "@/assets/images/logo.svg?url";
import { Link } from '@tanstack/react-router'
import ThemeToggle from './ThemeToggle'
export default function Header() {
const maintenanceModeMessage = env.VITE_MAINTENANCE_MODE
? z
.string()
.transform((value) => (value.trim() === "" ? null : value))
.parse(env.VITE_MAINTENANCE_MODE_MESSAGE)
: null;
return (
<header>
{maintenanceModeMessage ? (
<div className="absolute w-full">
<MaintenanceModeBanner message={maintenanceModeMessage} />
</div>
) : null}
<header className="sticky top-0 z-50 border-b border-[var(--line)] bg-[var(--header-bg)] px-4 backdrop-blur-lg">
<nav className="page-wrap flex flex-wrap items-center gap-x-3 gap-y-2 py-3 sm:py-4">
<h2 className="m-0 flex-shrink-0 text-base font-semibold tracking-tight">
<Link
to="/"
className="inline-flex items-center gap-2 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink)] no-underline shadow-[0_8px_24px_rgba(30,90,72,0.08)] sm:px-4 sm:py-2"
>
<span className="h-2 w-2 rounded-full bg-[linear-gradient(90deg,#56c6be,#7ed3bf)]" />
TanStack Start
</Link>
</h2>
<nav className="w-full max-w-screen-2xl place-self-center p-8">
<div className="container mx-auto flex items-center justify-between">
<div className="flex items-center">
<Link to="/" className="flex flex-row items-center justify-center">
<Image
src={logo}
alt="ChessScribe Logo"
width={50}
height={50}
className="mr-4"
<div className="order-3 flex w-full flex-wrap items-center gap-x-4 gap-y-1 pb-1 text-sm font-semibold sm:order-none sm:w-auto sm:flex-nowrap sm:pb-0">
<Link
to="/"
className="nav-link"
activeProps={{ className: 'nav-link is-active' }}
>
Home
</Link>
<Link
to="/about"
className="nav-link"
activeProps={{ className: 'nav-link is-active' }}
>
About
</Link>
<a
href="https://tanstack.com/start/latest/docs/framework/react/overview"
className="nav-link"
target="_blank"
rel="noreferrer"
>
Docs
</a>
</div>
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
<a
href="https://x.com/tan_stack"
target="_blank"
rel="noreferrer"
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
>
<span className="sr-only">Follow TanStack on X</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="24" height="24">
<path
fill="currentColor"
d="M12.6 1h2.2L10 6.48 15.64 15h-4.41L7.78 9.82 3.23 15H1l5.14-5.84L.72 1h4.52l3.12 4.73L12.6 1zm-.77 12.67h1.22L4.57 2.26H3.26l8.57 11.41z"
/>
<span className="nav-title hidden text-2xl font-extrabold sm:block">
ChessScribe
</span>
</Link>
</div>
</svg>
</a>
<a
href="https://github.com/TanStack"
target="_blank"
rel="noreferrer"
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
>
<span className="sr-only">Go to TanStack GitHub</span>
<svg viewBox="0 0 16 16" aria-hidden="true" width="24" height="24">
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
/>
</svg>
</a>
<div>
<ul className="flex flex-1 items-center gap-14 font-semibold">
<li>
<Link to="/chessboard" className="nav-item">
Chessboard
</Link>
</li>
<li>
<Link to="/contact" className="nav-item">
Contact
</Link>
</li>
</ul>
</div>
<ThemeToggle />
</div>
</nav>
</header>
);
)
}
-105
View File
@@ -1,105 +0,0 @@
import type { ChangeEvent } from "react";
import FormField from "#/components/FormField.tsx";
import type { IHeader } from "#/interfaces.ts";
interface IProps {
headers: IHeader;
updateHeaders: (e: ChangeEvent<HTMLInputElement>, fieldName: string) => void;
}
const HeaderFields = ({ headers, updateHeaders }: IProps) => {
return (
<div className="mt-4 w-full max-w-150">
<details className="bg-(--neutral-content)/15 p-4 rounded-lg">
<summary>
Headers
<p className="text-sm font-normal">Edit the game PGN headers</p>
</summary>
<div>
<form className="flex flex-wrap mt-2 justify-center gap-4">
<FormField
fieldName="event"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="site"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="date"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="round"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="white"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="black"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="result"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="eco"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="whiteElo"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="blackElo"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="plyCount"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="eventDate"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
<FormField
fieldName="source"
type="text"
headers={headers}
updateHeaders={updateHeaders}
/>
</form>
</div>
</details>
</div>
);
};
export default HeaderFields;
-34
View File
@@ -1,34 +0,0 @@
import { useLichessUser } from "#/context/LichessUserContext.tsx";
import { useLichess } from "#/hooks/useLichess.ts";
const LichessButton = () => {
const { lichessLogin, lichessLogout } = useLichess();
const { user } = useLichessUser();
const onClickHandler = () => {
return user ? lichessLogout() : lichessLogin();
};
return (
<button
className="w-full text-(--accent) border rounded-xl px-6 py-3 cursor-pointer flex items-center justify-center gap-2 p-2 hover:bg-(--accent) hover:text-(--header-bg) transition-colors duration-300 ease-in-out"
onClick={onClickHandler}
type="button"
>
<div className="w-6 h-6">
<svg
viewBox="0 0 50 50"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<title>Lichess Logo</title>
<path d="M38.956.5c-3.53.418-6.452.902-9.286 2.984C5.534 1.786-.692 18.533.68 29.364 3.493 50.214 31.918 55.785 41.329 41.7c-7.444 7.696-19.276 8.752-28.323 3.084C3.959 39.116-.506 27.392 4.683 17.567 9.873 7.742 18.996 4.535 29.03 6.405c2.43-1.418 5.225-3.22 7.655-3.187l-1.694 4.86 12.752 21.37c-.439 5.654-5.459 6.112-5.459 6.112-.574-1.47-1.634-2.942-4.842-6.036-3.207-3.094-17.465-10.177-15.788-16.207-2.001 6.967 10.311 14.152 14.04 17.663 3.73 3.51 5.426 6.04 5.795 6.756 0 0 9.392-2.504 7.838-8.927L37.4 7.171z" />
</svg>
</div>
<p>{user ? `Logout ${user.username}` : "Log into Lichess.org"}</p>
</button>
);
};
export default LichessButton;
-61
View File
@@ -1,61 +0,0 @@
import type { FormEvent } from "react";
import { toast } from "react-toastify";
import { useLichess } from "#/hooks/useLichess.ts";
interface IProps {
setSelectedStudyId: (id: string) => void;
}
const LichessStudyLinkInput = ({ setSelectedStudyId }: IProps) => {
const { parseLichessStudyLink } = useLichess();
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const studyLink = formData.get("lichess-study-link");
if (!studyLink) return;
if (typeof studyLink !== "string") {
toast.error("Invalid study link");
return;
}
const result = parseLichessStudyLink(studyLink);
if (!result.ok) {
toast.error(result.error);
return;
}
console.log(result.data);
setSelectedStudyId(result.data);
toast.success("Study loaded — pick a chapter");
};
return (
<form className="flex items-end gap-2" onSubmit={handleSubmit}>
<div>
<label htmlFor="lichess-study-link" className="text-sm capitalize">
Lichess Study Link
</label>
<input
className="border rounded-md p-2 w-full"
type="text"
id="lichess-study-link"
name="lichess-study-link"
placeholder="https://lichess.org/study/..."
/>
</div>
<button
type="submit"
className="border py-2 px-3 rounded-md cursor-pointer bg-(--accent) text-white hover:bg-transparent hover:text-(--accent) transition-colors duration-300 ease-in-out"
>
Submit
</button>
</form>
);
};
export default LichessStudyLinkInput;
-34
View File
@@ -1,34 +0,0 @@
interface IProps {
title?: string;
description?: string;
}
const LoadingView = ({ title, description }: IProps) => {
return (
<div className="flex min-h-[calc(100vh-194px)] flex-col items-center justify-center p-6 bg-base-100">
<div className="relative flex max-w-sm flex-col items-center text-center">
{/* Animated Custom Loader */}
<div className="relative mb-6 flex h-16 w-16 items-center justify-center">
{/* Outer Pulsing Ring */}
<div className="absolute inset-0 animate-ping rounded-full bg-(--accent) opacity-20 [animation-duration:1.5s]"></div>
{/* Inner Spinning Ring */}
<div className="h-12 w-12 animate-spin rounded-full border-4 border-(--accent) border-t-transparent"></div>
</div>
{/* Loading Content */}
<h2 className="text-xl font-bold tracking-tight mb-2 text-(--base-content)">
{title || "Loading Data"}
</h2>
<p className="text-sm text-(--neutral-content) max-w-xs">
{description || "Please wait a moment while we load the data."}
</p>
{/* Decorative background glow matching your layout */}
<div className="absolute -inset-10 -z-10 bg-(--accent)/5 blur-2xl rounded-full pointer-events-none" />
</div>
</div>
);
};
export default LoadingView;
-13
View File
@@ -1,13 +0,0 @@
interface IProps {
message?: string;
}
const DEFAULT_MESSAGE = "Maintenance Mode";
const MaintenanceModeBanner = ({ message }: IProps) => (
<div className="bg-orange-400 text-white text-center py-1 w-full text-sm">
<p>{message || DEFAULT_MESSAGE}</p>
</div>
);
export default MaintenanceModeBanner;
-34
View File
@@ -1,34 +0,0 @@
import { trackAppRouter } from "@socialgouv/matomo-next";
import { useLocation, useSearch } from "@tanstack/react-router";
import { useEffect } from "react";
import { env } from "#/env.ts";
const MATOMO_URL = env.VITE_MATOMO_URL;
const MATOMO_SITE_ID = env.VITE_MATOMO_SITE_ID;
export function MatomoAnalytics() {
const location = useLocation();
const searchParams = useSearch({ strict: false });
useEffect(() => {
if (typeof window === "undefined") return;
const serializedParams = new URLSearchParams(
searchParams as Record<string, string>,
);
trackAppRouter({
url: MATOMO_URL || "",
siteId: MATOMO_SITE_ID || "",
pathname: location.pathname,
searchParams: serializedParams,
enableHeatmapSessionRecording: false,
enableHeartBeatTimer: true,
cleanUrl: true,
disableCookies: true,
debug: import.meta.env.DEV,
});
}, [location.pathname, searchParams]);
return null;
}
-21
View File
@@ -1,21 +0,0 @@
import { Link } from "@tanstack/react-router";
const NotFoundView = () => {
return (
<div className="flex min-h-[calc(100vh-194px)] flex-col items-center justify-center p-6">
<div className="relative flex max-w-md flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight mb-4 text-(--accent)">
The page you are looking for does not exist.
</h1>
<div className="flex w-full gap-3 justify-center">
<Link to="/" className="btn btn-primary">
Go Home
</Link>
</div>
</div>
</div>
);
};
export default NotFoundView;
-15
View File
@@ -1,15 +0,0 @@
import { useLichessPgnViewer } from "#/hooks/useLichessPgnViewer.ts";
import type { IPosition } from "#/interfaces.ts";
interface IProps {
gamePgn: string;
handlePlyChange: ({ ply, fen }: IPosition) => void;
}
const PgnViewer = ({ gamePgn, handlePlyChange }: IProps) => {
useLichessPgnViewer({ gamePgn, handlePlyChange });
return <div className="lpv-board" />;
};
export default PgnViewer;
-31
View File
@@ -1,31 +0,0 @@
import type { ReactNode } from "react";
interface IProps {
title: string;
smallTitle?: string;
description?: string;
children: ReactNode;
}
const Section = ({ title, smallTitle, description, children }: IProps) => {
const words = title.trim().split(" ");
const lastWord = words.pop();
const mainTitle = words.join(" ");
return (
<section className="mx-auto mt-8 grid max-w-5xl place-items-center gap-4 p-8 text-center md:grid-cols-3 md:text-left text-(--accent)">
<h3 className="col-span-full mb-4 text-center text-2xl font-bold tracking-wider text-primary">
{smallTitle}
</h3>
<h4 className="col-span-full mb-4 text-center text-4xl font-extrabold text-(--neutral-content) md:text-5xl">
{mainTitle} <span className="text-(--accent)">{lastWord}</span>
</h4>
<p className="col-span-full mb-8 w-1/2 text-center font-semibold text-(--neutral-content) ">
{description}
</p>
{children}
</section>
);
};
export default Section;
-107
View File
@@ -1,107 +0,0 @@
import { useRef, useState } from "react";
import { toast } from "react-toastify";
import { useClickOutside } from "#/hooks/useClickOutside.ts";
import { useLichess } from "#/hooks/useLichess.ts";
interface IProps {
setSelectedStudyId: (studyId: string) => void;
}
const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const { getLichessUserStudies, userStudies, setUserStudies } = useLichess();
const dropdownRef = useRef<HTMLDivElement>(null);
const filteredStudies = userStudies.filter((study) =>
study.name.toLowerCase().includes(search.toLowerCase()),
);
const onClickHandler = async () => {
setIsOpen(!isOpen);
if (userStudies.length) return;
const result = await getLichessUserStudies();
if (result.ok) {
setUserStudies(result.data);
}
};
const handleStudyClick = async (studyId: string) => {
const study = userStudies.find((s) => s.id === studyId);
setSelectedStudyId(studyId);
setIsOpen(false);
setSearch("");
toast.success(`Loaded study "${study?.name ?? studyId}"`);
};
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
return (
<div ref={dropdownRef} className="relative">
<button
type="button"
className="btn btn-secondary"
onClick={onClickHandler}
>
Select Lichess Study
</button>
{isOpen ? (
<div className="absolute z-3 bg-(--bg-base) mt-2 max-h-80 border border-(--neutral-content)/30 rounded-lg p-3 overflow-y-scroll transition-all duration-300 ease-in-out opacity-100 scale-100 shadow-lg">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--neutral-content) pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>search</title>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full h-12 pl-10 pr-4 rounded-lg border border-(--neutral-content) text-(--base-content) placeholder-(--neutral-content)/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50"
/>
</div>
<hr className="border-(--neutral-content)/15 my-2" />
{filteredStudies.length > 0 ? (
<ul>
{filteredStudies.map((study) => (
<li key={study.id}>
<button
type="button"
className="p-2 cursor-pointer hover:bg-(--neutral-content)/15 focus-visible:outline-2 focus-visible:outline-(--accent) focus-visible:outline-offset-2 rounded text-left w-full transition-colors duration-200"
onClick={() => handleStudyClick(study.id)}
>
{study.name}
</button>
</li>
))}
</ul>
) : (
<p className="text-(--neutral-content) text-sm text-center py-4">
No results found
</p>
)}
</div>
) : null}
</div>
);
};
export default SelectLichessStudy;
-167
View File
@@ -1,167 +0,0 @@
import {
type Dispatch,
useActionState,
useEffect,
// useRef,
useState,
} from "react";
import { toast } from "react-toastify";
// import { useClickOutside } from "#/hooks/useClickOutside.ts";
import { useLichess } from "#/hooks/useLichess.ts";
import type { IUserStudyChapter } from "#/interfaces.ts";
import type { GameAction } from "#/reducers/gameReducer.ts";
interface IProps {
selectedStudyId: string;
gameDispatch: Dispatch<GameAction>;
}
type ChapterState = {
status: "idle" | "success" | "error";
chapters: IUserStudyChapter[];
error: string | null;
};
const initialChapterState: ChapterState = {
status: "idle",
chapters: [],
error: null,
};
const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
// const dropdownRef = useRef<HTMLDivElement>(null);
const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess();
const [chapterState, fetchChaptersAction, isPending] = useActionState(
async (_prev: ChapterState, _formData: FormData): Promise<ChapterState> => {
if (!selectedStudyId) {
return {
status: "error",
chapters: [],
error: "No study selected.",
} satisfies ChapterState;
}
const result = await getLichessStudyChapters({
studyId: selectedStudyId,
});
console.log(result);
if (result.ok) {
return {
status: "success",
chapters: result.data,
error: null,
} satisfies ChapterState;
}
return {
status: "error",
chapters: [],
error: result.error,
} satisfies ChapterState;
},
initialChapterState,
);
const filteredChapters = chapterState.chapters.filter((chapter) =>
chapter.name.toLowerCase().includes(search.toLowerCase()),
);
// useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
useEffect(() => {
if (isPending || chapterState.status === "success") {
setIsOpen(true);
}
}, [isPending, chapterState.status]);
const handleChapterClick = async (chapter: IUserStudyChapter) => {
const { headers, pgn } = await loadLichessStudyChapter({ chapter });
gameDispatch({ type: "SET_GAME", payload: { pgn, headers } });
setIsOpen(false);
setSearch("");
toast.success(`Loaded chapter "${chapter.name}"`);
};
return (
<form
key={selectedStudyId}
action={fetchChaptersAction}
className="relative"
>
<input type="hidden" name="studyId" value={selectedStudyId} />
<button
type="submit"
className="btn btn-secondary"
disabled={!selectedStudyId || isPending}
>
{isPending ? "Loading chapters…" : "Import Chapter"}
</button>
{isOpen ? (
<div className="absolute z-3 bg-(--bg-base) mt-2 max-h-80 border border-(--neutral-content)/30 rounded-lg p-3 overflow-y-scroll transition-all duration-300 ease-in-out opacity-100 scale-100 shadow-lg">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--neutral-content) pointer-events-none">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>search</title>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full h-12 pl-10 pr-4 rounded-lg border border-(--neutral-content) text-(--base-content) placeholder-(--neutral-content)/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50"
/>
</div>
<hr className="border-(--neutral-content)/15 my-2" />
{isPending ? (
<p className="text-(--neutral-content) text-sm text-center py-4">
Loading chapters
</p>
) : filteredChapters.length > 0 ? (
<ul>
{filteredChapters.map((chapter) => (
<li key={`${chapter.chapterId}${chapter.name}`}>
<button
type="button"
className="p-2 cursor-pointer hover:bg-(--neutral-content)/15 focus-visible:outline-2 focus-visible:outline-(--accent) focus-visible:outline-offset-2 rounded text-left w-full transition-colors duration-200"
onClick={() => handleChapterClick(chapter)}
>
{chapter.name}
</button>
</li>
))}
</ul>
) : (
<p className="text-(--neutral-content) text-sm text-center py-4">
No results found
</p>
)}
</div>
) : null}
</form>
);
};
export default SelectStudyChapter;
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
type ThemeMode = 'light' | 'dark' | 'auto'
function getInitialMode(): ThemeMode {
if (typeof window === 'undefined') {
return 'auto'
}
const stored = window.localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark' || stored === 'auto') {
return stored
}
return 'auto'
}
function applyThemeMode(mode: ThemeMode) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
if (mode === 'auto') {
document.documentElement.removeAttribute('data-theme')
} else {
document.documentElement.setAttribute('data-theme', mode)
}
document.documentElement.style.colorScheme = resolved
}
export default function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>('auto')
useEffect(() => {
const initialMode = getInitialMode()
setMode(initialMode)
applyThemeMode(initialMode)
}, [])
useEffect(() => {
if (mode !== 'auto') {
return
}
const media = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => applyThemeMode('auto')
media.addEventListener('change', onChange)
return () => {
media.removeEventListener('change', onChange)
}
}, [mode])
function toggleMode() {
const nextMode: ThemeMode =
mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light'
setMode(nextMode)
applyThemeMode(nextMode)
window.localStorage.setItem('theme', nextMode)
}
const label =
mode === 'auto'
? 'Theme mode: auto (system). Click to switch to light mode.'
: `Theme mode: ${mode}. Click to switch mode.`
return (
<button
type="button"
onClick={toggleMode}
aria-label={label}
title={label}
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
>
{mode === 'auto' ? 'Auto' : mode === 'dark' ? 'Dark' : 'Light'}
</button>
)
}
-4
View File
@@ -1,4 +0,0 @@
export const HOST_URL = "https://chess-scribe.org";
export const GITHUB_URL =
"https://github.com/therealowenrees/chess-scribe-website";
-55
View File
@@ -1,55 +0,0 @@
import { useServerFn } from "@tanstack/react-start";
import {
createContext,
type Dispatch,
type ReactNode,
type SetStateAction,
useContext,
useEffect,
useState,
} from "react";
import type { ISession } from "#/interfaces.ts";
import { getSession } from "#/server/lichess.ts";
interface ILichessUser extends Omit<ISession, "token"> {}
interface ILichessUserContextType {
user: ILichessUser | null;
setUser: Dispatch<SetStateAction<ILichessUser | null>>;
}
const LichessUserContext = createContext<ILichessUserContextType | undefined>(
undefined,
);
export const LichessUserProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<ILichessUser | null>(null);
const getSessionFn = useServerFn(getSession);
useEffect(() => {
(async () => {
const session = await getSessionFn();
if (session) {
setUser({
username: session.username,
id: session.id,
});
}
})();
}, [getSessionFn]);
return (
<LichessUserContext.Provider value={{ user, setUser }}>
{children}
</LichessUserContext.Provider>
);
};
export const useLichessUser = () => {
const context = useContext(LichessUserContext);
if (!context) {
throw new Error("useLichessUser must be used within a LichessUserProvider");
}
return context;
};
-45
View File
@@ -1,45 +0,0 @@
import { z } from "zod";
const clientSchema = z.object({
VITE_API_BASE_URL: z.url("Invalid API Base URL format"),
VITE_MATOMO_URL: z.url("Invalid URL format"),
VITE_MATOMO_SITE_ID: z.string("Invalid Site ID"),
VITE_MAINTENANCE_MODE: z.string("Invalid Maintenance Mode"),
VITE_MAINTENANCE_MODE_MESSAGE: z.string().optional(),
});
const serverSchema = z.object({
DISCORD_CONTACT_WEBHOOK: z.string("Invalid Discord Contact Webhook URL"),
DISCORD_ERROR_LOG_WEBHOOK: z.string("Invalid Discord Error Log Webhook URL"),
LICHESS_CLIENT_ID: z.string("Invalid Lichess Client ID"),
});
const fullSchema = z.object({
...clientSchema.shape,
...serverSchema.shape,
});
const isServer = typeof window === "undefined";
const rawEnv = isServer ? process.env : import.meta.env;
const getEnv = () => {
if (isServer) {
const _env = fullSchema.safeParse(rawEnv);
if (!_env.success) {
console.error("❌ Invalid Server Environment Variables:");
console.error(JSON.stringify(_env.error.format(), null, 2));
throw new Error("Invalid environment variables");
}
return _env.data;
} else {
const _env = clientSchema.safeParse(rawEnv);
if (!_env.success) {
console.error("❌ Invalid Client Environment Variables:");
console.error(JSON.stringify(_env.error.format(), null, 2));
throw new Error("Invalid environment variables");
}
return _env.data as z.infer<typeof fullSchema>;
}
};
export const env = getEnv();
-198
View File
@@ -1,198 +0,0 @@
import { useServerFn } from "@tanstack/react-start";
import {
type ChangeEvent,
useCallback,
useReducer,
useRef,
useState,
} from "react";
import { toast } from "react-toastify";
import { env } from "#/env.ts";
import type { IHeader, IPosition } from "#/interfaces.ts";
import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts";
import { recordPdfSuccess } from "#/server/pdfMetrics.ts";
import { downloadPDF } from "#/utils/pdfUtils.ts";
import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
import { downloadString } from "#/utils/stringUtils.ts";
export const useChessGame = () => {
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState);
const recordPdfSuccessFn = useServerFn(recordPdfSuccess);
const [currentPosition, setCurrentPosition] = useState<IPosition>({
ply: 0,
fen: "",
});
const [generatingPdf, setGeneratingPdf] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handlePlyChange = useCallback(({ ply, fen }: IPosition) => {
setCurrentPosition({ ply, fen });
}, []);
const handleSavePgn = () => {
try {
const pgnString = buildPgnString(gameState);
downloadString(pgnString, "game.pgn");
} catch (error) {
// TODO logger
console.error(error);
toast.error("An error occurred while saving the PGN. Please try again.", {
toastId: "pgn-download-error",
});
}
};
const handleSavePdf = async () => {
try {
setGeneratingPdf(true);
const { diagrams, diagramClock } = gameState;
const pgnString = buildPgnString(gameState);
const apiBaseUrl = env.VITE_API_BASE_URL;
const response = await fetch(`${apiBaseUrl}/pdf`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
pgn: pgnString,
diagrams,
diagramClock,
}),
});
if (response.ok) {
downloadPDF(await response.blob());
await recordPdfSuccessFn();
toast.success("PDF successfully generated!", {
toastId: "pdf-success",
});
} else {
// TODO toast and remove console but pass to logger
const body = await response.json();
console.error("Error saving PDF:", body.error);
toast.error("An error has occurred. Please try again later.", {
toastId: "pdf-error",
});
}
} catch (error: unknown) {
// TODO add metrics / logger
console.error("Error saving PDF:", error);
if (error instanceof Error) {
toast.error("An error has occurred. Please try again later.", {
toastId: "pdf-error",
});
}
} finally {
setGeneratingPdf(false);
}
};
const handleClearGame = () => {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
gameDispatch({
type: "CLEAR_GAME",
});
};
const handleLoadPgn = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const selectedFile = e.target.files?.[0];
if (selectedFile?.name.endsWith(".pgn")) {
const reader = new FileReader();
reader.onload = (event) => {
const pgnData = event.target?.result;
let pgnString = "";
if (pgnData) {
if (typeof pgnData === "string") {
pgnString = pgnData;
} else {
pgnString = new TextDecoder().decode(pgnData);
}
}
if (pgnString) {
const headers = {
...getHeaders(pgnString),
title: "",
subtitle: "",
author: "",
};
gameDispatch({
type: "SET_GAME",
payload: { pgn: pgnString, headers: headers },
});
} else {
e.target.files = null;
handleClearGame();
}
};
reader.readAsText(selectedFile);
} else {
handleClearGame();
}
};
const handleToggleClock = () => {
gameDispatch({
type: "TOGGLE_DIAGRAM_CLOCK",
});
};
const handleToggleDiagram = () => {
if (!gameState.pgn) return;
const { ply, fen } = currentPosition;
if (ply === 0) return;
const exists = gameState.diagrams.some((d) => d.ply === ply);
gameDispatch(
exists
? { type: "DELETE_DIAGRAM", payload: { ply } }
: { type: "ADD_DIAGRAM", payload: { ply, fen } },
);
};
const updateHeaders = (
e: ChangeEvent<HTMLInputElement>,
fieldName: string,
) => {
e.preventDefault();
const updatedHeaders = {
...gameState.headers,
[fieldName]: e.target.value,
} as IHeader;
gameDispatch({ type: "SET_HEADERS", payload: updatedHeaders });
};
return {
gameState,
fileInputRef,
handleSavePgn,
handleSavePdf,
handleClearGame,
handleLoadPgn,
handleToggleClock,
handlePlyChange,
handleToggleDiagram,
currentPosition,
generatingPdf,
updateHeaders,
gameDispatch,
};
};
-42
View File
@@ -1,42 +0,0 @@
import { type RefObject, useEffect } from "react";
interface IProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
ref: RefObject<HTMLDivElement | null>;
setSearch: (search: string) => void;
}
export const useClickOutside = ({
isOpen,
setIsOpen,
setSearch,
ref,
}: IProps) => {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isOpen &&
ref.current &&
!ref.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearch("");
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
setSearch("");
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen, setIsOpen, setSearch, ref]);
};
-206
View File
@@ -1,206 +0,0 @@
import { useServerFn } from "@tanstack/react-start";
import { useEffect, useState } from "react";
import { useLichessUser } from "#/context/LichessUserContext.tsx";
import type {
ITokenData,
IUserStudy,
IUserStudyChapter,
} from "#/interfaces.ts";
import {
getSession,
getStudyChapters,
getToken,
getUser,
getUserStudies,
login,
logout,
setSession,
} from "#/server/lichess.ts";
import { getHeaders } from "#/utils/pgnUtils.ts";
type Status = "loading" | "success" | "error";
export type LichessResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
const LICHESS_STUDY_LINK_RE =
/^https?:\/\/lichess\.org\/study\/([A-Za-z0-9]{8})(?:\.pgn)?(?:$|\/|\?|#)/i;
const lichessFetch = async <T>(
fn: () => Promise<T | undefined>,
fallbackError: string,
): Promise<LichessResult<T>> => {
try {
const data = await fn();
if (data === undefined || data === null) {
return { ok: false, error: fallbackError };
}
return { ok: true, data };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : fallbackError,
};
}
};
export const useLichess = () => {
const { user, setUser } = useLichessUser();
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
const loginFn = useServerFn(login);
const logoutFn = useServerFn(logout);
const accessTokenFn = useServerFn(getToken);
const getUserFn = useServerFn(getUser);
const setSessionFn = useServerFn(setSession);
const getSessionFn = useServerFn(getSession);
const getUserStudiesFn = useServerFn(getUserStudies);
const getStudyChaptersFn = useServerFn(getStudyChapters);
const lichessAccessToken = async ({ code }: { code: string }) => {
return accessTokenFn({ data: { code } });
};
const setLichessUser = async ({
username,
id,
token,
}: {
username: string;
id: string;
token: string;
}) => {
await setSessionFn({ data: { username, id, token } });
setUser({ username, id });
};
const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => {
return getUserFn({
data: { access_token: tokenData.access_token },
});
};
const lichessLogin = async () => await loginFn();
const lichessLogout = async () => {
await logoutFn();
setSelectedStudyId(null);
setUser(null);
};
const getLichessSession = async () => {
return getSessionFn();
};
const useLichessCallback = ({ code }: { code: string }) => {
const [status, setStatus] = useState<Status>("loading");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!code) return;
const isMounted = true;
try {
(async () => {
const tokenData = await lichessAccessToken({ code });
const userData = await getLichessUser({ tokenData });
if (!isMounted) return;
await setLichessUser({
username: userData.username,
id: userData.id,
token: tokenData.access_token,
});
setStatus("success");
})();
} catch (err: unknown) {
if (isMounted && err instanceof Error) {
console.error("Lichess Authentication Error:", err);
setError(err.message || "Failed to authenticate with Lichess");
setStatus("error");
}
}
}, [code]);
return { status, error };
};
const getLichessUserStudies = async (): Promise<
LichessResult<IUserStudy[]>
> => {
if (!user) return { ok: false, error: "Not signed in to Lichess." };
return lichessFetch(() => getUserStudiesFn(), "Failed to load studies.");
};
const getLichessStudyChapters = async ({
studyId,
}: {
studyId: string;
}): Promise<LichessResult<IUserStudyChapter[]>> => {
if (!user) return { ok: false, error: "Not signed in to Lichess." };
return lichessFetch(
() => getStudyChaptersFn({ data: { studyId } }),
"Failed to load chapters.",
);
};
// return headers and pgn from the Lichess chapter
const loadLichessStudyChapter = async ({
chapter,
}: {
chapter: IUserStudyChapter;
}) => {
const pgn = chapter.pgn.trim();
if (!chapter.pgn) throw new Error("No valid PGN found for chapter");
const headers = {
...getHeaders(pgn),
title: "",
subtitle: "",
author: "",
};
return { headers, pgn };
};
// parse and process lichess study link
const parseLichessStudyLink = (studyLink: string): LichessResult<string> => {
const trimmed = studyLink.trim();
if (!trimmed) {
return { ok: false, error: "Please paste a Lichess study link." };
}
const match = trimmed.match(LICHESS_STUDY_LINK_RE);
if (!match) {
return { ok: false, error: "Invalid Lichess study link." };
}
return { ok: true, data: match[1] };
};
return {
lichessLogin,
lichessLogout,
lichessAccessToken,
getLichessUser,
setLichessUser,
getLichessSession,
useLichessCallback,
getLichessUserStudies,
userStudies,
setUserStudies,
setSelectedStudyId,
selectedStudyId,
getLichessStudyChapters,
loadLichessStudyChapter,
parseLichessStudyLink,
};
};
-80
View File
@@ -1,80 +0,0 @@
import LichessPgnViewer from "lichess-pgn-viewer";
import { type RefObject, useEffect, useRef } from "react";
import type { IPosition } from "#/interfaces.ts";
type ViewerRef = ReturnType<typeof LichessPgnViewer>;
type HandlePlyChange = ({ ply, fen }: IPosition) => void;
interface IHandleBoardClick {
viewerRef: RefObject<ViewerRef | null>;
handlePlyChange: HandlePlyChange;
}
interface IProps {
gamePgn: string;
handlePlyChange: HandlePlyChange;
}
const getBoardEventData = () => {
const variationTags = document.querySelector("variation");
const moves = [...document.querySelectorAll("move")].filter((m) => {
if (!variationTags) return m.className !== "empty";
return m.parentNode?.querySelector("variation") && m.className !== "empty";
});
const ply = moves.findIndex((m) => m.classList.contains("current")) + 1;
return { moves, ply };
};
const handleBoardClick = ({
viewerRef,
handlePlyChange,
}: IHandleBoardClick) => {
const { ply } = getBoardEventData();
const fen = viewerRef.current?.curData().fen ?? "";
handlePlyChange({ ply, fen });
};
export const useLichessPgnViewer = ({ gamePgn, handlePlyChange }: IProps) => {
const viewerRef = useRef<ViewerRef | null>(null);
useEffect(() => {
const element: HTMLElement | null = document.querySelector(".lpv-board");
if (!element) return;
viewerRef.current = LichessPgnViewer(element, {
pgn: gamePgn,
scrollToMove: false,
});
const boardButtons = document.querySelectorAll(".lpv__controls");
const movesList = document.querySelectorAll(".lpv__moves");
if (!boardButtons || !movesList) return;
const handleClick = () => handleBoardClick({ viewerRef, handlePlyChange });
const clickHandlerDelay = () => setTimeout(() => handleClick, 250);
boardButtons.forEach((button) => {
button.addEventListener("click", handleClick);
button.addEventListener("touchstart", handleClick);
});
movesList.forEach((move) => {
move.addEventListener("click", clickHandlerDelay);
move.addEventListener("touchstart", clickHandlerDelay);
});
return () => {
boardButtons.forEach((button) => {
button.removeEventListener("click", handleClick);
button.removeEventListener("touchstart", handleClick);
});
movesList.forEach((move) => {
move.removeEventListener("click", clickHandlerDelay);
move.removeEventListener("touchstart", clickHandlerDelay);
});
};
}, [gamePgn, handlePlyChange]);
return viewerRef;
};
-21
View File
@@ -1,21 +0,0 @@
import { useEffect } from "react";
import { toast } from "react-toastify";
interface IProps {
state: {
success: boolean;
message: string;
} | null;
}
export const useToastStateChange = ({ state }: IProps) => {
useEffect(() => {
if (!state) return;
if (state.success) {
toast.success(state.message);
} else {
toast.error(state.message);
}
}, [state]);
};
-50
View File
@@ -1,50 +0,0 @@
import type { infer } from "zod";
import type { SessionSchema, TokenResponseSchema } from "#/schemas.ts";
export interface ISession extends infer<typeof SessionSchema> {}
export interface ITokenData extends infer<typeof TokenResponseSchema> {}
export interface IUserStudy {
id: string;
name: string;
createdAt: number;
updatedAt: number;
}
export interface IUserStudyChapter {
chapterId: string;
name: string;
pgn: string;
}
export interface IPosition {
ply: number;
fen: string;
}
export interface IHeader {
event: string;
site: string;
date: string;
round: string;
white: string;
black: string;
result: string;
eco: string;
whiteElo: string;
blackElo: string;
plyCount: string;
eventDate: string;
source: string;
title: string;
subtitle: string;
author: string;
[key: string]: string;
}
export interface IGameState {
pgn: string;
headers: IHeader;
diagrams: IPosition[];
diagramClock: boolean;
}
-12
View File
@@ -1,12 +0,0 @@
import { createHash } from "node:crypto";
export const base64UrlEncode = (str: Buffer) => {
return str
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
};
export const sha256 = (buffer: string) =>
createHash("sha256").update(buffer).digest();
-23
View File
@@ -1,23 +0,0 @@
import { getRouteApi, useNavigate } from "@tanstack/react-router";
import GenericErrorView from "#/components/GenericErrorView.tsx";
import LoadingView from "#/components/LoadingView.tsx";
import { useLichess } from "#/hooks/useLichess.ts";
const routeApi = getRouteApi("/callback");
const Callback = () => {
const navigate = useNavigate();
const { useLichessCallback } = useLichess();
const { code } = routeApi.useSearch();
const { status, error } = useLichessCallback({ code });
if (status === "success") navigate({ to: "/chessboard" });
if (status === "error") return <GenericErrorView error={error} />;
return (
<LoadingView title="Logging in..." description="Please wait a moment..." />
);
};
export default Callback;
-160
View File
@@ -1,160 +0,0 @@
import { lazy, Suspense } from "react";
import CustomHeaders from "#/components/CustomHeaders.tsx";
import HeaderFields from "#/components/HeaderFields.tsx";
import LichessButton from "#/components/LichessButton.tsx";
import LichessStudyLinkInput from "#/components/LichessStudyLinkInput.tsx";
import Section from "#/components/Section.tsx";
import SelectLichessStudy from "#/components/SelectLichessStudy.tsx";
import SelectStudyChapter from "#/components/SelectStudyChapter.tsx";
import { useLichessUser } from "#/context/LichessUserContext.tsx";
import { useChessGame } from "#/hooks/useChessGame.ts";
import { useLichess } from "#/hooks/useLichess.ts";
const PgnViewer = lazy(() => import("#/components/PgnViewer.tsx"));
const LoadingBoard = () => {
return (
<div className="relative grid h-125 w-160 items-center text-center">
<div id="loading-bar-spinner" className="spinner">
<div className="spinner-icon"></div>
</div>
</div>
);
};
const Chessboard = () => {
const {
gameState,
fileInputRef,
currentPosition,
generatingPdf,
handleLoadPgn,
handleClearGame,
handleSavePgn,
handleSavePdf,
handleToggleClock,
handleToggleDiagram,
handlePlyChange,
updateHeaders,
gameDispatch,
} = useChessGame();
const { selectedStudyId, setSelectedStudyId } = useLichess();
const { user } = useLichessUser();
return (
<main className="px-4 pb-8 place-self-center min-h-[calc(100vh-226px)]">
<Section title="Convert PGN to PDF">
<LichessButton /> or{" "}
<input
type="file"
id="file-input"
ref={fileInputRef}
className="file-input max-w-xs"
onChange={handleLoadPgn}
/>
{user ? (
<SelectLichessStudy setSelectedStudyId={setSelectedStudyId} />
) : null}
<div />
{user ? (
<SelectStudyChapter
selectedStudyId={selectedStudyId || ""}
gameDispatch={gameDispatch}
/>
) : null}
{user ? (
<div className="flex flex-col">
<p className="text-center">or</p>
<LichessStudyLinkInput setSelectedStudyId={setSelectedStudyId} />
</div>
) : null}
</Section>
<div className="grid lg:grid-cols-2 gap-4">
<Suspense fallback={<LoadingBoard />}>
<div className="mx-auto">
<PgnViewer
gamePgn={gameState.pgn || ""}
handlePlyChange={handlePlyChange}
/>
<div className="grid grid-cols-12 gap-4 max-w-150 mt-2">
<button
className="btn btn-primary col-span-5 flex items-center justify-center"
type="button"
onClick={handleClearGame}
>
<p className="text-sm font-semibold">Clear Game</p>
</button>
<div className="flex gap-1 items-center col-span-3">
<label
htmlFor="diagram-add"
className="text-sm text-(--base-content)"
>
Select Diagram
</label>
<input
id="diagram-toggle"
name="diagram-toggle"
type="checkbox"
className="checkbox-accent"
checked={gameState.diagrams.some(
(d) => d.ply === currentPosition.ply,
)}
onChange={handleToggleDiagram}
/>
</div>
<label className="toggle text-sm text-(--base-content) col-span-4">
Render move times
<input
id="render-times"
name="render-times"
type="checkbox"
disabled={!gameState.pgn}
checked={gameState.diagramClock}
onChange={handleToggleClock}
/>
<span className="slider round" />
</label>
</div>
</div>
</Suspense>
<div className="flex flex-col gap-4 items-center">
<div className="flex justify-center gap-4">
<button
className="btn btn-secondary"
type="button"
onClick={handleSavePgn}
disabled={!gameState.pgn}
>
Save PGN
</button>
<button
className="btn btn-secondary"
type="button"
onClick={handleSavePdf}
disabled={!gameState.pgn || generatingPdf}
>
Save PDF
</button>
</div>
<HeaderFields
headers={gameState.headers}
updateHeaders={updateHeaders}
/>
<CustomHeaders
headers={gameState.headers}
updateHeaders={updateHeaders}
/>
</div>
</div>
</main>
);
};
export default Chessboard;
-34
View File
@@ -1,34 +0,0 @@
import Contact from "#/pages/Contact/Contact.tsx";
import { sendToDiscord } from "#/server/contact.ts";
const ContactContainer = () => {
const handleSubmit = async (_prevState: unknown, data: FormData) => {
const payload = {
name: (data.get("name") as string) || "",
email: (data.get("email") as string) || "",
subject: (data.get("subject") as string) || "",
message: (data.get("message") as string) || "",
};
try {
await sendToDiscord({ data: payload });
return {
success: true,
message: "Message sent successfully!",
};
} catch (error) {
// TODO logger
console.error("Error sending message:", error);
return {
success: false,
message: "Failed to send message. Please try again.",
};
}
};
return <Contact handleSubmit={handleSubmit} />;
};
export default ContactContainer;
-115
View File
@@ -1,115 +0,0 @@
import { useActionState } from "react";
import { LuSend } from "react-icons/lu";
import Section from "#/components/Section.tsx";
import { useToastStateChange } from "#/hooks/useToastStateChange.ts";
interface IFormField {
fieldName: string;
type: "text" | "email" | "textarea";
placeholder: string;
required?: boolean;
textarea?: boolean;
}
type IProps = {
handleSubmit: (
prevState: unknown,
formData: FormData,
) => Promise<{ success: boolean; message: string }>;
};
const FormField = ({
type,
fieldName,
placeholder,
required = false,
textarea = false,
}: IFormField) => (
<div className="flex flex-col gap-2">
<label
htmlFor={fieldName}
className="capitalize text-(--neutral-content) text-left"
>
{fieldName}
{required && <span>*</span>}
</label>
{textarea ? (
<textarea
id={fieldName}
name={fieldName}
placeholder={placeholder}
required={required}
rows={5}
className="text-(--base-content) w-full rounded-md border border-neutral-300 px-3 py-2 placeholder:text-neutral-400/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50 resize-y"
/>
) : (
<input
type={type}
id={fieldName}
name={fieldName}
placeholder={placeholder}
required={required}
className="text-(--base-content) w-full rounded-md border border-neutral-300 px-3 py-2 placeholder:text-neutral-400/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50"
/>
)}
</div>
);
const Contact = ({ handleSubmit }: IProps) => {
const [state, formAction, isPending] = useActionState(handleSubmit, null);
useToastStateChange({ state });
return (
<main className="px-4 pb-8 place-self-center min-h-[calc(100vh-226px)]">
<Section
title="Contact Us"
description="Do you have suggestions on how to improve this service? Would you like to get involved with this project? Ask us anything you like."
>
<form
action={formAction}
className="col-span-full max-w-150 flex flex-col gap-6 w-full"
>
<FormField
type="text"
fieldName="name"
placeholder="Your name"
required
/>
<FormField
fieldName="email"
type="email"
placeholder="name@example.com"
required
/>
<FormField
fieldName="subject"
type="text"
placeholder="Reason for contacting us"
/>
<FormField
fieldName="message"
type="text"
placeholder="Your message here..."
required
textarea
/>
<button
type="submit"
disabled={isPending}
className="btn btn-primary flex items-center justify-center gap-2 w-full"
>
{isPending ? "Sending..." : "Send"}
<LuSend className="w-5 h-5" />
</button>
{/*{state && <p>{state.message}</p>}*/}
</form>
</Section>
</main>
);
};
export default Contact;
-109
View File
@@ -1,109 +0,0 @@
import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react";
import AccordionItem from "#/components/AccordionItem.tsx";
import Feature from "#/components/Feature.tsx";
import Section from "#/components/Section.tsx";
import examplePdf1 from "@/assets/images/examplepdf1.webp?url";
import examplePdf2 from "@/assets/images/examplepdf2.webp?url";
const Home = () => (
<main className="px-4 pb-8 pt-14">
<section
id="hero"
className="relative grid max-w-screen-2xl items-center gap-8 p-8 text-center md:grid-cols-2 md:text-left mx-auto md:min-h-[calc(100vh-226px)]"
>
<div>
<h1 className="text-3xl font-bold text-base-content md:text-4xl">
Convert your Chess PGN
</h1>
<h1 className="text-3xl font-bold text-base-content md:text-4xl">
into a <span className="text-(--accent)">Publishable PDF.</span>
</h1>
<h2 className="mb-8 mt-4 text-(--base-content)">
Upload and convert your games into a book format, along with variation
and annotations.
</h2>
<Link to="/chessboard" className="btn btn-primary btn-large">
Get Started
</Link>
</div>
<div className="example-pdfs">
<Image src={examplePdf2} alt="Example PDF 2" width={500} height={500} />
<Image
src={examplePdf1}
alt="Example PDF 1"
width={500}
height={500}
priority
/>
</div>
<div className="absolute bottom-4 left-1/2 hidden -translate-x-1/2 md:block animate-bounce text-base-content/50">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<title>scroll down</title>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
</section>
<Section
title="A Unique Chess Publication Service"
smallTitle="FEATURES"
description="Convert your chess PGN files into a publishable PDF, complete with diagrams of chosen positions."
>
<Feature title="Fast">
Quick PDF generation using a custom TeX Live server.
</Feature>
<Feature title="Easy">
Upload a PGN of your game, and choose the diagrams you want in your PDF.
Annotations are added automatically.
</Feature>
<Feature title="Reliable">
Dedicated servers ensure that downtime is kept to a minimum.
</Feature>
</Section>
<Section
title="Your Questions Answered"
smallTitle="FAQs"
description="Below we answer some of the most common questions we get regarding this service."
>
<div className="hero__accordion-container">
<AccordionItem title="Is this service free?" id="accordion-1">
Yes. There are no current plans to charge for this service. If in the
future there is a payment plan, it will come with a generous free
tier.
</AccordionItem>
<AccordionItem
title="Will you be adding more features?"
id="accordion-2"
>
We have a lot more features to implement in the near future. If you
have a feature that you would like to see implemented, please feel
free to send us a message via our contact form.
</AccordionItem>
<AccordionItem
title="Do you hold any copyright on the produced materials?"
id="accordion-3"
>
No. We will never implement any terms whereby we have any interest in
the materials created with this service.
</AccordionItem>
</div>
</Section>
</main>
);
export default Home;
-73
View File
@@ -1,73 +0,0 @@
import type { IGameState, IHeader, IPosition } from "#/interfaces.ts";
export type GameAction =
| { type: "SET_GAME"; payload: { pgn: string; headers: IHeader } }
| { type: "CLEAR_GAME" }
| { type: "SET_HEADERS"; payload: IHeader }
| { type: "ADD_DIAGRAM"; payload: IPosition }
| { type: "DELETE_DIAGRAM"; payload: { ply: number } }
| { type: "TOGGLE_DIAGRAM_CLOCK" };
const defaultHeaderFields = {
event: "",
site: "",
date: "",
round: "",
white: "",
black: "",
result: "",
eco: "",
whiteElo: "",
blackElo: "",
plyCount: "",
eventDate: "",
source: "",
title: "",
subtitle: "",
author: "",
} satisfies IHeader;
export const initialGameState: IGameState = {
pgn: "",
headers: defaultHeaderFields,
diagrams: [],
diagramClock: false,
};
export const gameReducer = (state: IGameState, action: GameAction) => {
switch (action.type) {
case "SET_GAME":
return {
pgn: action.payload.pgn,
headers: action.payload.headers,
diagrams: [],
diagramClock: false,
};
case "CLEAR_GAME":
return initialGameState;
case "SET_HEADERS":
return {
...state,
headers: action.payload,
};
case "ADD_DIAGRAM":
return {
...state,
diagrams: [...state.diagrams, action.payload],
};
case "DELETE_DIAGRAM":
return {
...state,
diagrams: state.diagrams.filter(
(diagram: IPosition) => diagram.ply !== action.payload.ply,
),
};
case "TOGGLE_DIAGRAM_CLOCK":
return {
...state,
diagramClock: !state.diagramClock,
};
default:
return state;
}
};
+17 -53
View File
@@ -9,24 +9,12 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ContactRouteImport } from './routes/contact'
import { Route as ChessboardRouteImport } from './routes/chessboard'
import { Route as CallbackRouteImport } from './routes/callback'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
const ContactRoute = ContactRouteImport.update({
id: '/contact',
path: '/contact',
getParentRoute: () => rootRouteImport,
} as any)
const ChessboardRoute = ChessboardRouteImport.update({
id: '/chessboard',
path: '/chessboard',
getParentRoute: () => rootRouteImport,
} as any)
const CallbackRoute = CallbackRouteImport.update({
id: '/callback',
path: '/callback',
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
@@ -37,59 +25,37 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute
'/about': typeof AboutRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute
'/about': typeof AboutRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute
'/about': typeof AboutRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/callback' | '/chessboard' | '/contact'
fullPaths: '/' | '/about'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/callback' | '/chessboard' | '/contact'
id: '__root__' | '/' | '/callback' | '/chessboard' | '/contact'
to: '/' | '/about'
id: '__root__' | '/' | '/about'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
CallbackRoute: typeof CallbackRoute
ChessboardRoute: typeof ChessboardRoute
ContactRoute: typeof ContactRoute
AboutRoute: typeof AboutRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/contact': {
id: '/contact'
path: '/contact'
fullPath: '/contact'
preLoaderRoute: typeof ContactRouteImport
parentRoute: typeof rootRouteImport
}
'/chessboard': {
id: '/chessboard'
path: '/chessboard'
fullPath: '/chessboard'
preLoaderRoute: typeof ChessboardRouteImport
parentRoute: typeof rootRouteImport
}
'/callback': {
id: '/callback'
path: '/callback'
fullPath: '/callback'
preLoaderRoute: typeof CallbackRouteImport
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof AboutRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
@@ -104,9 +70,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
CallbackRoute: CallbackRoute,
ChessboardRoute: ChessboardRoute,
ContactRoute: ContactRoute,
AboutRoute: AboutRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+7 -7
View File
@@ -1,19 +1,19 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
});
})
return router;
return router
}
declare module "@tanstack/react-router" {
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>;
router: ReturnType<typeof getRouter>
}
}
+21 -59
View File
@@ -1,99 +1,61 @@
import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { type ReactNode, Suspense } from "react";
import { ToastContainer } from "react-toastify";
import GenericErrorView from "#/components/GenericErrorView.tsx";
import LoadingView from "#/components/LoadingView.tsx";
import { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx";
import NotFoundView from "#/components/NotFoundView.tsx";
import { LichessUserProvider } from "#/context/LichessUserContext.tsx";
import Footer from "../components/Footer";
import Header from "../components/Header";
import appCss from "../styles.css?url";
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import Footer from '../components/Footer'
import Header from '../components/Header'
import appCss from '../styles.css?url'
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();`
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
charSet: 'utf-8',
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: "ChessScribe",
},
{
name: "description",
content: "Create PDFs of your chess games from a PGN file",
},
{
name: "keywords",
content: "chess, pgn, pdf, chess games, chess notation",
title: 'TanStack Start Starter',
},
],
links: [
{
rel: "stylesheet",
rel: 'stylesheet',
href: appCss,
},
{
rel: "icon",
href: "/favicon.svg",
type: "image/svg+xml",
},
{
rel: "icon",
href: "/favicon.ico",
sizes: "32x32",
},
{
rel: "apple-touch-icon",
href: "/apple-touch-icon.png",
},
{
rel: "manifest",
href: "/site.webmanifest",
},
],
}),
shellComponent: RootDocument,
errorComponent: ({ error }) => <GenericErrorView error={error} />,
pendingComponent: () => <LoadingView />,
notFoundComponent: () => <NotFoundView />,
});
})
function RootDocument({ children }: { children: ReactNode }) {
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<HeadContent />
</head>
<body className="font-sans antialiased">
<LichessUserProvider>
<body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-[rgba(79,184,178,0.24)]">
<Header />
{children}
<ToastContainer position="bottom-right" />
<Footer />
<TanStackDevtools
config={{
position: "bottom-right",
position: 'bottom-right',
}}
plugins={[
{
name: "Tanstack Router",
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
</LichessUserProvider>
<Scripts />
<Suspense fallback={null}>
<MatomoAnalytics />
</Suspense>
</body>
</html>
);
)
}
+23
View File
@@ -0,0 +1,23 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: About,
})
function About() {
return (
<main className="page-wrap px-4 py-12">
<section className="island-shell rounded-2xl p-6 sm:p-8">
<p className="island-kicker mb-2">About</p>
<h1 className="display-title mb-3 text-4xl font-bold text-[var(--sea-ink)] sm:text-5xl">
A small starter with room to grow.
</h1>
<p className="m-0 max-w-3xl text-base leading-8 text-[var(--sea-ink-soft)]">
TanStack Start gives you type-safe routing, server functions, and
modern SSR defaults. Use this as a clean foundation, then layer in
your own routes, styling, and add-ons.
</p>
</section>
</main>
)
}
-29
View File
@@ -1,29 +0,0 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { z } from "zod";
import GenericErrorView from "#/components/GenericErrorView.tsx";
import Callback from "#/pages/Callback.tsx";
const callbackSchema = z.object({
code: z.string(),
});
export const Route = createFileRoute("/callback")({
head: () => ({
meta: [{ name: "robots", content: "noindex, nofollow" }],
}),
validateSearch: (search) => callbackSchema.parse(search),
beforeLoad: ({ search }) => {
if (!search.code) {
throw notFound();
}
},
component: RouteComponent,
errorComponent: ({ error }) => <GenericErrorView error={error} />,
});
function RouteComponent() {
return <Callback />;
}
-15
View File
@@ -1,15 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { HOST_URL } from "#/config.ts";
import Chessboard from "#/pages/Chessboard.tsx";
export const Route = createFileRoute("/chessboard")({
head: () => ({
meta: [{ title: "ChessScribe | Chessboard" }],
links: [{ rel: "canonical", href: `${HOST_URL}/chessboard` }],
}),
component: RouteComponent,
});
function RouteComponent() {
return <Chessboard />;
}
-15
View File
@@ -1,15 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { HOST_URL } from "#/config.ts";
import ContactContainer from "#/pages/Contact/Contact.container.tsx";
export const Route = createFileRoute("/contact")({
head: () => ({
meta: [{ title: "ChessScribe | Contact" }],
links: [{ rel: "canonical", href: `${HOST_URL}/contact` }],
}),
component: RouteComponent,
});
function RouteComponent() {
return <ContactContainer />;
}
+83 -11
View File
@@ -1,15 +1,87 @@
import { createFileRoute } from "@tanstack/react-router";
import { HOST_URL } from "#/config.ts";
import Home from "#/pages";
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute("/")({
head: () => ({
meta: [{ title: "ChessScribe | Home" }],
links: [{ rel: "canonical", href: HOST_URL }],
}),
component: App,
});
export const Route = createFileRoute('/')({ component: App })
function App() {
return <Home />;
return (
<main className="page-wrap px-4 pb-8 pt-14">
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-14">
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_66%)]" />
<p className="island-kicker mb-3">TanStack Start Base Template</p>
<h1 className="display-title mb-5 max-w-3xl text-4xl leading-[1.02] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl">
Start simple, ship quickly.
</h1>
<p className="mb-8 max-w-2xl text-base text-[var(--sea-ink-soft)] sm:text-lg">
This base starter intentionally keeps things light: two routes, clean
structure, and the essentials you need to build from scratch.
</p>
<div className="flex flex-wrap gap-3">
<a
href="/about"
className="rounded-full border border-[rgba(50,143,151,0.3)] bg-[rgba(79,184,178,0.14)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.24)]"
>
About This Starter
</a>
<a
href="https://tanstack.com/router"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-[rgba(23,58,64,0.2)] bg-white/50 px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.35)]"
>
Router Guide
</a>
</div>
</section>
<section className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
[
'Type-Safe Routing',
'Routes and links stay in sync across every page.',
],
[
'Server Functions',
'Call server code from your UI without creating API boilerplate.',
],
[
'Streaming by Default',
'Ship progressively rendered responses for faster experiences.',
],
[
'Tailwind Native',
'Design quickly with utility-first styling and reusable tokens.',
],
].map(([title, desc], index) => (
<article
key={title}
className="island-shell feature-card rise-in rounded-2xl p-5"
style={{ animationDelay: `${index * 90 + 80}ms` }}
>
<h2 className="mb-2 text-base font-semibold text-[var(--sea-ink)]">
{title}
</h2>
<p className="m-0 text-sm text-[var(--sea-ink-soft)]">{desc}</p>
</article>
))}
</section>
<section className="island-shell mt-8 rounded-2xl p-6">
<p className="island-kicker mb-2">Quick Start</p>
<ul className="m-0 list-disc space-y-2 pl-5 text-sm text-[var(--sea-ink-soft)]">
<li>
Edit <code>src/routes/index.tsx</code> to customize the home page.
</li>
<li>
Update <code>src/components/Header.tsx</code> and{' '}
<code>src/components/Footer.tsx</code> for brand links.
</li>
<li>
Add routes in <code>src/routes</code> and tweak visual tokens in{' '}
<code>src/styles.css</code>.
</li>
</ul>
</section>
</main>
)
}
-19
View File
@@ -1,19 +0,0 @@
import { z } from "zod";
export const SessionSchema = z.object({
username: z.string(),
id: z.string(),
token: z.string(),
});
export const TokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
scope: z.string().optional(),
token_type: z.string(),
});
export const LichessAccountSchema = z.object({
id: z.string(),
username: z.string(),
});
-38
View File
@@ -1,38 +0,0 @@
import { createServerFn } from "@tanstack/react-start";
import { env } from "#/env.ts";
export const sendToDiscord = createServerFn({ method: "POST" })
.inputValidator(
(data: { name: string; email: string; subject: string; message: string }) =>
data,
)
.handler(async ({ data }) => {
const response = await fetch(env.DISCORD_CONTACT_WEBHOOK, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
embeds: [
{
title: "Contact Form Submission",
fields: [
{ name: "Name", value: data.name.trim() || "Not provided" },
{ name: "Email", value: data.email.trim() || "Not provided" },
{ name: "Subject", value: data.subject.trim() || "No Subject" },
{
name: "Message",
value: data.message.trim() || "Empty message",
},
],
},
],
}),
});
if (!response.ok) {
throw new Error("Discord API rejected the request");
}
return { success: true };
});
-213
View File
@@ -1,213 +0,0 @@
import { randomBytes } from "node:crypto";
import { redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import {
deleteCookie,
getCookie,
setCookie,
} from "@tanstack/react-start/server";
import { z } from "zod";
import { env } from "#/env.ts";
import { base64UrlEncode, sha256 } from "#/lib/encodeDecode.ts";
import {
LichessAccountSchema,
SessionSchema,
TokenResponseSchema,
} from "#/schemas.ts";
const createVerifier = () => base64UrlEncode(randomBytes(32));
const createChallenge = (verifier: string) => base64UrlEncode(sha256(verifier));
const getRedirectUri = () =>
process.env.NODE_ENV === "production"
? `https://${env.LICHESS_CLIENT_ID}/callback`
: "http://localhost:3000/callback";
export const getSession = createServerFn({ method: "GET" }).handler(
async () => {
const session = getCookie("lichess-session");
if (!session) return null;
try {
const parsed = SessionSchema.safeParse(JSON.parse(session));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
},
);
export const setSession = createServerFn({ method: "POST" })
.inputValidator(SessionSchema)
.handler(async ({ data }) => {
setCookie("lichess-session", JSON.stringify(data), {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
});
setCookie("lichess-oauth-verifier", "", {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 0,
});
return { username: data.username, id: data.id };
});
export const getToken = createServerFn({ method: "POST" })
.inputValidator(z.object({ code: z.string() }))
.handler(async ({ data }) => {
const verifier = getCookie("lichess-oauth-verifier");
if (!verifier) {
throw new Error("OAuth session expired. Please login again.");
}
const response = await fetch("https://lichess.org/api/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: env.LICHESS_CLIENT_ID || "",
code: data.code,
redirect_uri: getRedirectUri(),
code_verifier: verifier,
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${await response.text()}`);
}
const json = await response.json();
return TokenResponseSchema.parse(json);
});
export const getUser = createServerFn({ method: "POST" })
.inputValidator(z.object({ access_token: z.string() }))
.handler(async ({ data }) => {
const response = await fetch("https://lichess.org/api/account", {
headers: {
Authorization: `Bearer ${data.access_token}`,
},
});
if (!response.ok) throw new Error("Failed to fetch Lichess account");
const json = await response.json();
return LichessAccountSchema.parse(json);
});
const deleteAccessToken = createServerFn().handler(async () => {
const session = await getSession();
const token = session?.token;
if (token) {
const response = await fetch("https://lichess.org/api/token", {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to delete access token: ${response.statusText}`);
}
return { success: true };
}
return { success: false, message: "No token found to delete." };
});
export const logout = createServerFn().handler(async () => {
await deleteAccessToken();
deleteCookie("lichess-session", { path: "/" });
deleteCookie("lichess-oauth-verifier", { path: "/" }); // cookie shouldn't exist
});
export const login = createServerFn({ method: "GET" }).handler(async () => {
const verifier = createVerifier();
const challenge = createChallenge(verifier);
setCookie("lichess-oauth-verifier", verifier, {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 10,
});
const params = new URLSearchParams({
response_type: "code",
client_id: env.LICHESS_CLIENT_ID || "",
redirect_uri: getRedirectUri(),
scope: "study:read",
code_challenge_method: "S256",
code_challenge: challenge,
});
throw redirect({
href: `https://lichess.org/oauth?${params.toString()}`,
});
});
export const getUserStudies = createServerFn({ method: "POST" }).handler(
async () => {
const session = await getSession();
if (!session) return;
const response = await fetch(
`https://lichess.org/api/study/by/${session.username}`,
{
headers: {
Authorization: `Bearer ${session.token}`,
},
},
);
const studies = await response.text();
return studies
.trim()
.split("\n")
.map((study) => JSON.parse(study));
},
);
export const getStudyChapters = createServerFn({ method: "POST" })
.inputValidator((data: { studyId: string }) => data)
.handler(async ({ data }) => {
const session = await getSession();
if (!session) return;
const response = await fetch(
`https://lichess.org/api/study/${data.studyId}.pgn`,
{
headers: {
Authorization: `Bearer ${session.token}`,
},
},
);
const text = await response.text();
return text
.trim()
.split(/\n{3,}/)
.map((game) => {
const eventHeaderText = game.match(/\[Event\s+"([^"]+)"]/)?.[1];
const eventName = eventHeaderText?.match(/(?<=:\s+)[^"]+/)?.[0] || "";
const siteHeaderText = game.match(/\[Site\s+"([^"]+)"]/)?.[1];
const chapterId = siteHeaderText?.match(/\/([^/]+)$/)?.[1] || "";
return { chapterId, pgn: game, name: eventName };
});
});
-41
View File
@@ -1,41 +0,0 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { createServerFn } from "@tanstack/react-start";
const METRICS_DIR = join(process.cwd(), "data");
const LOG_FILE = join(METRICS_DIR, "pdf-events.log");
const COUNT_FILE = join(METRICS_DIR, "pdf-count.txt");
const readCount = async (): Promise<number> => {
try {
const n = Number.parseInt(
(await fs.readFile(COUNT_FILE, "utf-8")).trim(),
10,
);
return Number.isFinite(n) ? n : 0;
} catch {
return 0;
}
};
const writeCountAtomic = async (count: number) => {
const tmp = `${COUNT_FILE}.tmp`;
await fs.writeFile(tmp, String(count), "utf-8");
await fs.rename(tmp, COUNT_FILE);
};
export const recordPdfSuccess = createServerFn({ method: "POST" }).handler(
async () => {
try {
await fs.mkdir(METRICS_DIR, { recursive: true });
const next = (await readCount()) + 1;
await Promise.all([
fs.appendFile(LOG_FILE, `${new Date().toISOString()}\n`, "utf-8"),
writeCountAtomic(next),
]);
return { count: next };
} catch (err) {
console.error("Failed to record PDF success:", err);
}
},
);
+200 -83
View File
@@ -1,33 +1,78 @@
@import "tailwindcss";
@import "styles/_accordion.css";
@import "./styles/_input.css";
@import "./styles/_button.css";
@import "./styles/_file-input.css";
@import './styles/_pgn-viewer.css';
@import "./styles/_toastify-overrides.css";
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap");
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* inter-latin-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url('@fontsource-variable/inter/files/inter-latin-wght-normal.woff2') format('woff2-variations');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@theme {
--font-sans: "Inter Variable", sans-serif;
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
}
:root {
--base-content: #1f2937;
--neutral-content: #4b5563;
--accent: #6f884d;
--sea-ink: #173a40;
--sea-ink-soft: #416166;
--lagoon: #4fb8b2;
--lagoon-deep: #328f97;
--palm: #2f6a4a;
--sand: #e7f0e8;
--foam: #f3faf5;
--surface: rgba(255, 255, 255, 0.74);
--surface-strong: rgba(255, 255, 255, 0.9);
--line: rgba(23, 58, 64, 0.14);
--inset-glint: rgba(255, 255, 255, 0.82);
--kicker: rgba(47, 106, 74, 0.9);
--bg-base: #e7f3ec;
--header-bg: rgba(251, 255, 248, 0.84);
--chip-bg: rgba(255, 255, 255, 0.8);
--chip-line: rgba(47, 106, 74, 0.18);
--link-bg-hover: rgba(255, 255, 255, 0.9);
--hero-a: rgba(79, 184, 178, 0.36);
--hero-b: rgba(47, 106, 74, 0.2);
}
:root[data-theme="dark"] {
--sea-ink: #d7ece8;
--sea-ink-soft: #afcdc8;
--lagoon: #60d7cf;
--lagoon-deep: #8de5db;
--palm: #6ec89a;
--sand: #0f1a1e;
--foam: #101d22;
--surface: rgba(16, 30, 34, 0.8);
--surface-strong: rgba(15, 27, 31, 0.92);
--line: rgba(141, 229, 219, 0.18);
--inset-glint: rgba(194, 247, 238, 0.14);
--kicker: #b8efe5;
--bg-base: #0a1418;
--header-bg: rgba(10, 20, 24, 0.8);
--chip-bg: rgba(13, 28, 32, 0.9);
--chip-line: rgba(141, 229, 219, 0.24);
--link-bg-hover: rgba(24, 44, 49, 0.8);
--hero-a: rgba(96, 215, 207, 0.18);
--hero-b: rgba(110, 200, 154, 0.12);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--sea-ink: #d7ece8;
--sea-ink-soft: #afcdc8;
--lagoon: #60d7cf;
--lagoon-deep: #8de5db;
--palm: #6ec89a;
--sand: #0f1a1e;
--foam: #101d22;
--surface: rgba(16, 30, 34, 0.8);
--surface-strong: rgba(15, 27, 31, 0.92);
--line: rgba(141, 229, 219, 0.18);
--inset-glint: rgba(194, 247, 238, 0.14);
--kicker: #b8efe5;
--bg-base: #0a1418;
--header-bg: rgba(10, 20, 24, 0.8);
--chip-bg: rgba(13, 28, 32, 0.9);
--chip-line: rgba(141, 229, 219, 0.24);
--link-bg-hover: rgba(24, 44, 49, 0.8);
--hero-a: rgba(96, 215, 207, 0.18);
--hero-b: rgba(110, 200, 154, 0.12);
}
}
* {
@@ -38,99 +83,171 @@ html,
body,
#app {
min-height: 100%;
background-color: var(--bg-base);
}
body {
margin: 0;
font-family: var(--font-sans), sans-serif;
color: var(--sea-ink);
font-family: var(--font-sans);
background-color: var(--bg-base);
background:
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),
linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.nav-container {
background-color: var(--header-bg);
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.28;
background:
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.8), transparent 34%),
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
}
.nav-title {
color: var(--accent);
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.14;
background-image:
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
}
.nav-item {
color: var(--neutral-content);
&:hover {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 8px;
}
a {
color: var(--lagoon-deep);
text-decoration-color: rgba(50, 143, 151, 0.4);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
.btn {
padding: 1rem 4rem;
a:hover {
color: #246f76;
}
.btn-primary {
background-color: var(--accent);
color: var(--bg-base);
transition: filter 0.3s ease;
&:hover {
filter: brightness(0.85);
}
code {
font-size: 0.9em;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
border-radius: 7px;
padding: 2px 7px;
}
.example-pdfs {
max-width: 450px;
pre code {
border: 0;
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.page-wrap {
width: min(1080px, calc(100% - 2rem));
margin-inline: auto;
}
.display-title {
font-family: "Fraunces", Georgia, serif;
}
.island-shell {
border: 1px solid var(--line);
background: linear-gradient(165deg, var(--surface-strong), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 22px 44px rgba(30, 90, 72, 0.1),
0 6px 18px rgba(23, 58, 64, 0.08);
backdrop-filter: blur(4px);
}
.feature-card {
background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, white 7%), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 18px 34px rgba(30, 90, 72, 0.1),
0 4px 14px rgba(23, 58, 64, 0.06);
}
.feature-card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
}
button,
.island-shell,
a {
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease,
transform 180ms ease;
}
.island-kicker {
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
font-size: 0.69rem;
color: var(--kicker);
}
.nav-link {
position: relative;
justify-self: center;
transform: translate(-10%, 10%);
img {
width: 300px;
box-shadow: -1px 1px 62px -18px black;
&:nth-child(2) {
display: inline-flex;
align-items: center;
text-decoration: none;
color: var(--sea-ink-soft);
}
.nav-link::after {
content: "";
position: absolute;
top: -10%;
right: -20%;
}
left: 0;
bottom: -6px;
width: 100%;
height: 2px;
transform: scaleX(0);
transform-origin: left;
background: linear-gradient(90deg, var(--lagoon), #7ed3bf);
transition: transform 170ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sea-ink);
}
.nav-link:hover::after,
.nav-link.is-active::after {
transform: scaleX(1);
}
@media (max-width: 640px) {
.nav-link::after {
bottom: -4px;
}
}
#loading-bar-spinner.spinner {
left: 50%;
margin-left: -20px;
top: 40%;
margin-top: -20px;
position: absolute;
z-index: 19 !important;
animation: loading-bar-spinner 400ms linear infinite;
}
#loading-bar-spinner.spinner .spinner-icon {
width: 40px;
height: 40px;
border: solid 4px transparent;
border-top-color: var(--accent) !important;
border-left-color: var(--neutral-content) !important;
border-radius: 50%;
.site-footer {
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
}
.rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes loading-bar-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes rise-in {
from {
opacity: 0;
-73
View File
@@ -1,73 +0,0 @@
.hero__accordion-container {
grid-column: 1 / -1;
max-width: 600px;
padding: 1rem;
}
.hero__accordion-item {
overflow: clip;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 0.15s;
cursor: pointer;
padding: 1rem 0;
}
.hero__accordion-item:not(:last-child) {
border-bottom: 1px solid var(--neutral-content);
}
.hero__accordion-item__content {
padding: 0 10px;
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 0.3s ease,
padding 0.3s ease;
@media (prefers-reduced-motion) {
transition: none;
}
}
.hero__accordion-details[open] + .hero__accordion-item__content {
grid-template-rows: 1fr;
padding: 10px;
}
.hero__accordion-details[open] .hero__accordion-item__title-container:after {
transform: rotate(270deg);
}
.hero__accordion-item__summary {
display: block;
&::-webkit-details-marker {
display: none;
}
}
.hero__accordion-item__title-container {
display: flex;
justify-content: space-between;
&:after {
content: "\276F";
color: var(--neutral-content);
margin-right: 1rem;
transform: rotate(90deg);
transition: transform 0.3s ease;
}
}
.hero__accordion-item__title {
color: var(--neutral-content);
font-size: 1.25rem;
font-weight: 600;
}
.hero__accordion-item__content__inner {
color: var(--neutral-content);
overflow: hidden;
margin: 0;
}
-46
View File
@@ -1,46 +0,0 @@
.btn {
display: inline-flex;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
border-radius: 0.5rem;
height: 50px;
align-items: center;
justify-items: center;
transition: all 0.3s ease;
&:disabled {
cursor: default;
pointer-events: none;
opacity: 0.5;
}
}
.btn-large {
font-size: 1.25rem;
padding: 1rem 4rem;
border-radius: 1.5rem;
height: auto;
}
.btn-primary {
background-color: var(--accent);
color: var(--bg-base);
&:hover {
background-color: var(--bg-base);
color: var(--accent);
}
}
.btn-secondary {
background-color: var(--bg-base);
color: var(--accent);
border: 1px solid var(--accent);
&:hover {
background-color: var(--accent);
color: var(--bg-base);
}
}
-22
View File
@@ -1,22 +0,0 @@
.file-input {
height: 3rem;
padding-inline-end: 1rem;
font-size: 0.875rem;
line-height: 2;
border-width: 1px;
border-color: var(--accent);
border-radius: 0.5rem;
background-color: var(--header-bg);
flex-shrink: 1;
}
.file-input::file-selector-button {
color: var(--header-bg);
font-weight: 600;
text-transform: uppercase;
text-align: center;
background-color: var(--accent);
cursor: pointer;
height: 100%;
padding: 0 1rem;
}
-81
View File
@@ -1,81 +0,0 @@
/* Headers */
.headers__input {
height: 3rem;
padding: 0 1rem;
line-height: 1.5rem;
border-radius: 0.5rem;
border: 1px solid var(--neutral-content);
&:focus {
outline: none;
border-color: var(--accent);
background-color: var(--header-bg);
}
}
/* Checkbox */
.checkbox-accent {
accent-color: var(--accent);
width: 18px;
height: 18px;
cursor: pointer;
}
/* Toggle */
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: relative;
cursor: pointer;
width: 36px;
height: 20px;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
flex-shrink: 0;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
-webkit-transition: 0.4s;
transition: 0.4s;
}
input:checked + .slider {
background-color: var(--accent);
}
input:focus + .slider {
box-shadow: 0 0 1px var(--accent);
}
input:checked + .slider:before {
-webkit-transform: translateX(16px);
-ms-transform: translateX(16px);
transform: translateX(16px);
}
.slider.round {
border-radius: 12px;
}
.slider.round:before {
border-radius: 50%;
}
-7
View File
@@ -1,7 +0,0 @@
@import "./vendor/lichess-pgn-viewer.css";
.lpv-board {
max-width: 600px;
--board-color: #b58863;
font-family: "Noto Sans", sans-serif;
}
-4
View File
@@ -1,4 +0,0 @@
:root {
/* biome-ignore lint/complexity/noImportantStyles: <override vendor style requires !important> */
--toastify-color-success: var(--accent) !important;
}
File diff suppressed because one or more lines are too long
-8
View File
@@ -1,8 +0,0 @@
export const downloadPDF = (pdf: Blob) => {
const fileName = "game.pdf";
const fileURL = URL.createObjectURL(pdf);
const a = document.createElement("a");
a.href = fileURL;
a.download = fileName;
a.click();
};
-61
View File
@@ -1,61 +0,0 @@
import type { IGameState } from "#/interfaces.ts";
export const getHeaders = (pgn: string) => {
const pgnHeader = pgn.split(/\n\n/g)[0];
const getHeaderField = (field: string) => {
const regex = new RegExp(`(?<=${field}.").*(?=")`);
const match = pgnHeader.match(regex);
return match && match[0] !== "undefined" ? match[0] : "";
};
return {
event: getHeaderField("Event"),
site: getHeaderField("Site"),
date: getHeaderField("Date"),
round: getHeaderField("Round"),
white: getHeaderField("White"),
black: getHeaderField("Black"),
result: getHeaderField("Result"),
eco: getHeaderField("ECO"),
whiteElo: getHeaderField("WhiteElo"),
blackElo: getHeaderField("BlackElo"),
plyCount: getHeaderField("PlyCount"),
eventDate: getHeaderField("EventDate"),
source: getHeaderField("Source"),
};
};
// Create a string based on new header values
export const buildPgnString = (game: IGameState) => {
const moves = game.pgn?.split(/\n\n/g)[1];
// if customer headers are present, use them
if (
game.headers.title?.length > 0 ||
game.headers.subtitle?.length > 0 ||
game.headers.author?.length > 0
) {
return `[Result "${game.headers.result}"]
[Title "${game.headers.title}"]
[Subtitle "${game.headers.subtitle}"]
[Date "${game.headers.date}"]
[Author "${game.headers.author}"]\n
${moves}`;
}
return `[Event "${game.headers.event}"]
[Site "${game.headers.site}"]
[Date "${game.headers.date}"]
[Round "${game.headers.round}"]
[White "${game.headers.white}"]
[Black "${game.headers.black}"]
[Result "${game.headers.result}"]
[ECO "${game.headers.eco}"]
[WhiteElo "${game.headers.whiteElo}"]
[BlackElo "${game.headers.blackElo}"]
[PlyCount "${game.headers.plyCount}"]
[EventDate "${game.headers.eventDate}"]
[Source "${game.headers.source}"]\n
${moves}`;
};
-9
View File
@@ -1,9 +0,0 @@
export const downloadString = (string: string, filename: string) => {
const element = document.createElement("a");
const file = new Blob([string], { type: "text/plain" });
element.href = URL.createObjectURL(file);
element.download = filename;
document.body.appendChild(element);
element.click();
};
+9 -30
View File
@@ -1,12 +1,11 @@
import tailwindcss from "@tailwindcss/vite";
import { devtools } from "@tanstack/devtools-vite";
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
import { HOST_URL } from "#/config.ts";
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
const config = defineConfig({
resolve: { tsconfigPaths: true },
@@ -14,29 +13,9 @@ const config = defineConfig({
devtools(),
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
tailwindcss(),
tanstackStart({
prerender: {
enabled: true,
crawlLinks: true,
},
pages: [
{
path: "/callback",
prerender: {
enabled: false,
},
sitemap: {
exclude: true,
},
},
],
sitemap: {
enabled: true,
host: HOST_URL,
},
}),
tanstackStart(),
viteReact(),
],
});
})
export default config;
export default config