Compare commits

..

13 Commits

Author SHA1 Message Date
owenrees c199bb15ef add Matomo analytics code 2026-05-29 22:27:37 +02:00
owenrees 5ab1d94576 use pnpm as package manager 2026-05-29 22:21:52 +02:00
owenrees a4b75dc1b0 add footer 2026-05-29 22:20:50 +02:00
owenrees 56368fc8f3 bg colour fixes 2026-05-29 21:54:33 +02:00
owenrees 0a71f4e4be font loading and colours fixed 2026-05-29 21:51:44 +02:00
owenrees ea11678365 remove bg gradient 2026-05-29 21:27:08 +02:00
owenrees aa4e1844a3 remove theme init script 2026-05-29 21:22:20 +02:00
owenrees ce9bc0314c basic navbar layout 2026-05-29 21:14:57 +02:00
owenrees 4cbe1f404e remove theme toggle adn add link to chessboard route 2026-05-29 20:50:02 +02:00
owenrees 74e33f70c0 remove dark mode css 2026-05-29 20:47:40 +02:00
owenrees c14909d116 setup correct routes, add analytics and pgn viewer deps 2026-05-29 20:45:26 +02:00
owenrees 7f8cc180c3 update Biome scheme to 2.4.5 2026-05-29 20:38:25 +02:00
owenrees 2f973e9991 initial commit 2026-05-29 20:36:55 +02:00
84 changed files with 259 additions and 3909 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 .vinxi
__unconfig* __unconfig*
todos.json todos.json
data
-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
+2 -6
View File
@@ -13,12 +13,7 @@
"**/index.html", "**/index.html",
"**/vite.config.ts", "**/vite.config.ts",
"!**/src/routeTree.gen.ts", "!**/src/routeTree.gen.ts",
"!**/src/styles.css", "!**/src/styles.css"
"!**/.tanstack/*",
"!**/.output/*",
"!**/.nitro/*",
"!**/node_modules/*",
"!**/styles/vendor/*"
] ]
}, },
"formatter": { "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"
+8 -6
View File
@@ -6,14 +6,11 @@
"#/*": "./src/*" "#/*": "./src/*"
}, },
"scripts": { "scripts": {
"dev": "vite dev --port 3000 --host", "dev": "vite dev --port 3000",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "node .output/server/index.mjs",
"test": "vitest run", "test": "vitest run",
"lint:fix": "biome lint --write",
"format": "biome format", "format": "biome format",
"format:fix": "biome format --write",
"lint": "biome lint", "lint": "biome lint",
"check": "biome check" "check": "biome check"
}, },
@@ -29,11 +26,10 @@
"@tanstack/router-plugin": "^1.132.0", "@tanstack/router-plugin": "^1.132.0",
"@unpic/react": "^1.0.2", "@unpic/react": "^1.0.2",
"lichess-pgn-viewer": "^2.4.5", "lichess-pgn-viewer": "^2.4.5",
"lucide-react": "^1.3.0",
"nitro": "npm:nitro-nightly@latest", "nitro": "npm:nitro-nightly@latest",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-icons": "^5.6.0",
"react-toastify": "^11.1.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -51,5 +47,11 @@
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^8.0.0", "vite": "^8.0.0",
"vitest": "^4.1.5" "vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
} }
} }
+17 -40
View File
@@ -41,6 +41,9 @@ importers:
lichess-pgn-viewer: lichess-pgn-viewer:
specifier: ^2.4.5 specifier: ^2.4.5
version: 2.4.5 version: 2.4.5
lucide-react:
specifier: ^1.3.0
version: 1.3.0(react@19.2.6)
nitro: nitro:
specifier: npm:nitro-nightly@latest specifier: npm:nitro-nightly@latest
version: nitro-nightly@3.0.1-20260529-064703-255b254f(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(vite@8.0.14(@types/node@22.19.19)(jiti@2.7.0)) version: nitro-nightly@3.0.1-20260529-064703-255b254f(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.1)(vite@8.0.14(@types/node@22.19.19)(jiti@2.7.0))
@@ -50,12 +53,6 @@ importers:
react-dom: react-dom:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.6(react@19.2.6) version: 19.2.6(react@19.2.6)
react-icons:
specifier: ^5.6.0
version: 5.6.0(react@19.2.6)
react-toastify:
specifier: ^11.1.0
version: 11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwindcss: tailwindcss:
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.3.0 version: 4.3.0
@@ -315,9 +312,6 @@ packages:
'@emnapi/runtime@1.10.0': '@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -1720,6 +1714,11 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.3.0:
resolution: {integrity: sha512-aKUU4jKpXe26Y3xmF3DGg+NXHteRCVT96r/+Jp7wziqvmFr0/x8ReRsES0wHgTvW7OLxiHvFLN+YeyDnK6h0dQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lz-string@1.5.0: lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
@@ -1856,20 +1855,9 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.6 react: ^19.2.6
react-icons@5.6.0:
resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==}
peerDependencies:
react: '*'
react-is@17.0.2: react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-toastify@11.1.0:
resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==}
peerDependencies:
react: ^18 || ^19
react-dom: ^18 || ^19
react@19.2.6: react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1906,8 +1894,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
semver@7.8.5: semver@7.8.1:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
@@ -2492,11 +2480,6 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -2591,7 +2574,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
dependencies: dependencies:
'@emnapi/runtime': 1.11.1 '@emnapi/runtime': 1.10.0
optional: true optional: true
'@img/sharp-win32-arm64@0.34.5': '@img/sharp-win32-arm64@0.34.5':
@@ -3680,6 +3663,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.3.0(react@19.2.6):
dependencies:
react: 19.2.6
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string@0.30.21: magic-string@0.30.21:
@@ -3850,18 +3837,8 @@ snapshots:
react: 19.2.6 react: 19.2.6
scheduler: 0.27.0 scheduler: 0.27.0
react-icons@5.6.0(react@19.2.6):
dependencies:
react: 19.2.6
react-is@17.0.2: {} react-is@17.0.2: {}
react-toastify@11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
clsx: 2.1.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react@19.2.6: {} react@19.2.6: {}
readdirp@5.0.0: {} readdirp@5.0.0: {}
@@ -3920,7 +3897,7 @@ snapshots:
semver@6.3.1: {} semver@6.3.1: {}
semver@7.8.5: semver@7.8.1:
optional: true optional: true
seroval-plugins@1.5.4(seroval@1.5.4): seroval-plugins@1.5.4(seroval@1.5.4):
@@ -3933,7 +3910,7 @@ snapshots:
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.8.5 semver: 7.8.1
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5
-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: * User-agent: *
Allow: / Disallow:
Sitemap: https://chess-scribe.org/sitemap.xml
-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"}
-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

