Compare commits

..

107 Commits

Author SHA1 Message Date
owenrees 1245bf74c2 no string for maintenance mode 2026-07-13 21:04:24 +02:00
owenrees 908dd9b57b parse maintenance mode message 2026-07-13 21:00:26 +02:00
owenrees 18f86125da pass build args for Vite 2026-07-13 20:43:25 +02:00
owenrees f4ff9d8c68 pass build args for Vite 2026-07-13 20:40:18 +02:00
owenrees 7994f79e02 remove build envs, change zod items to url 2026-07-13 20:31:33 +02:00
owenrees d11d360cbf print mopre detailed error logs 2026-07-13 20:25:52 +02:00
owenrees 573d063d55 set maintenance mode false 2026-07-12 22:46:43 +02:00
owenrees 2dd006f14e update api base URL 2026-07-12 21:56:18 +02:00
owenrees 072e24b227 add VITE client vars to build 2026-07-12 13:51:21 +02:00
owenrees 0795daba4b add production workflow 2026-07-11 21:43:49 +02:00
owenrees f139449d9a update text for importing study via login or link 2026-07-11 21:00:22 +02:00
owenrees f9d02efe49 remove extraneous container_name from docker-compose.yml 2026-06-25 14:56:44 +02:00
owenrees 2c657c7598 Import study link (#1)
* add bug to todo

* disabled import study button if no study ID is selected, but always show if logged in as not to break the layout

* basic import of lichess study link

* change interface import

* Minmax M3 solution to import study chapter/game functionality

* change button text

* fix toast to display on study and chapter load

* add lint and format fix scripts

* apply auto formatting

* update ignored directories

* fix specificity

* add rudimentary PDF count metrics

* allow docker build

* create maintenance mode banner, triggered by env variables
2026-06-25 14:29:50 +02:00
owenrees fbc704dc30 center nav items on firefox as well 2026-06-13 22:17:19 +02:00
owenrees c082a356fd add multiple favicons 2026-06-13 21:30:25 +02:00
owenrees 6120c48377 noindex nofollow on callback route 2026-06-13 20:56:22 +02:00
owenrees 99f8aa8ac2 exclude callback route from sitemap 2026-06-13 20:51:06 +02:00
owenrees 2e05f86d5f update lighthouse todos 2026-06-12 16:57:10 +02:00
owenrees 4577755c93 add aria label to footer logo link 2026-06-12 16:52:40 +02:00
owenrees a110c7490c change h6 to h5 to fix heading structure 2026-06-12 16:49:55 +02:00
owenrees 0b10637df4 avoid prerendering callback page 2026-06-12 16:46:41 +02:00
owenrees 41cbd5db16 fix aria reference 2026-06-12 16:45:05 +02:00
owenrees af6a8426ad aria labels and font size changes 2026-06-12 15:17:29 +02:00
owenrees 0dde848cfe add aria labels to non-labelled icons 2026-06-12 15:04:53 +02:00
owenrees ee9968e99b pass matomo analytics env vars with zod 2026-06-12 14:48:52 +02:00
owenrees 7384a3f825 custom 404 page 2026-06-12 14:17:29 +02:00
owenrees 887a43636f export session interface to create a user interface for context 2026-06-12 11:27:12 +02:00
owenrees 734c631356 export interfaces 2026-06-12 11:22:54 +02:00
owenrees 802d46191e do not set isLoggedInBoolean since it is now removed 2026-06-12 11:22:41 +02:00
owenrees 4edcb49f58 fix button text based on user existing, rather that the removed isLogged in boolean 2026-06-12 11:21:50 +02:00
owenrees 030d44226d remove isLoggedIn booelan from lichess user, either a user exists or doesnt 2026-06-12 11:21:05 +02:00
owenrees f12176e713 remove isLoggedIn booelan from lichess user, either a user exists or doesnt 2026-06-12 11:20:55 +02:00
owenrees 3da81680a1 split schema for server functions 2026-06-12 11:04:43 +02:00
owenrees 0b32e77d7e add zod schema and validation to server 2026-06-12 10:58:59 +02:00
owenrees f475732715 remove loading page 2026-06-11 15:47:34 +02:00
owenrees f4b9820b5b add loading page 2026-06-11 15:06:13 +02:00
owenrees 98a5bb742b generic error view with try again 2026-06-11 12:23:19 +02:00
owenrees 6ef08f4b91 generic error view with try again 2026-06-11 11:12:31 +02:00
owenrees 5bf4ec95c8 if no code is provided return 404 2026-06-11 10:14:08 +02:00
owenrees 8d19564a4e remove header size todo 2026-06-10 12:31:35 +02:00
owenrees b94edbedb2 fix headers open sizes 2026-06-10 12:31:19 +02:00
owenrees cba86118a6 format 2026-06-10 12:23:14 +02:00
owenrees 3dcdc52052 add scroll down chevron indicator 2026-06-10 12:22:46 +02:00
owenrees 0903ac12c6 hero full screen 2026-06-10 12:20:24 +02:00
owenrees 80c2cce4de change response on access token delete function 2026-06-10 12:11:57 +02:00
owenrees ed820b7fd1 full width button and center text 2026-06-10 11:57:33 +02:00
owenrees eb49b6248d remove unused LichessStudyUrlInput and hide lichess buttons when no user or study loaded 2026-06-10 11:53:33 +02:00
owenrees 0f61400272 update todo with error handling 2026-06-10 11:45:00 +02:00
owenrees 55989f09c0 close dropdown on escape 2026-06-09 14:54:24 +02:00
owenrees 43d7eaa77f click outside of button/dropdowqn as hook 2026-06-09 14:53:05 +02:00
owenrees 07068ab4fa opencode suggestions for inputs 2026-06-09 14:37:55 +02:00
owenrees 63fffb11de update pgnviewer on lichess chapter load 2026-06-09 14:13:31 +02:00
owenrees fcb1433403 import chapter 2026-06-09 14:08:35 +02:00
owenrees 7b2bf3161b basic loading of user's studies 2026-06-09 10:31:59 +02:00
owenrees 0b1a0e6457 remove comments 2026-06-08 13:06:04 +02:00
owenrees 3b29c42b3b consolidate callback hooks 2026-06-08 12:50:09 +02:00
owenrees 430ec2db75 check for existing user on remount 2026-06-08 12:40:52 +02:00
owenrees d1cdc7764b basic login / logout lichess user 2026-06-08 12:23:20 +02:00
owenrees ba05fbedd3 add context 2026-06-07 23:07:59 +02:00
owenrees 82c24ba9d4 add placeholder function for token 2026-06-07 22:47:26 +02:00
owenrees 0888df153f check callback from lichess server 2026-06-07 22:44:31 +02:00
owenrees 585bb5549f change layout of clear button and other chessboard related bottom buttons 2026-06-04 22:47:53 +02:00
owenrees 2511c636ab override success colour of toastify to match accent colour of app 2026-06-04 22:35:46 +02:00
owenrees 021841beec discord server component and toast on state change 2026-06-04 22:30:29 +02:00
owenrees 03e6c3fe31 improve contact form collapse 2026-06-04 21:39:00 +02:00
owenrees 40541d3da2 improve fields for contact and add send icon 2026-06-04 21:29:44 +02:00
owenrees 38fc389776 rudimentary contact form boilerplate 2026-06-03 22:21:11 +02:00
owenrees af6809874c headers form fields now in flex box and with max width and gaps 2026-06-03 20:59:45 +02:00
owenrees ad4d9ab12c add favicon, update root layout with meta description etc 2026-06-03 20:39:21 +02:00
owenrees 70d65cfcfd add titles and description to pages 2026-06-03 20:31:46 +02:00
owenrees 9fe1b2043c update with sitemap 2026-06-03 20:19:58 +02:00
owenrees 48d0c4e47d prerender html and generate sitemap 2026-06-03 20:19:00 +02:00
owenrees e30bb5927a move functions outside of useEffect 2026-06-03 20:13:01 +02:00
owenrees 1d9ee25e1f move the pgn viewer related logic into a hook and type the ref 2026-06-03 19:48:40 +02:00
owenrees 459b79535b update github url to come from config rather than env vars 2026-06-03 13:42:25 +02:00
owenrees 9415f3b6b8 remove commented out code and old todos 2026-06-03 10:43:31 +02:00
owenrees 95973937f5 zod parse env vars 2026-06-03 10:35:12 +02:00
owenrees 9bb2cf331f add spinner on pgn viewer loading 2026-06-03 10:09:33 +02:00
owenrees 9acd458898 change feature title colour 2026-06-03 10:06:13 +02:00
owenrees 7209a88827 change accordion title and chevron colour 2026-06-03 10:05:12 +02:00
owenrees 9ecbce5a15 pgnviewer lazy load and suspense 2026-06-03 10:02:53 +02:00
owenrees 6a9a51c1ed remove try/catch in downloadString 2026-06-03 09:53:48 +02:00
owenrees 2d56700dd8 reduce spacing between header details sections 2026-06-02 22:47:54 +02:00
owenrees 4cd7b0199e update header fields on typing 2026-06-02 22:46:24 +02:00
owenrees 5ed83a20d8 populate header fields 2026-06-02 22:33:27 +02:00
owenrees 00267b05ba browser tab 2026-06-02 22:02:51 +02:00
owenrees 7e205df26d add headers fields 2026-06-02 22:01:41 +02:00
owenrees 551b07fc42 example env file 2026-06-02 21:34:39 +02:00
owenrees a5bb7dafd9 pdf generation toasts 2026-06-02 21:22:57 +02:00
owenrees 1c8f1674b7 basic large screen layout 2026-06-02 21:15:03 +02:00
owenrees 19c2951cc3 rudimentary buttons 2026-06-02 21:02:46 +02:00
owenrees 1251921ea1 handlePlyChange passed as prop to useEffect is being recreated every time state changes, so wrapper in useCallback 2026-06-02 20:26:47 +02:00
owenrees 7d8f390a50 move game state and head interfaces into shared file 2026-06-02 20:19:47 +02:00
owenrees 1d6a9f8e6e use shared IPosition interface for diagrams and current move state 2026-06-02 20:17:48 +02:00
owenrees 87fa6997ee update with button styling 2026-06-02 15:24:44 +02:00
owenrees f841b7aeee pass generatingPdf state to checkbox to disable, remove container compoentn for chessboard 2026-06-02 15:24:19 +02:00
owenrees 9fab5e10f2 add state to track pdf generation state 2026-06-02 15:09:43 +02:00
owenrees be47dc449e update with zod, headers, suspense, toasts, logger 2026-06-02 15:07:23 +02:00
owenrees a0d2247ff8 rudimentary pdf saving 2026-06-02 15:06:56 +02:00
owenrees 95bef61a29 update todo list 2026-06-02 14:55:48 +02:00
owenrees 1a9d26a6ea wire diagram toggle checkbox state 2026-06-02 14:52:23 +02:00
owenrees 50664f4759 trigger reducer pased on if the diagram is in the position or not 2026-06-02 14:48:33 +02:00
owenrees 98fbc4d253 pass current fen and ply to state 2026-06-02 14:44:18 +02:00
owenrees b8bd48885e pass ply change to callback 2026-06-02 14:24:17 +02:00
owenrees 42ef8f20e0 add board listeners 2026-06-02 13:49:34 +02:00
owenrees 6b21f0bfac style diagram select checkbox and label text 2026-06-02 13:16:22 +02:00
owenrees 401ac760cf style render move times toggle 2026-06-02 13:11:13 +02:00
80 changed files with 3291 additions and 317 deletions
+9
View File
@@ -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=
+59
View File
@@ -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
+1
View File
@@ -11,3 +11,4 @@ dist-ssr
.vinxi .vinxi
__unconfig* __unconfig*
todos.json todos.json
data
+33 -33
View File
@@ -1,35 +1,35 @@
{ {
"files.watcherExclude": { "files.watcherExclude": {
"**/routeTree.gen.ts": true "**/routeTree.gen.ts": true
}, },
"search.exclude": { "search.exclude": {
"**/routeTree.gen.ts": true "**/routeTree.gen.ts": true
}, },
"files.readonlyInclude": { "files.readonlyInclude": {
"**/routeTree.gen.ts": true "**/routeTree.gen.ts": true
}, },
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[javascriptreact]": { "[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[jsonc]": { "[jsonc]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[css]": { "[css]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit" "source.organizeImports.biome": "explicit"
} }
} }
+38
View File
@@ -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"]
+13
View File
@@ -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
+6 -2
View File
@@ -13,7 +13,12 @@
"**/index.html", "**/index.html",
"**/vite.config.ts", "**/vite.config.ts",
"!**/src/routeTree.gen.ts", "!**/src/routeTree.gen.ts",
"!**/src/styles.css" "!**/src/styles.css",
"!**/.tanstack/*",
"!**/.output/*",
"!**/.nitro/*",
"!**/node_modules/*",
"!**/styles/vendor/*"
] ]
}, },
"formatter": { "formatter": {
@@ -33,4 +38,3 @@
} }
} }
} }
+13
View File
@@ -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
+5 -7
View File
@@ -6,11 +6,14 @@
"#/*": "./src/*" "#/*": "./src/*"
}, },
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev --port 3000 --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "node .output/server/index.mjs",
"test": "vitest run", "test": "vitest run",
"lint:fix": "biome lint --write",
"format": "biome format", "format": "biome format",
"format:fix": "biome format --write",
"lint": "biome lint", "lint": "biome lint",
"check": "biome check" "check": "biome check"
}, },
@@ -30,6 +33,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-icons": "^5.6.0", "react-icons": "^5.6.0",
"react-toastify": "^11.1.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
@@ -47,11 +51,5 @@
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^8.0.0", "vite": "^8.0.0",
"vitest": "^4.1.5" "vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
} }
} }
+28 -5
View File
@@ -53,6 +53,9 @@ importers:
react-icons: react-icons:
specifier: ^5.6.0 specifier: ^5.6.0
version: 5.6.0(react@19.2.6) version: 5.6.0(react@19.2.6)
react-toastify:
specifier: ^11.1.0
version: 11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwindcss: tailwindcss:
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.3.0 version: 4.3.0
@@ -312,6 +315,9 @@ packages:
'@emnapi/runtime@1.10.0': '@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -1858,6 +1864,12 @@ packages:
react-is@17.0.2: react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-toastify@11.1.0:
resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==}
peerDependencies:
react: ^18 || ^19
react-dom: ^18 || ^19
react@19.2.6: react@19.2.6:
resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1894,8 +1906,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
semver@7.8.1: semver@7.8.5:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
@@ -2480,6 +2492,11 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -2574,7 +2591,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
dependencies: dependencies:
'@emnapi/runtime': 1.10.0 '@emnapi/runtime': 1.11.1
optional: true optional: true
'@img/sharp-win32-arm64@0.34.5': '@img/sharp-win32-arm64@0.34.5':
@@ -3839,6 +3856,12 @@ snapshots:
react-is@17.0.2: {} react-is@17.0.2: {}
react-toastify@11.1.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
clsx: 2.1.1
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react@19.2.6: {} react@19.2.6: {}
readdirp@5.0.0: {} readdirp@5.0.0: {}
@@ -3897,7 +3920,7 @@ snapshots:
semver@6.3.1: {} semver@6.3.1: {}
semver@7.8.1: semver@7.8.5:
optional: true optional: true
seroval-plugins@1.5.4(seroval@1.5.4): seroval-plugins@1.5.4(seroval@1.5.4):
@@ -3910,7 +3933,7 @@ snapshots:
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.8.1 semver: 7.8.5
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5
+9
View File
@@ -0,0 +1,9 @@
# pnpm v11 configuration file
packages:
- "."
# This replaces 'onlyBuiltDependencies'
allowBuilds:
"sharp": true
"esbuild": true
"lightningcss": true
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+44
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

