Compare commits
108 Commits
72f68da3b0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
1245bf74c2
|
|||
|
908dd9b57b
|
|||
|
18f86125da
|
|||
|
f4ff9d8c68
|
|||
|
7994f79e02
|
|||
|
d11d360cbf
|
|||
|
573d063d55
|
|||
|
2dd006f14e
|
|||
|
072e24b227
|
|||
|
0795daba4b
|
|||
|
f139449d9a
|
|||
|
f9d02efe49
|
|||
| 2c657c7598 | |||
|
fbc704dc30
|
|||
|
c082a356fd
|
|||
|
6120c48377
|
|||
|
99f8aa8ac2
|
|||
|
2e05f86d5f
|
|||
|
4577755c93
|
|||
|
a110c7490c
|
|||
|
0b10637df4
|
|||
|
41cbd5db16
|
|||
|
af6a8426ad
|
|||
|
0dde848cfe
|
|||
|
ee9968e99b
|
|||
|
7384a3f825
|
|||
|
887a43636f
|
|||
|
734c631356
|
|||
|
802d46191e
|
|||
|
4edcb49f58
|
|||
|
030d44226d
|
|||
|
f12176e713
|
|||
|
3da81680a1
|
|||
|
0b32e77d7e
|
|||
|
f475732715
|
|||
|
f4b9820b5b
|
|||
|
98a5bb742b
|
|||
|
6ef08f4b91
|
|||
|
5bf4ec95c8
|
|||
|
8d19564a4e
|
|||
|
b94edbedb2
|
|||
|
cba86118a6
|
|||
|
3dcdc52052
|
|||
|
0903ac12c6
|
|||
|
80c2cce4de
|
|||
|
ed820b7fd1
|
|||
|
eb49b6248d
|
|||
|
0f61400272
|
|||
|
55989f09c0
|
|||
|
43d7eaa77f
|
|||
|
07068ab4fa
|
|||
|
63fffb11de
|
|||
|
fcb1433403
|
|||
|
7b2bf3161b
|
|||
|
0b1a0e6457
|
|||
|
3b29c42b3b
|
|||
|
430ec2db75
|
|||
|
d1cdc7764b
|
|||
|
ba05fbedd3
|
|||
|
82c24ba9d4
|
|||
|
0888df153f
|
|||
|
585bb5549f
|
|||
|
2511c636ab
|
|||
|
021841beec
|
|||
|
03e6c3fe31
|
|||
|
40541d3da2
|
|||
|
38fc389776
|
|||
|
af6809874c
|
|||
|
ad4d9ab12c
|
|||
|
70d65cfcfd
|
|||
|
9fe1b2043c
|
|||
|
48d0c4e47d
|
|||
|
e30bb5927a
|
|||
|
1d9ee25e1f
|
|||
|
459b79535b
|
|||
|
9415f3b6b8
|
|||
|
95973937f5
|
|||
|
9bb2cf331f
|
|||
|
9acd458898
|
|||
|
7209a88827
|
|||
|
9ecbce5a15
|
|||
|
6a9a51c1ed
|
|||
|
2d56700dd8
|
|||
|
4cd7b0199e
|
|||
|
5ed83a20d8
|
|||
|
00267b05ba
|
|||
|
7e205df26d
|
|||
|
551b07fc42
|
|||
|
a5bb7dafd9
|
|||
|
1c8f1674b7
|
|||
|
19c2951cc3
|
|||
|
1251921ea1
|
|||
|
7d8f390a50
|
|||
|
1d6a9f8e6e
|
|||
|
87fa6997ee
|
|||
|
f841b7aeee
|
|||
|
9fab5e10f2
|
|||
|
be47dc449e
|
|||
|
a0d2247ff8
|
|||
|
95bef61a29
|
|||
|
1a9d26a6ea
|
|||
|
50664f4759
|
|||
|
98fbc4d253
|
|||
|
b8bd48885e
|
|||
|
42ef8f20e0
|
|||
|
6b21f0bfac
|
|||
|
401ac760cf
|
|||
|
2805bd74df
|
@@ -0,0 +1,9 @@
|
||||
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=
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
@@ -11,3 +11,4 @@ dist-ssr
|
||||
.vinxi
|
||||
__unconfig*
|
||||
todos.json
|
||||
data
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,38 @@
|
||||
# --- 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"]
|
||||
@@ -0,0 +1,13 @@
|
||||
- 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
|
||||
@@ -13,7 +13,12 @@
|
||||
"**/index.html",
|
||||
"**/vite.config.ts",
|
||||
"!**/src/routeTree.gen.ts",
|
||||
"!**/src/styles.css"
|
||||
"!**/src/styles.css",
|
||||
"!**/.tanstack/*",
|
||||
"!**/.output/*",
|
||||
"!**/.nitro/*",
|
||||
"!**/node_modules/*",
|
||||
"!**/styles/vendor/*"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
@@ -33,4 +38,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
chess-scribe-frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
@@ -6,11 +6,14 @@
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
"dev": "vite dev --port 3000 --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"test": "vitest run",
|
||||
"lint:fix": "biome lint --write",
|
||||
"format": "biome format",
|
||||
"format:fix": "biome format --write",
|
||||
"lint": "biome lint",
|
||||
"check": "biome check"
|
||||
},
|
||||
@@ -30,6 +33,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -47,11 +51,5 @@
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"lightningcss"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ importers:
|
||||
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:
|
||||
specifier: ^4.1.18
|
||||
version: 4.3.0
|
||||
@@ -312,6 +315,9 @@ packages:
|
||||
'@emnapi/runtime@1.10.0':
|
||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||
|
||||
'@emnapi/runtime@1.11.1':
|
||||
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||
|
||||
@@ -1858,6 +1864,12 @@ packages:
|
||||
react-is@17.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1894,8 +1906,8 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.1:
|
||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2480,6 +2492,11 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/runtime@1.11.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@emnapi/wasi-threads@1.2.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -2574,7 +2591,7 @@ snapshots:
|
||||
|
||||
'@img/sharp-wasm32@0.34.5':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@emnapi/runtime': 1.11.1
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.5':
|
||||
@@ -3839,6 +3856,12 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
@@ -3897,7 +3920,7 @@ snapshots:
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.1:
|
||||
semver@7.8.5:
|
||||
optional: true
|
||||
|
||||
seroval-plugins@1.5.4(seroval@1.5.4):
|
||||
@@ -3910,7 +3933,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.1
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# pnpm v11 configuration file
|
||||
packages:
|
||||
- "."
|
||||
|
||||
# This replaces 'onlyBuiltDependencies'
|
||||
allowBuilds:
|
||||
"sharp": true
|
||||
"esbuild": true
|
||||
"lightningcss": true
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 859 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,44 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://chess-scribe.org/sitemap.xml
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"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"}
|
||||
@@ -9,10 +9,7 @@ interface IProps {
|
||||
const AccordionItem = ({ title, id, children }: IProps) => (
|
||||
<div className="hero__accordion-item">
|
||||
<details className="hero__accordion-details" name="linked">
|
||||
<summary
|
||||
className="hero__accordion-item__summary"
|
||||
aria-describedby={title}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -13,6 +13,7 @@ const CoffeeWidget = ({ height, width }: IProps) => {
|
||||
target="_blank"
|
||||
className="ml-auto flex items-center gap-2 text-sm"
|
||||
rel="noopener"
|
||||
aria-label="Buy Me A Coffee"
|
||||
>
|
||||
<Image
|
||||
src={buyMeACoffeeLogo}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
@@ -7,7 +7,7 @@ interface IProps {
|
||||
|
||||
const Feature = ({ title, children }: IProps) => (
|
||||
<div className="mb-auto">
|
||||
<h6 className="text-(--base-content) text-2xl font-bold">{title}</h6>
|
||||
<h5 className="text-(--neutral-content) text-2xl font-bold">{title}</h5>
|
||||
<p className="text-(--neutral-content) font-medium" data-testid="text">
|
||||
{children}
|
||||
</p>
|
||||
|
||||
@@ -4,40 +4,41 @@ import { Image } from "@unpic/react";
|
||||
import { LuGithub, LuMail } from "react-icons/lu";
|
||||
|
||||
import CoffeeWidget from "#/components/CoffeeWidget.tsx";
|
||||
import { GITHUB_URL } from "#/config.ts";
|
||||
import footerLogo from "@/assets/images/footerLogo.svg?url";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="text-white w-full grid-cols-2 items-center bg-(--accent) px-10 py-5">
|
||||
<div className="flex w-full items-center">
|
||||
<Link to="/">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Link to="/" aria-label="Home">
|
||||
<Image
|
||||
src={footerLogo}
|
||||
alt="ChessScribe Logo"
|
||||
width={10}
|
||||
height={10}
|
||||
className="hidden h-10 w-10 sm:block"
|
||||
aria-label="ChessScribe Logo"
|
||||
/>
|
||||
</Link>
|
||||
<div>
|
||||
<p data-testid="copyright">
|
||||
Copyright © 2023 - {new Date().getFullYear()}
|
||||
</p>
|
||||
<p>© 2023 - {new Date().getFullYear()}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Link
|
||||
to="/"
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out"
|
||||
aria-label="GitHub"
|
||||
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out cursor-pointer"
|
||||
>
|
||||
<LuGithub />
|
||||
</Link>
|
||||
</a>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
target="_blank"
|
||||
to="/contact"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Contact"
|
||||
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out"
|
||||
>
|
||||
<LuMail />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
@@ -0,0 +1,68 @@
|
||||
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,11 +1,26 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Image } from "@unpic/react";
|
||||
|
||||
import { z } from "zod";
|
||||
import MaintenanceModeBanner from "#/components/MaintenanceModeBanner.tsx";
|
||||
import { env } from "#/env.ts";
|
||||
import logo from "@/assets/images/logo.svg?url";
|
||||
|
||||
export default function Header() {
|
||||
const maintenanceModeMessage = env.VITE_MAINTENANCE_MODE
|
||||
? z
|
||||
.string()
|
||||
.transform((value) => (value.trim() === "" ? null : value))
|
||||
.parse(env.VITE_MAINTENANCE_MODE_MESSAGE)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<header>
|
||||
{maintenanceModeMessage ? (
|
||||
<div className="absolute w-full">
|
||||
<MaintenanceModeBanner message={maintenanceModeMessage} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<nav className="w-full max-w-screen-2xl place-self-center p-8">
|
||||
<div className="container mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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;
|
||||
@@ -1,11 +1,17 @@
|
||||
interface IProps {
|
||||
onClickHandler: () => void;
|
||||
}
|
||||
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();
|
||||
};
|
||||
|
||||
const LichessButton = ({ onClickHandler }: IProps) => {
|
||||
return (
|
||||
<button
|
||||
className="text-(--accent) border rounded-xl px-6 py-3 cursor-pointer flex items-center gap-2 p-2 hover:bg-(--accent) hover:text-(--header-bg) transition-colors duration-300 ease-in-out"
|
||||
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"
|
||||
>
|
||||
@@ -15,11 +21,12 @@ const LichessButton = ({ onClickHandler }: IProps) => {
|
||||
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>Log into Lichess.org</p>
|
||||
<p>{user ? `Logout ${user.username}` : "Log into Lichess.org"}</p>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
@@ -0,0 +1,13 @@
|
||||
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;
|
||||
@@ -1,9 +1,10 @@
|
||||
import { trackAppRouter } from "@socialgouv/matomo-next";
|
||||
import { useLocation, useSearch } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { env } from "#/env.ts";
|
||||
|
||||
const MATOMO_URL = import.meta.env.NEXT_PUBLIC_MATOMO_URL;
|
||||
const MATOMO_SITE_ID = import.meta.env.NEXT_PUBLIC_MATOMO_SITE_ID;
|
||||
const MATOMO_URL = env.VITE_MATOMO_URL;
|
||||
const MATOMO_SITE_ID = env.VITE_MATOMO_SITE_ID;
|
||||
|
||||
export function MatomoAnalytics() {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
@@ -14,13 +14,13 @@ const Section = ({ title, smallTitle, description, children }: IProps) => {
|
||||
|
||||
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="md:col-span-3 mb-4 text-center text-lg font-bold tracking-wider text-primary">
|
||||
<h3 className="col-span-full mb-4 text-center text-2xl font-bold tracking-wider text-primary">
|
||||
{smallTitle}
|
||||
</h3>
|
||||
<h4 className="md:col-span-3 mb-4 text-center text-4xl font-extrabold text-(--neutral-content) md:text-5xl">
|
||||
<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="md:col-span-3 mb-8 w-1/2 text-center font-semibold text-(--neutral-content) ">
|
||||
<p className="col-span-full mb-8 w-1/2 text-center font-semibold text-(--neutral-content) ">
|
||||
{description}
|
||||
</p>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
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;
|
||||
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export const HOST_URL = "https://chess-scribe.org";
|
||||
|
||||
export const GITHUB_URL =
|
||||
"https://github.com/therealowenrees/chess-scribe-website";
|
||||
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
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();
|
||||
@@ -0,0 +1,198 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
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]);
|
||||
};
|
||||
@@ -0,0 +1,206 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
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]);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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();
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import Chessboard from "#/pages/Chessboard.tsx";
|
||||
|
||||
const ChessboardContainer = () => {
|
||||
return <Chessboard />;
|
||||
};
|
||||
|
||||
export default ChessboardContainer;
|
||||
@@ -1,13 +1,160 @@
|
||||
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 Chessboard = () => (
|
||||
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" className="file-input max-w-xs" />
|
||||
<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;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
const Contact = () => <div>Hello "/contact"!</div>;
|
||||
|
||||
export default Contact;
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
@@ -0,0 +1,115 @@
|
||||
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;
|
||||
@@ -7,10 +7,10 @@ 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 place-self-center">
|
||||
<main className="px-4 pb-8 pt-14">
|
||||
<section
|
||||
id="hero"
|
||||
className="grid max-w-screen-2xl items-center gap-8 p-8 text-center md:grid-cols-2 md:text-left"
|
||||
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">
|
||||
@@ -23,10 +23,7 @@ const Home = () => (
|
||||
Upload and convert your games into a book format, along with variation
|
||||
and annotations.
|
||||
</h2>
|
||||
<Link
|
||||
to="/chessboard"
|
||||
className="rounded-3xl text-xl font-bold btn btn-primary"
|
||||
>
|
||||
<Link to="/chessboard" className="btn btn-primary btn-large">
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
@@ -37,9 +34,25 @@ const Home = () => (
|
||||
alt="Example PDF 1"
|
||||
width={500}
|
||||
height={500}
|
||||
priority={true}
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as ContactRouteImport } from './routes/contact'
|
||||
import { Route as ChessboardRouteImport } from './routes/chessboard'
|
||||
import { Route as CallbackRouteImport } from './routes/callback'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const ContactRoute = ContactRouteImport.update({
|
||||
@@ -23,6 +24,11 @@ const ChessboardRoute = ChessboardRouteImport.update({
|
||||
path: '/chessboard',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CallbackRoute = CallbackRouteImport.update({
|
||||
id: '/callback',
|
||||
path: '/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -31,30 +37,34 @@ const IndexRoute = IndexRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/callback': typeof CallbackRoute
|
||||
'/chessboard': typeof ChessboardRoute
|
||||
'/contact': typeof ContactRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/callback': typeof CallbackRoute
|
||||
'/chessboard': typeof ChessboardRoute
|
||||
'/contact': typeof ContactRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/callback': typeof CallbackRoute
|
||||
'/chessboard': typeof ChessboardRoute
|
||||
'/contact': typeof ContactRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/chessboard' | '/contact'
|
||||
fullPaths: '/' | '/callback' | '/chessboard' | '/contact'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/chessboard' | '/contact'
|
||||
id: '__root__' | '/' | '/chessboard' | '/contact'
|
||||
to: '/' | '/callback' | '/chessboard' | '/contact'
|
||||
id: '__root__' | '/' | '/callback' | '/chessboard' | '/contact'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
CallbackRoute: typeof CallbackRoute
|
||||
ChessboardRoute: typeof ChessboardRoute
|
||||
ContactRoute: typeof ContactRoute
|
||||
}
|
||||
@@ -75,6 +85,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ChessboardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/callback': {
|
||||
id: '/callback'
|
||||
path: '/callback'
|
||||
fullPath: '/callback'
|
||||
preLoaderRoute: typeof CallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -87,6 +104,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
CallbackRoute: CallbackRoute,
|
||||
ChessboardRoute: ChessboardRoute,
|
||||
ContactRoute: ContactRoute,
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
export function getRouter() {
|
||||
const router = createTanStackRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
});
|
||||
|
||||
return router
|
||||
return router;
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
router: ReturnType<typeof getRouter>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||
import { type ReactNode, Suspense } from "react";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import GenericErrorView from "#/components/GenericErrorView.tsx";
|
||||
import LoadingView from "#/components/LoadingView.tsx";
|
||||
import { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx";
|
||||
import NotFoundView from "#/components/NotFoundView.tsx";
|
||||
import { LichessUserProvider } from "#/context/LichessUserContext.tsx";
|
||||
import Footer from "../components/Footer";
|
||||
import Header from "../components/Header";
|
||||
import appCss from "../styles.css?url";
|
||||
@@ -20,15 +25,44 @@ export const Route = createRootRoute({
|
||||
{
|
||||
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: [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: appCss,
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/favicon.svg",
|
||||
type: "image/svg+xml",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/favicon.ico",
|
||||
sizes: "32x32",
|
||||
},
|
||||
{
|
||||
rel: "apple-touch-icon",
|
||||
href: "/apple-touch-icon.png",
|
||||
},
|
||||
{
|
||||
rel: "manifest",
|
||||
href: "/site.webmanifest",
|
||||
},
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
errorComponent: ({ error }) => <GenericErrorView error={error} />,
|
||||
pendingComponent: () => <LoadingView />,
|
||||
notFoundComponent: () => <NotFoundView />,
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: ReactNode }) {
|
||||
@@ -38,8 +72,10 @@ function RootDocument({ children }: { children: ReactNode }) {
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="font-sans antialiased">
|
||||
<LichessUserProvider>
|
||||
<Header />
|
||||
{children}
|
||||
<ToastContainer position="bottom-right" />
|
||||
<Footer />
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
@@ -52,6 +88,7 @@ function RootDocument({ children }: { children: ReactNode }) {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</LichessUserProvider>
|
||||
<Scripts />
|
||||
<Suspense fallback={null}>
|
||||
<MatomoAnalytics />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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 />;
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import ChessboardContainer from "#/pages/Chessboard.container.tsx";
|
||||
import { HOST_URL } from "#/config.ts";
|
||||
import Chessboard from "#/pages/Chessboard.tsx";
|
||||
|
||||
export const Route = createFileRoute("/chessboard")({
|
||||
head: () => ({
|
||||
meta: [{ title: "ChessScribe | Chessboard" }],
|
||||
links: [{ rel: "canonical", href: `${HOST_URL}/chessboard` }],
|
||||
}),
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <ChessboardContainer />;
|
||||
return <Chessboard />;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import Contact from "#/pages/Contact.tsx";
|
||||
import { HOST_URL } from "#/config.ts";
|
||||
import ContactContainer from "#/pages/Contact/Contact.container.tsx";
|
||||
|
||||
export const Route = createFileRoute("/contact")({
|
||||
head: () => ({
|
||||
meta: [{ title: "ChessScribe | Contact" }],
|
||||
links: [{ rel: "canonical", href: `${HOST_URL}/contact` }],
|
||||
}),
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <Contact />;
|
||||
return <ContactContainer />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { HOST_URL } from "#/config.ts";
|
||||
import Home from "#/pages";
|
||||
|
||||
export const Route = createFileRoute("/")({ component: App });
|
||||
export const Route = createFileRoute("/")({
|
||||
head: () => ({
|
||||
meta: [{ title: "ChessScribe | Home" }],
|
||||
links: [{ rel: "canonical", href: HOST_URL }],
|
||||
}),
|
||||
component: App,
|
||||
});
|
||||
|
||||
function App() {
|
||||
return <Home />;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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(),
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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 };
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
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 };
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
@import "./styles/_accordion.css";
|
||||
@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";
|
||||
|
||||
@@ -95,10 +99,38 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
#loading-bar-spinner.spinner {
|
||||
left: 50%;
|
||||
margin-left: -20px;
|
||||
top: 40%;
|
||||
margin-top: -20px;
|
||||
position: absolute;
|
||||
z-index: 19 !important;
|
||||
animation: loading-bar-spinner 400ms linear infinite;
|
||||
}
|
||||
|
||||
#loading-bar-spinner.spinner .spinner-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: solid 4px transparent;
|
||||
border-top-color: var(--accent) !important;
|
||||
border-left-color: var(--neutral-content) !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rise-in {
|
||||
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes loading-bar-spinner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -17,6 +17,19 @@
|
||||
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;
|
||||
@@ -40,7 +53,7 @@
|
||||
|
||||
&:after {
|
||||
content: "\276F";
|
||||
color: var(--base-content);
|
||||
color: var(--neutral-content);
|
||||
margin-right: 1rem;
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.3s ease;
|
||||
@@ -48,24 +61,11 @@
|
||||
}
|
||||
|
||||
.hero__accordion-item__title {
|
||||
color: var(--base-content);
|
||||
color: var(--neutral-content);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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-item__content__inner {
|
||||
color: var(--neutral-content);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* 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%;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import "./vendor/lichess-pgn-viewer.css";
|
||||
|
||||
.lpv-board {
|
||||
max-width: 600px;
|
||||
--board-color: #b58863;
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
:root {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: <override vendor style requires !important> */
|
||||
--toastify-color-success: var(--accent) !important;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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();
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
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}`;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
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();
|
||||
};
|
||||
@@ -1,11 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { devtools } from '@tanstack/devtools-vite'
|
||||
import tailwindcss from "@tailwindcss/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 tailwindcss from '@tailwindcss/vite'
|
||||
import { nitro } from 'nitro/vite'
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import { nitro } from "nitro/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import { HOST_URL } from "#/config.ts";
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: { tsconfigPaths: true },
|
||||
@@ -13,9 +14,29 @@ const config = defineConfig({
|
||||
devtools(),
|
||||
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
|
||||
tailwindcss(),
|
||||
tanstackStart(),
|
||||
tanstackStart({
|
||||
prerender: {
|
||||
enabled: true,
|
||||
crawlLinks: true,
|
||||
},
|
||||
pages: [
|
||||
{
|
||||
path: "/callback",
|
||||
prerender: {
|
||||
enabled: false,
|
||||
},
|
||||
sitemap: {
|
||||
exclude: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
sitemap: {
|
||||
enabled: true,
|
||||
host: HOST_URL,
|
||||
},
|
||||
}),
|
||||
viteReact(),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
export default config
|
||||
export default config;
|
||||
|
||||