-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;
-1
View File
@@ -13,7 +13,6 @@ const CoffeeWidget = ({ height, width }: IProps) => {
target="_blank" target="_blank"
className="ml-auto flex items-center gap-2 text-sm" className="ml-auto flex items-center gap-2 text-sm"
rel="noopener" rel="noopener"
aria-label="Buy Me A Coffee"
> >
<Image <Image
src={buyMeACoffeeLogo} src={buyMeACoffeeLogo}
-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;
+10 -23
View File
@@ -1,47 +1,34 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react"; import { Image } from "@unpic/react";
import { LuGithub, LuMail } from "react-icons/lu";
import CoffeeWidget from "#/components/CoffeeWidget.tsx"; import CoffeeWidget from "#/components/CoffeeWidget.tsx";
import { GITHUB_URL } from "#/config.ts";
import footerLogo from "@/assets/images/footerLogo.svg?url"; import footerLogo from "@/assets/images/footerLogo.svg?url";
export default function Footer() { export default function Footer() {
return ( return (
<footer className="text-white w-full grid-cols-2 items-center bg-(--accent) px-10 py-5"> <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"> <div className="flex w-full items-center">
<Link to="/" aria-label="Home"> <Link to="/">
<Image <Image
src={footerLogo} src={footerLogo}
alt="ChessScribe Logo" alt="ChessScribe Logo"
width={10} width={10}
height={10} height={10}
className="hidden h-10 w-10 sm:block" className="hidden h-10 w-10 sm:block"
aria-label="ChessScribe Logo"
/> />
</Link> </Link>
<div> <div>
<p>© 2023 - {new Date().getFullYear()}</p> <p data-testid="copyright">
Copyright © 2023 - {new Date().getFullYear()}
</p>
</div> </div>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<a <Link to="/" target="_blank" rel="noopener noreferrer">
href={GITHUB_URL} Github
target="_blank" </Link>
rel="noopener noreferrer"
aria-label="GitHub"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out cursor-pointer"
>
<LuGithub />
</a>
<Link <Link to="/" target="_blank" rel="noopener noreferrer">
to="/contact" Mail
rel="noopener noreferrer"
aria-label="Contact"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out"
>
<LuMail />
</Link> </Link>
</div> </div>
<CoffeeWidget width={20} height={50} /> <CoffeeWidget width={20} height={50} />
-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;
+1 -16
View File
@@ -1,26 +1,11 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react"; 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 logo from "@/assets/images/logo.svg?url";
export default function Header() { 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 ( return (
<header> <header>
{maintenanceModeMessage ? (
<div className="absolute w-full">
<MaintenanceModeBanner message={maintenanceModeMessage} />
</div>
) : null}
<nav className="w-full max-w-screen-2xl place-self-center p-8"> <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="container mx-auto flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
-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;
+2 -3
View File
@@ -1,10 +1,9 @@
import { trackAppRouter } from "@socialgouv/matomo-next"; import { trackAppRouter } from "@socialgouv/matomo-next";
import { useLocation, useSearch } from "@tanstack/react-router"; import { useLocation, useSearch } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { env } from "#/env.ts";
const MATOMO_URL = env.VITE_MATOMO_URL; const MATOMO_URL = import.meta.env.NEXT_PUBLIC_MATOMO_URL;
const MATOMO_SITE_ID = env.VITE_MATOMO_SITE_ID; const MATOMO_SITE_ID = import.meta.env.NEXT_PUBLIC_MATOMO_SITE_ID;
export function MatomoAnalytics() { export function MatomoAnalytics() {
const location = useLocation(); const location = useLocation();
-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;
-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;
}
};
+3 -21
View File
@@ -11,7 +11,6 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as ContactRouteImport } from './routes/contact' import { Route as ContactRouteImport } from './routes/contact'
import { Route as ChessboardRouteImport } from './routes/chessboard' import { Route as ChessboardRouteImport } from './routes/chessboard'
import { Route as CallbackRouteImport } from './routes/callback'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
const ContactRoute = ContactRouteImport.update({ const ContactRoute = ContactRouteImport.update({
@@ -24,11 +23,6 @@ const ChessboardRoute = ChessboardRouteImport.update({
path: '/chessboard', path: '/chessboard',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const CallbackRoute = CallbackRouteImport.update({
id: '/callback',
path: '/callback',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -37,34 +31,30 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute '/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute '/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute '/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/callback' | '/chessboard' | '/contact' fullPaths: '/' | '/chessboard' | '/contact'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/callback' | '/chessboard' | '/contact' to: '/' | '/chessboard' | '/contact'
id: '__root__' | '/' | '/callback' | '/chessboard' | '/contact' id: '__root__' | '/' | '/chessboard' | '/contact'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
CallbackRoute: typeof CallbackRoute
ChessboardRoute: typeof ChessboardRoute ChessboardRoute: typeof ChessboardRoute
ContactRoute: typeof ContactRoute ContactRoute: typeof ContactRoute
} }
@@ -85,13 +75,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChessboardRouteImport preLoaderRoute: typeof ChessboardRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/callback': {
id: '/callback'
path: '/callback'
fullPath: '/callback'
preLoaderRoute: typeof CallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -104,7 +87,6 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
CallbackRoute: CallbackRoute,
ChessboardRoute: ChessboardRoute, ChessboardRoute: ChessboardRoute,
ContactRoute: ContactRoute, ContactRoute: ContactRoute,
} }
+7 -7
View File
@@ -1,19 +1,19 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router"; import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from "./routeTree.gen"; import { routeTree } from './routeTree.gen'
export function getRouter() { export function getRouter() {
const router = createTanStackRouter({ const router = createTanStackRouter({
routeTree, routeTree,
scrollRestoration: true, scrollRestoration: true,
defaultPreload: "intent", defaultPreload: 'intent',
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
}); })
return router; return router
} }
declare module "@tanstack/react-router" { declare module '@tanstack/react-router' {
interface Register { interface Register {
router: ReturnType<typeof getRouter>; router: ReturnType<typeof getRouter>
} }
} }
-37
View File
@@ -2,12 +2,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { type ReactNode, Suspense } from "react"; 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 { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx";
import NotFoundView from "#/components/NotFoundView.tsx";
import { LichessUserProvider } from "#/context/LichessUserContext.tsx";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Header from "../components/Header"; import Header from "../components/Header";
import appCss from "../styles.css?url"; import appCss from "../styles.css?url";
@@ -25,44 +20,15 @@ export const Route = createRootRoute({
{ {
title: "ChessScribe", 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",
},
], ],
links: [ links: [
{ {
rel: "stylesheet", rel: "stylesheet",
href: appCss, 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, shellComponent: RootDocument,
errorComponent: ({ error }) => <GenericErrorView error={error} />,
pendingComponent: () => <LoadingView />,
notFoundComponent: () => <NotFoundView />,
}); });
function RootDocument({ children }: { children: ReactNode }) { function RootDocument({ children }: { children: ReactNode }) {
@@ -72,10 +38,8 @@ function RootDocument({ children }: { children: ReactNode }) {
<HeadContent /> <HeadContent />
</head> </head>
<body className="font-sans antialiased"> <body className="font-sans antialiased">
<LichessUserProvider>
<Header /> <Header />
{children} {children}
<ToastContainer position="bottom-right" />
<Footer /> <Footer />
<TanStackDevtools <TanStackDevtools
config={{ config={{
@@ -88,7 +52,6 @@ function RootDocument({ children }: { children: ReactNode }) {
}, },
]} ]}
/> />
</LichessUserProvider>
<Scripts /> <Scripts />
<Suspense fallback={null}> <Suspense fallback={null}>
<MatomoAnalytics /> <MatomoAnalytics />
-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 />;
}
+4 -10
View File
@@ -1,15 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from '@tanstack/react-router'
import { HOST_URL } from "#/config.ts";
import Chessboard from "#/pages/Chessboard.tsx";
export const Route = createFileRoute("/chessboard")({ export const Route = createFileRoute('/chessboard')({
head: () => ({
meta: [{ title: "ChessScribe | Chessboard" }],
links: [{ rel: "canonical", href: `${HOST_URL}/chessboard` }],
}),
component: RouteComponent, component: RouteComponent,
}); })
function RouteComponent() { function RouteComponent() {
return <Chessboard />; return <div>Hello "/chessboard"!</div>
} }
+4 -10
View File
@@ -1,15 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"; 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")({ export const Route = createFileRoute('/contact')({
head: () => ({
meta: [{ title: "ChessScribe | Contact" }],
links: [{ rel: "canonical", href: `${HOST_URL}/contact` }],
}),
component: RouteComponent, component: RouteComponent,
}); })
function RouteComponent() { function RouteComponent() {
return <ContactContainer />; return <div>Hello "/contact"!</div>
} }
+82 -10
View File
@@ -1,15 +1,87 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { HOST_URL } from "#/config.ts";
import Home from "#/pages";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({ component: App });
head: () => ({
meta: [{ title: "ChessScribe | Home" }],
links: [{ rel: "canonical", href: HOST_URL }],
}),
component: App,
});
function 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);
}
},
);
+21 -60
View File
@@ -1,11 +1,4 @@
@import "tailwindcss"; @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";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
/* inter-latin-wght-normal */ /* inter-latin-wght-normal */
@@ -69,68 +62,36 @@ body {
} }
} }
.btn { /*a {*/
padding: 1rem 4rem; /* text-decoration-thickness: 1px;*/
} /* text-underline-offset: 2px;*/
/*}*/
.btn-primary { /*a:hover {*/
background-color: var(--accent); /* color: #246f76;*/
color: var(--bg-base); /*}*/
transition: filter 0.3s ease;
&:hover { /*.page-wrap {*/
filter: brightness(0.85); /* width: min(1080px, calc(100% - 2rem));*/
} /* margin-inline: auto;*/
} /*}*/
.example-pdfs { /*button,*/
max-width: 450px; /*a {*/
position: relative; /* transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease,*/
justify-self: center; /* transform 180ms ease;*/
transform: translate(-10%, 10%); /*}*/
img {
width: 300px;
box-shadow: -1px 1px 62px -18px black;
&:nth-child(2) {
position: absolute;
top: -10%;
right: -20%;
}
}
}
#loading-bar-spinner.spinner { /*@media (max-width: 640px) {*/
left: 50%; /* .nav-link::after {*/
margin-left: -20px; /* bottom: -4px;*/
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%;
}
.rise-in { .rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both; 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 { @keyframes rise-in {
from { from {
opacity: 0; 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 { defineConfig } from 'vite'
import { devtools } from "@tanstack/devtools-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 viteReact from '@vitejs/plugin-react'
import { nitro } from "nitro/vite"; import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from "vite"; import { nitro } from 'nitro/vite'
import { HOST_URL } from "#/config.ts";
const config = defineConfig({ const config = defineConfig({
resolve: { tsconfigPaths: true }, resolve: { tsconfigPaths: true },
@@ -14,29 +13,9 @@ const config = defineConfig({
devtools(), devtools(),
nitro({ rollupConfig: { external: [/^@sentry\//] } }), nitro({ rollupConfig: { external: [/^@sentry\//] } }),
tailwindcss(), tailwindcss(),
tanstackStart({ tanstackStart(),
prerender: {
enabled: true,
crawlLinks: true,
},
pages: [
{
path: "/callback",
prerender: {
enabled: false,
},
sitemap: {
exclude: true,
},
},
],
sitemap: {
enabled: true,
host: HOST_URL,
},
}),
viteReact(), viteReact(),
], ],
}); })
export default config; export default config