-25
View File
@@ -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"
}
+3 -2
View File
@@ -1,3 +1,4 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: * User-agent: *
Disallow: Allow: /
Sitemap: https://chess-scribe.org/sitemap.xml
+1
View File
@@ -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"}
+1 -4
View File
@@ -9,10 +9,7 @@ interface IProps {
const AccordionItem = ({ title, id, children }: IProps) => ( const AccordionItem = ({ title, id, children }: IProps) => (
<div className="hero__accordion-item"> <div className="hero__accordion-item">
<details className="hero__accordion-details" name="linked"> <details className="hero__accordion-details" name="linked">
<summary <summary className="hero__accordion-item__summary" id={`${id}-summary`}>
className="hero__accordion-item__summary"
aria-describedby={title}
>
<span className="hero__accordion-item__title-container"> <span className="hero__accordion-item__title-container">
<span className="hero__accordion-item__title">{title}</span> <span className="hero__accordion-item__title">{title}</span>
</span> </span>
+1
View File
@@ -13,6 +13,7 @@ const CoffeeWidget = ({ height, width }: IProps) => {
target="_blank" target="_blank"
className="ml-auto flex items-center gap-2 text-sm" className="ml-auto flex items-center gap-2 text-sm"
rel="noopener" rel="noopener"
aria-label="Buy Me A Coffee"
> >
<Image <Image
src={buyMeACoffeeLogo} src={buyMeACoffeeLogo}
+54
View File
@@ -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;
+1 -1
View File
@@ -7,7 +7,7 @@ interface IProps {
const Feature = ({ title, children }: IProps) => ( const Feature = ({ title, children }: IProps) => (
<div className="mb-auto"> <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"> <p className="text-(--neutral-content) font-medium" data-testid="text">
{children} {children}
</p> </p>
+8 -6
View File
@@ -4,31 +4,32 @@ import { Image } from "@unpic/react";
import { LuGithub, LuMail } from "react-icons/lu"; import { LuGithub, LuMail } from "react-icons/lu";
import CoffeeWidget from "#/components/CoffeeWidget.tsx"; import CoffeeWidget from "#/components/CoffeeWidget.tsx";
import { GITHUB_URL } from "#/config.ts";
import footerLogo from "@/assets/images/footerLogo.svg?url"; import footerLogo from "@/assets/images/footerLogo.svg?url";
export default function Footer() { export default function Footer() {
return ( return (
<footer className="text-white w-full grid-cols-2 items-center bg-(--accent) px-10 py-5"> <footer className="text-white w-full grid-cols-2 items-center bg-(--accent) px-10 py-5">
<div className="flex w-full items-center"> <div className="flex w-full items-center gap-2">
<Link to="/"> <Link to="/" aria-label="Home">
<Image <Image
src={footerLogo} src={footerLogo}
alt="ChessScribe Logo" alt="ChessScribe Logo"
width={10} width={10}
height={10} height={10}
className="hidden h-10 w-10 sm:block" className="hidden h-10 w-10 sm:block"
aria-label="ChessScribe Logo"
/> />
</Link> </Link>
<div> <div>
<p data-testid="copyright"> <p>© 2023 - {new Date().getFullYear()}</p>
Copyright © 2023 - {new Date().getFullYear()}
</p>
</div> </div>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<a <a
href={import.meta.env.VITE_GITHUB_URL} // TODO ZOD href={GITHUB_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="GitHub"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out cursor-pointer" className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out cursor-pointer"
> >
<LuGithub /> <LuGithub />
@@ -37,6 +38,7 @@ export default function Footer() {
<Link <Link
to="/contact" to="/contact"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="Contact"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out" className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out"
> >
<LuMail /> <LuMail />
+29
View File
@@ -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;
+68
View File
@@ -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;
+16 -1
View File
@@ -1,11 +1,26 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react"; import { Image } from "@unpic/react";
import { z } from "zod";
import MaintenanceModeBanner from "#/components/MaintenanceModeBanner.tsx";
import { env } from "#/env.ts";
import logo from "@/assets/images/logo.svg?url"; import logo from "@/assets/images/logo.svg?url";
export default function Header() { export default function Header() {
const maintenanceModeMessage = env.VITE_MAINTENANCE_MODE
? z
.string()
.transform((value) => (value.trim() === "" ? null : value))
.parse(env.VITE_MAINTENANCE_MODE_MESSAGE)
: null;
return ( return (
<header> <header>
{maintenanceModeMessage ? (
<div className="absolute w-full">
<MaintenanceModeBanner message={maintenanceModeMessage} />
</div>
) : null}
<nav className="w-full max-w-screen-2xl place-self-center p-8"> <nav className="w-full max-w-screen-2xl place-self-center p-8">
<div className="container mx-auto flex items-center justify-between"> <div className="container mx-auto flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
+105
View File
@@ -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;
+12 -6
View File
@@ -1,11 +1,17 @@
interface IProps { import { useLichessUser } from "#/context/LichessUserContext.tsx";
onClickHandler: () => void; 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 ( return (
<button <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} onClick={onClickHandler}
type="button" type="button"
> >
@@ -20,7 +26,7 @@ const LichessButton = ({ onClickHandler }: IProps) => {
</svg> </svg>
</div> </div>
<p>Log into Lichess.org</p> <p>{user ? `Logout ${user.username}` : "Log into Lichess.org"}</p>
</button> </button>
); );
}; };
+61
View File
@@ -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;
+34
View File
@@ -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;
+13
View File
@@ -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;
+3 -2
View File
@@ -1,9 +1,10 @@
import { trackAppRouter } from "@socialgouv/matomo-next"; import { trackAppRouter } from "@socialgouv/matomo-next";
import { useLocation, useSearch } from "@tanstack/react-router"; import { useLocation, useSearch } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { env } from "#/env.ts";
const MATOMO_URL = import.meta.env.NEXT_PUBLIC_MATOMO_URL; const MATOMO_URL = env.VITE_MATOMO_URL;
const MATOMO_SITE_ID = import.meta.env.NEXT_PUBLIC_MATOMO_SITE_ID; const MATOMO_SITE_ID = env.VITE_MATOMO_SITE_ID;
export function MatomoAnalytics() { export function MatomoAnalytics() {
const location = useLocation(); const location = useLocation();
+21
View File
@@ -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;
+6 -23
View File
@@ -1,32 +1,15 @@
import LichessPgnViewer from "lichess-pgn-viewer"; import { useLichessPgnViewer } from "#/hooks/useLichessPgnViewer.ts";
import type PgnViewerType from "lichess-pgn-viewer/pgnViewer"; import type { IPosition } from "#/interfaces.ts";
import { useEffect, useRef } from "react";
interface IProps { interface IProps {
gamePgn: string; gamePgn: string;
handlePlyChange: ({ ply, fen }: IPosition) => void;
} }
const PgnViewer = ({ gamePgn }: IProps) => { const PgnViewer = ({ gamePgn, handlePlyChange }: IProps) => {
const containerRef = useRef<HTMLDivElement | null>(null); useLichessPgnViewer({ gamePgn, handlePlyChange });
const viewerRef = useRef<PgnViewerType | null>(null);
useEffect(() => { return <div className="lpv-board" />;
const element: HTMLElement | null = document.querySelector(".lpv-board");
if (!element) return;
viewerRef.current = LichessPgnViewer(element, {
pgn: gamePgn,
scrollToMove: false,
});
return () => {
if (containerRef.current) {
containerRef.current = null;
}
};
}, [gamePgn]);
return <div ref={containerRef} className="lpv-board" />;
}; };
export default PgnViewer; export default PgnViewer;
+3 -3
View File
@@ -14,13 +14,13 @@ const Section = ({ title, smallTitle, description, children }: IProps) => {
return ( 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)"> <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} {smallTitle}
</h3> </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> {mainTitle} <span className="text-(--accent)">{lastWord}</span>
</h4> </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} {description}
</p> </p>
{children} {children}
+107
View File
@@ -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;
+167
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
export const HOST_URL = "https://chess-scribe.org";
export const GITHUB_URL =
"https://github.com/therealowenrees/chess-scribe-website";
+55
View File
@@ -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;
};
+45
View File
@@ -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();
+119 -5
View File
@@ -1,19 +1,101 @@
import { type ChangeEvent, useReducer, useRef } from "react"; 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 { 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 { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
import { downloadString } from "#/utils/stringUtils.ts"; import { downloadString } from "#/utils/stringUtils.ts";
export const useChessGame = () => { export const useChessGame = () => {
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState); 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 fileInputRef = useRef<HTMLInputElement | null>(null);
const handlePlyChange = useCallback(({ ply, fen }: IPosition) => {
setCurrentPosition({ ply, fen });
}, []);
const handleSavePgn = () => { const handleSavePgn = () => {
const pgnString = buildPgnString(gameState); try {
downloadString(pgnString, "game.pgn"); 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 = () => { const handleSavePdf = async () => {
console.log("Save PDF"); 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 = () => { const handleClearGame = () => {
@@ -50,6 +132,7 @@ export const useChessGame = () => {
...getHeaders(pgnString), ...getHeaders(pgnString),
title: "", title: "",
subtitle: "", subtitle: "",
author: "",
}; };
gameDispatch({ gameDispatch({
type: "SET_GAME", type: "SET_GAME",
@@ -72,6 +155,31 @@ export const useChessGame = () => {
}); });
}; };
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 { return {
gameState, gameState,
fileInputRef, fileInputRef,
@@ -80,5 +188,11 @@ export const useChessGame = () => {
handleClearGame, handleClearGame,
handleLoadPgn, handleLoadPgn,
handleToggleClock, handleToggleClock,
handlePlyChange,
handleToggleDiagram,
currentPosition,
generatingPdf,
updateHeaders,
gameDispatch,
}; };
}; };
+42
View File
@@ -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]);
};
+206
View File
@@ -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,
};
};
+80
View File
@@ -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;
};
+21
View File
@@ -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]);
};
+50
View File
@@ -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;
}
+12
View File
@@ -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();
+23
View File
@@ -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;
-20
View File
@@ -1,20 +0,0 @@
import { useChessGame } from "#/hooks/useChessGame.ts";
import Chessboard from "#/pages/Chessboard.tsx";
const ChessboardContainer = () => {
const chessGameProps = useChessGame();
return (
<Chessboard
gameState={chessGameProps.gameState}
handleLoadPgn={chessGameProps.handleLoadPgn}
handleClearGame={chessGameProps.handleClearGame}
handleSavePgn={chessGameProps.handleSavePgn}
handleSavePdf={chessGameProps.handleSavePdf}
handleToggleClock={chessGameProps.handleToggleClock}
fileInputRef={chessGameProps.fileInputRef}
/>
);
};
export default ChessboardContainer;
+135 -55
View File
@@ -1,28 +1,48 @@
import type { ChangeEvent, RefObject } from "react"; import { lazy, Suspense } from "react";
import CustomHeaders from "#/components/CustomHeaders.tsx";
import HeaderFields from "#/components/HeaderFields.tsx";
import LichessButton from "#/components/LichessButton.tsx"; import LichessButton from "#/components/LichessButton.tsx";
import PgnViewer from "#/components/PgnViewer.tsx"; import LichessStudyLinkInput from "#/components/LichessStudyLinkInput.tsx";
import Section from "#/components/Section.tsx"; import Section from "#/components/Section.tsx";
import type { IGameState } from "#/reducers/gameReducer.ts"; 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";
interface IProps { const PgnViewer = lazy(() => import("#/components/PgnViewer.tsx"));
gameState: IGameState;
handleLoadPgn: (e: ChangeEvent<HTMLInputElement>) => void; const LoadingBoard = () => {
handleClearGame: () => void; return (
handleSavePgn: () => void; <div className="relative grid h-125 w-160 items-center text-center">
handleSavePdf: () => void; <div id="loading-bar-spinner" className="spinner">
handleToggleClock: () => void; <div className="spinner-icon"></div>
fileInputRef: RefObject<HTMLInputElement | null>; </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();
const Chessboard = ({
gameState,
handleLoadPgn,
handleClearGame,
handleSavePgn,
handleSavePdf,
handleToggleClock,
fileInputRef,
}: IProps) => {
return ( return (
<main className="px-4 pb-8 place-self-center min-h-[calc(100vh-226px)]"> <main className="px-4 pb-8 place-self-center min-h-[calc(100vh-226px)]">
<Section title="Convert PGN to PDF"> <Section title="Convert PGN to PDF">
@@ -34,45 +54,105 @@ const Chessboard = ({
className="file-input max-w-xs" className="file-input max-w-xs"
onChange={handleLoadPgn} 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> </Section>
<div className="flex gap-4"> <div className="grid lg:grid-cols-2 gap-4">
<button <Suspense fallback={<LoadingBoard />}>
className="cursor-pointer" <div className="mx-auto">
type="button" <PgnViewer
onClick={handleClearGame} gamePgn={gameState.pgn || ""}
> handlePlyChange={handlePlyChange}
Clear Game />
</button> <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>
<button <label className="toggle text-sm text-(--base-content) col-span-4">
className="cursor-pointer" Render move times
type="button" <input
onClick={handleSavePgn} id="render-times"
disabled={!gameState.pgn} name="render-times"
> type="checkbox"
Save PGN disabled={!gameState.pgn}
</button> checked={gameState.diagramClock}
onChange={handleToggleClock}
/>
<span className="slider round" />
</label>
</div>
</div>
</Suspense>
<button <div className="flex flex-col gap-4 items-center">
className="cursor-pointer" <div className="flex justify-center gap-4">
type="button" <button
onClick={handleSavePdf} className="btn btn-secondary"
disabled={!gameState.pgn} type="button"
> onClick={handleSavePgn}
Save PDF disabled={!gameState.pgn}
</button> >
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> </div>
<PgnViewer gamePgn={gameState.pgn || ""} />
<label htmlFor="diagram-clock">
Render Clock Times
<input
id="diagram-clock"
name="diagram-clock"
type="checkbox"
onChange={handleToggleClock}
/>
</label>
</main> </main>
); );
}; };
-3
View File
@@ -1,3 +0,0 @@
const Contact = () => <div>Hello "/contact"!</div>;
export default Contact;
+34
View File
@@ -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;
+115
View File
@@ -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;
+18 -5
View File
@@ -10,7 +10,7 @@ const Home = () => (
<main className="px-4 pb-8 pt-14"> <main className="px-4 pb-8 pt-14">
<section <section
id="hero" id="hero"
className="grid max-w-screen-2xl items-center gap-8 p-8 text-center md:grid-cols-2 md:text-left mx-auto" 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> <div>
<h1 className="text-3xl font-bold text-base-content md:text-4xl"> <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 Upload and convert your games into a book format, along with variation
and annotations. and annotations.
</h2> </h2>
<Link <Link to="/chessboard" className="btn btn-primary btn-large">
to="/chessboard"
className="rounded-3xl text-xl font-bold btn btn-primary"
>
Get Started Get Started
</Link> </Link>
</div> </div>
@@ -40,6 +37,22 @@ const Home = () => (
priority priority
/> />
</div> </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>
<Section <Section
title="A Unique Chess Publication Service" title="A Unique Chess Publication Service"
+4 -31
View File
@@ -1,37 +1,10 @@
interface IDiagrams { import type { IGameState, IHeader, IPosition } from "#/interfaces.ts";
ply: number;
fen: string;
}
export interface IHeader { export type GameAction =
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;
[key: string]: string;
}
export interface IGameState {
pgn: string;
headers: IHeader;
diagrams: IDiagrams[];
diagramClock: boolean;
}
type GameAction =
| { type: "SET_GAME"; payload: { pgn: string; headers: IHeader } } | { type: "SET_GAME"; payload: { pgn: string; headers: IHeader } }
| { type: "CLEAR_GAME" } | { type: "CLEAR_GAME" }
| { type: "SET_HEADERS"; payload: IHeader } | { type: "SET_HEADERS"; payload: IHeader }
| { type: "ADD_DIAGRAM"; payload: IDiagrams } | { type: "ADD_DIAGRAM"; payload: IPosition }
| { type: "DELETE_DIAGRAM"; payload: { ply: number } } | { type: "DELETE_DIAGRAM"; payload: { ply: number } }
| { type: "TOGGLE_DIAGRAM_CLOCK" }; | { type: "TOGGLE_DIAGRAM_CLOCK" };
@@ -86,7 +59,7 @@ export const gameReducer = (state: IGameState, action: GameAction) => {
return { return {
...state, ...state,
diagrams: state.diagrams.filter( diagrams: state.diagrams.filter(
(diagram: IDiagrams) => diagram.ply !== action.payload.ply, (diagram: IPosition) => diagram.ply !== action.payload.ply,
), ),
}; };
case "TOGGLE_DIAGRAM_CLOCK": case "TOGGLE_DIAGRAM_CLOCK":
+21 -3
View File
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as ContactRouteImport } from './routes/contact' import { Route as ContactRouteImport } from './routes/contact'
import { Route as ChessboardRouteImport } from './routes/chessboard' import { Route as ChessboardRouteImport } from './routes/chessboard'
import { Route as CallbackRouteImport } from './routes/callback'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
const ContactRoute = ContactRouteImport.update({ const ContactRoute = ContactRouteImport.update({
@@ -23,6 +24,11 @@ const ChessboardRoute = ChessboardRouteImport.update({
path: '/chessboard', path: '/chessboard',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const CallbackRoute = CallbackRouteImport.update({
id: '/callback',
path: '/callback',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -31,30 +37,34 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute '/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute '/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/callback': typeof CallbackRoute
'/chessboard': typeof ChessboardRoute '/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute '/contact': typeof ContactRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/chessboard' | '/contact' fullPaths: '/' | '/callback' | '/chessboard' | '/contact'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/chessboard' | '/contact' to: '/' | '/callback' | '/chessboard' | '/contact'
id: '__root__' | '/' | '/chessboard' | '/contact' id: '__root__' | '/' | '/callback' | '/chessboard' | '/contact'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
CallbackRoute: typeof CallbackRoute
ChessboardRoute: typeof ChessboardRoute ChessboardRoute: typeof ChessboardRoute
ContactRoute: typeof ContactRoute ContactRoute: typeof ContactRoute
} }
@@ -75,6 +85,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChessboardRouteImport preLoaderRoute: typeof ChessboardRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/callback': {
id: '/callback'
path: '/callback'
fullPath: '/callback'
preLoaderRoute: typeof CallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/': { '/': {
id: '/' id: '/'
path: '/' path: '/'
@@ -87,6 +104,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
CallbackRoute: CallbackRoute,
ChessboardRoute: ChessboardRoute, ChessboardRoute: ChessboardRoute,
ContactRoute: ContactRoute, ContactRoute: ContactRoute,
} }
+13 -13
View File
@@ -1,19 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from './routeTree.gen' import { routeTree } from "./routeTree.gen";
export function getRouter() { export function getRouter() {
const router = createTanStackRouter({ const router = createTanStackRouter({
routeTree, routeTree,
scrollRestoration: true, scrollRestoration: true,
defaultPreload: 'intent', defaultPreload: "intent",
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
}) });
return router return router;
} }
declare module '@tanstack/react-router' { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: ReturnType<typeof getRouter> router: ReturnType<typeof getRouter>;
} }
} }
+51 -14
View File
@@ -2,7 +2,12 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { type ReactNode, Suspense } from "react"; import { type ReactNode, Suspense } from "react";
import { ToastContainer } from "react-toastify";
import GenericErrorView from "#/components/GenericErrorView.tsx";
import LoadingView from "#/components/LoadingView.tsx";
import { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx"; import { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx";
import NotFoundView from "#/components/NotFoundView.tsx";
import { LichessUserProvider } from "#/context/LichessUserContext.tsx";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Header from "../components/Header"; import Header from "../components/Header";
import appCss from "../styles.css?url"; import appCss from "../styles.css?url";
@@ -20,15 +25,44 @@ export const Route = createRootRoute({
{ {
title: "ChessScribe", title: "ChessScribe",
}, },
{
name: "description",
content: "Create PDFs of your chess games from a PGN file",
},
{
name: "keywords",
content: "chess, pgn, pdf, chess games, chess notation",
},
], ],
links: [ links: [
{ {
rel: "stylesheet", rel: "stylesheet",
href: appCss, href: appCss,
}, },
{
rel: "icon",
href: "/favicon.svg",
type: "image/svg+xml",
},
{
rel: "icon",
href: "/favicon.ico",
sizes: "32x32",
},
{
rel: "apple-touch-icon",
href: "/apple-touch-icon.png",
},
{
rel: "manifest",
href: "/site.webmanifest",
},
], ],
}), }),
shellComponent: RootDocument, shellComponent: RootDocument,
errorComponent: ({ error }) => <GenericErrorView error={error} />,
pendingComponent: () => <LoadingView />,
notFoundComponent: () => <NotFoundView />,
}); });
function RootDocument({ children }: { children: ReactNode }) { function RootDocument({ children }: { children: ReactNode }) {
@@ -38,20 +72,23 @@ function RootDocument({ children }: { children: ReactNode }) {
<HeadContent /> <HeadContent />
</head> </head>
<body className="font-sans antialiased"> <body className="font-sans antialiased">
<Header /> <LichessUserProvider>
{children} <Header />
<Footer /> {children}
<TanStackDevtools <ToastContainer position="bottom-right" />
config={{ <Footer />
position: "bottom-right", <TanStackDevtools
}} config={{
plugins={[ position: "bottom-right",
{ }}
name: "Tanstack Router", plugins={[
render: <TanStackRouterDevtoolsPanel />, {
}, name: "Tanstack Router",
]} render: <TanStackRouterDevtoolsPanel />,
/> },
]}
/>
</LichessUserProvider>
<Scripts /> <Scripts />
<Suspense fallback={null}> <Suspense fallback={null}>
<MatomoAnalytics /> <MatomoAnalytics />
+29
View File
@@ -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 />;
}
+7 -2
View File
@@ -1,10 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"; 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")({ export const Route = createFileRoute("/chessboard")({
head: () => ({
meta: [{ title: "ChessScribe | Chessboard" }],
links: [{ rel: "canonical", href: `${HOST_URL}/chessboard` }],
}),
component: RouteComponent, component: RouteComponent,
}); });
function RouteComponent() { function RouteComponent() {
return <ChessboardContainer />; return <Chessboard />;
} }
+7 -2
View File
@@ -1,10 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"; 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")({ export const Route = createFileRoute("/contact")({
head: () => ({
meta: [{ title: "ChessScribe | Contact" }],
links: [{ rel: "canonical", href: `${HOST_URL}/contact` }],
}),
component: RouteComponent, component: RouteComponent,
}); });
function RouteComponent() { function RouteComponent() {
return <Contact />; return <ContactContainer />;
} }
+8 -1
View File
@@ -1,7 +1,14 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { HOST_URL } from "#/config.ts";
import Home from "#/pages"; 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() { function App() {
return <Home />; return <Home />;
+19
View File
@@ -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(),
});
+38
View File
@@ -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 };
});
+213
View File
@@ -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 };
});
});
+41
View File
@@ -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);
}
},
);
+31
View File
@@ -1,7 +1,10 @@
@import "tailwindcss"; @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/_file-input.css";
@import './styles/_pgn-viewer.css'; @import './styles/_pgn-viewer.css';
@import "./styles/_toastify-overrides.css";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@@ -96,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 { .rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both; animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
} }
@keyframes loading-bar-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes rise-in { @keyframes rise-in {
from { from {
opacity: 0; opacity: 0;
+15 -15
View File
@@ -17,6 +17,19 @@
border-bottom: 1px solid var(--neutral-content); 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 { .hero__accordion-details[open] + .hero__accordion-item__content {
grid-template-rows: 1fr; grid-template-rows: 1fr;
padding: 10px; padding: 10px;
@@ -40,7 +53,7 @@
&:after { &:after {
content: "\276F"; content: "\276F";
color: var(--base-content); color: var(--neutral-content);
margin-right: 1rem; margin-right: 1rem;
transform: rotate(90deg); transform: rotate(90deg);
transition: transform 0.3s ease; transition: transform 0.3s ease;
@@ -48,24 +61,11 @@
} }
.hero__accordion-item__title { .hero__accordion-item__title {
color: var(--base-content); color: var(--neutral-content);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; 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 { .hero__accordion-item__content__inner {
color: var(--neutral-content); color: var(--neutral-content);
overflow: hidden; overflow: hidden;
+46
View File
@@ -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);
}
}
+81
View File
@@ -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%;
}
+4
View File
@@ -0,0 +1,4 @@
:root {
/* biome-ignore lint/complexity/noImportantStyles: <override vendor style requires !important> */
--toastify-color-success: var(--accent) !important;
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
import type { IGameState } from "#/reducers/gameReducer.ts"; import type { IGameState } from "#/interfaces.ts";
export const getHeaders = (pgn: string) => { export const getHeaders = (pgn: string) => {
const pgnHeader = pgn.split(/\n\n/g)[0]; const pgnHeader = pgn.split(/\n\n/g)[0];
+6 -10
View File
@@ -1,13 +1,9 @@
export const downloadString = (string: string, filename: string) => { export const downloadString = (string: string, filename: string) => {
try { const element = document.createElement("a");
const element = document.createElement("a"); const file = new Blob([string], { type: "text/plain" });
const file = new Blob([string], { type: "text/plain" });
element.href = URL.createObjectURL(file); element.href = URL.createObjectURL(file);
element.download = filename; element.download = filename;
document.body.appendChild(element); document.body.appendChild(element);
element.click(); element.click();
} catch (error) {
throw new Error("Failed to download file");
}
}; };
+37 -16
View File
@@ -1,21 +1,42 @@
import { defineConfig } from 'vite' import tailwindcss from "@tailwindcss/vite";
import { devtools } from '@tanstack/devtools-vite' import { devtools } from "@tanstack/devtools-vite";
import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from '@vitejs/plugin-react' import viteReact from "@vitejs/plugin-react";
import tailwindcss from '@tailwindcss/vite' import { nitro } from "nitro/vite";
import { nitro } from 'nitro/vite' import { defineConfig } from "vite";
import { HOST_URL } from "#/config.ts";
const config = defineConfig({ const config = defineConfig({
resolve: { tsconfigPaths: true }, resolve: { tsconfigPaths: true },
plugins: [ plugins: [
devtools(), devtools(),
nitro({ rollupConfig: { external: [/^@sentry\//] } }), nitro({ rollupConfig: { external: [/^@sentry\//] } }),
tailwindcss(), tailwindcss(),
tanstackStart(), tanstackStart({
viteReact(), 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;