mirror of
https://github.com/TheRealOwenRees/chess-scribe-website.git
synced 2026-07-23 09:56:57 +00:00
Compare commits
66 Commits
38fc389776
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
1245bf74c2
|
|||
|
908dd9b57b
|
|||
|
18f86125da
|
|||
|
f4ff9d8c68
|
|||
|
7994f79e02
|
|||
|
d11d360cbf
|
|||
|
573d063d55
|
|||
|
2dd006f14e
|
|||
|
072e24b227
|
|||
|
0795daba4b
|
|||
|
f139449d9a
|
|||
|
f9d02efe49
|
|||
| 2c657c7598 | |||
|
fbc704dc30
|
|||
|
c082a356fd
|
|||
|
6120c48377
|
|||
|
99f8aa8ac2
|
|||
|
2e05f86d5f
|
|||
|
4577755c93
|
|||
|
a110c7490c
|
|||
|
0b10637df4
|
|||
|
41cbd5db16
|
|||
|
af6a8426ad
|
|||
|
0dde848cfe
|
|||
|
ee9968e99b
|
|||
|
7384a3f825
|
|||
|
887a43636f
|
|||
|
734c631356
|
|||
|
802d46191e
|
|||
|
4edcb49f58
|
|||
|
030d44226d
|
|||
|
f12176e713
|
|||
|
3da81680a1
|
|||
|
0b32e77d7e
|
|||
|
f475732715
|
|||
|
f4b9820b5b
|
|||
|
98a5bb742b
|
|||
|
6ef08f4b91
|
|||
|
5bf4ec95c8
|
|||
|
8d19564a4e
|
|||
|
b94edbedb2
|
|||
|
cba86118a6
|
|||
|
3dcdc52052
|
|||
|
0903ac12c6
|
|||
|
80c2cce4de
|
|||
|
ed820b7fd1
|
|||
|
eb49b6248d
|
|||
|
0f61400272
|
|||
|
55989f09c0
|
|||
|
43d7eaa77f
|
|||
|
07068ab4fa
|
|||
|
63fffb11de
|
|||
|
fcb1433403
|
|||
|
7b2bf3161b
|
|||
|
0b1a0e6457
|
|||
|
3b29c42b3b
|
|||
|
430ec2db75
|
|||
|
d1cdc7764b
|
|||
|
ba05fbedd3
|
|||
|
82c24ba9d4
|
|||
|
0888df153f
|
|||
|
585bb5549f
|
|||
|
2511c636ab
|
|||
|
021841beec
|
|||
|
03e6c3fe31
|
|||
|
40541d3da2
|
+7
-2
@@ -1,4 +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_CONTACT_WEBHOOK=
|
||||||
DISCORD_ERROR_LOG_WEBHOOK=
|
DISCORD_ERROR_LOG_WEBHOOK=
|
||||||
LICHESS_CLIENT_ID=
|
LICHESS_CLIENT_ID=
|
||||||
VITE_API_BASE_URL=
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Production Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
GH_USER: therealowenrees
|
||||||
|
IMAGE_NAME: therealowenrees/chess-scribe:latest
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: publish image
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Login to Github Container Registry
|
||||||
|
run: echo "${{ secrets.GHCR_SECRET }}" | docker login ghcr.io -u ${{ env.GH_USER }} --password-stdin
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build image and publish with Cache
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
build-args: |
|
||||||
|
VITE_API_BASE_URL=https://api.chess-scribe.org/api/v1
|
||||||
|
VITE_MATOMO_URL=https://analytics.owenrees.eu
|
||||||
|
VITE_MATOMO_SITE_ID="3"
|
||||||
|
VITE_MAINTENANCE_MODE=
|
||||||
|
VITE_MAINTENANCE_MODE_MESSAGE="ChessScribe is currently undergoing maintenance. Some features may be unavailable."
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: publish
|
||||||
|
name: deploy image
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Connect and pull image from GHCR
|
||||||
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.PORT }}
|
||||||
|
script: |
|
||||||
|
cd /home/${{ secrets.USERNAME }}
|
||||||
|
echo "${{ secrets.GHCR_SECRET }}" | docker login ghcr.io -u ${{ env.GH_USER }} --password-stdin
|
||||||
|
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
docker compose up -d
|
||||||
@@ -11,3 +11,4 @@ dist-ssr
|
|||||||
.vinxi
|
.vinxi
|
||||||
__unconfig*
|
__unconfig*
|
||||||
todos.json
|
todos.json
|
||||||
|
data
|
||||||
|
|||||||
Vendored
+33
-33
@@ -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
@@ -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"]
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
- og / json-sd
|
- og / json-sd
|
||||||
- Lichess login
|
|
||||||
- logger
|
- logger
|
||||||
- contact form
|
|
||||||
- bring sections headers on all pages other than home page further up (reduce top margin / padding)
|
|
||||||
- set game current position on game load to ply 0 and whatever the FEN is (starting position probably, but could be custom position)
|
- 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
|
- privacy policy - have no access to anything you generate, only keep a count of success/failed pdf generations with timestamp and error message
|
||||||
- a11y and lighthouse fixes
|
- check wcag compliance - footer colour contrast
|
||||||
- check wcag compliance
|
- 429 lichess error handling - retry after 10 seconds
|
||||||
- white background?
|
- 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
@@ -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 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+4
-7
@@ -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"
|
||||||
},
|
},
|
||||||
@@ -48,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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+13
-5
@@ -315,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==}
|
||||||
|
|
||||||
@@ -1903,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
|
||||||
|
|
||||||
@@ -2489,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
|
||||||
@@ -2583,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':
|
||||||
@@ -3912,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):
|
||||||
@@ -3925,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
|
||||||
|
|||||||
@@ -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.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -9,10 +9,7 @@ interface IProps {
|
|||||||
const AccordionItem = ({ title, id, children }: IProps) => (
|
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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface IProps {
|
|||||||
|
|
||||||
const CustomHeaders = ({ headers, updateHeaders }: IProps) => {
|
const CustomHeaders = ({ headers, updateHeaders }: IProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 w-full max-w-175">
|
<div className="mt-4 w-full max-w-150">
|
||||||
<details className="bg-(--neutral-content)/20 p-4 rounded-lg">
|
<details className="bg-(--neutral-content)/20 p-4 rounded-lg">
|
||||||
<summary>
|
<summary>
|
||||||
Custom Headers (PDF)
|
Custom Headers (PDF)
|
||||||
|
|||||||
@@ -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-(--neutral-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>
|
||||||
|
|||||||
@@ -10,26 +10,26 @@ 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={GITHUB_URL}
|
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 />
|
||||||
@@ -38,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 />
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
interface GenericErrorViewProps {
|
||||||
|
error?: Error | unknown;
|
||||||
|
resetErrorBoundary?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenericErrorView = ({
|
||||||
|
error,
|
||||||
|
resetErrorBoundary,
|
||||||
|
}: GenericErrorViewProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "An unexpected system error occurred.";
|
||||||
|
|
||||||
|
const handleTryAgain = async () => {
|
||||||
|
if (resetErrorBoundary) {
|
||||||
|
resetErrorBoundary();
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.invalidate();
|
||||||
|
const currentSearch = router.state.location.search;
|
||||||
|
await navigate({ to: ".", search: currentSearch });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-194px)] flex-col items-center justify-center p-6">
|
||||||
|
<div className="relative flex max-w-md flex-col items-center text-center">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight mb-2 text-(--accent)">
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-(--neutral-content) mb-6 max-w-sm">
|
||||||
|
The application encountered an issue it couldn't recover from.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="w-full mb-8 rounded-md bg-slate-300/60 border border-slate-800/80 p-4 text-left font-mono text-xs text-red-400 max-h-32 overflow-y-auto">
|
||||||
|
<span className="text-(--base-content) block mb-1">Error Logs:</span>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full gap-3 justify-center">
|
||||||
|
{resetErrorBoundary && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTryAgain}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate({ to: "/" })}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenericErrorView;
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { 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">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface IProps {
|
|||||||
|
|
||||||
const HeaderFields = ({ headers, updateHeaders }: IProps) => {
|
const HeaderFields = ({ headers, updateHeaders }: IProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 w-full max-w-175">
|
<div className="mt-4 w-full max-w-150">
|
||||||
<details className="bg-(--neutral-content)/15 p-4 rounded-lg">
|
<details className="bg-(--neutral-content)/15 p-4 rounded-lg">
|
||||||
<summary>
|
<summary>
|
||||||
Headers
|
Headers
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
setSelectedStudyId: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LichessStudyLinkInput = ({ setSelectedStudyId }: IProps) => {
|
||||||
|
const { parseLichessStudyLink } = useLichess();
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const studyLink = formData.get("lichess-study-link");
|
||||||
|
|
||||||
|
if (!studyLink) return;
|
||||||
|
|
||||||
|
if (typeof studyLink !== "string") {
|
||||||
|
toast.error("Invalid study link");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseLichessStudyLink(studyLink);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(result.data);
|
||||||
|
|
||||||
|
setSelectedStudyId(result.data);
|
||||||
|
toast.success("Study loaded — pick a chapter");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex items-end gap-2" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lichess-study-link" className="text-sm capitalize">
|
||||||
|
Lichess Study Link
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="border rounded-md p-2 w-full"
|
||||||
|
type="text"
|
||||||
|
id="lichess-study-link"
|
||||||
|
name="lichess-study-link"
|
||||||
|
placeholder="https://lichess.org/study/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="border py-2 px-3 rounded-md cursor-pointer bg-(--accent) text-white hover:bg-transparent hover:text-(--accent) transition-colors duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LichessStudyLinkInput;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
interface IProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingView = ({ title, description }: IProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-194px)] flex-col items-center justify-center p-6 bg-base-100">
|
||||||
|
<div className="relative flex max-w-sm flex-col items-center text-center">
|
||||||
|
{/* Animated Custom Loader */}
|
||||||
|
<div className="relative mb-6 flex h-16 w-16 items-center justify-center">
|
||||||
|
{/* Outer Pulsing Ring */}
|
||||||
|
<div className="absolute inset-0 animate-ping rounded-full bg-(--accent) opacity-20 [animation-duration:1.5s]"></div>
|
||||||
|
|
||||||
|
{/* Inner Spinning Ring */}
|
||||||
|
<div className="h-12 w-12 animate-spin rounded-full border-4 border-(--accent) border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading Content */}
|
||||||
|
<h2 className="text-xl font-bold tracking-tight mb-2 text-(--base-content)">
|
||||||
|
{title || "Loading Data"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-(--neutral-content) max-w-xs">
|
||||||
|
{description || "Please wait a moment while we load the data."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Decorative background glow matching your layout */}
|
||||||
|
<div className="absolute -inset-10 -z-10 bg-(--accent)/5 blur-2xl rounded-full pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingView;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
interface IProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE = "Maintenance Mode";
|
||||||
|
|
||||||
|
const MaintenanceModeBanner = ({ message }: IProps) => (
|
||||||
|
<div className="bg-orange-400 text-white text-center py-1 w-full text-sm">
|
||||||
|
<p>{message || DEFAULT_MESSAGE}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MaintenanceModeBanner;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { trackAppRouter } from "@socialgouv/matomo-next";
|
import { 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();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useClickOutside } from "#/hooks/useClickOutside.ts";
|
||||||
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
setSelectedStudyId: (studyId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const { getLichessUserStudies, userStudies, setUserStudies } = useLichess();
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const filteredStudies = userStudies.filter((study) =>
|
||||||
|
study.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClickHandler = async () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
if (userStudies.length) return;
|
||||||
|
|
||||||
|
const result = await getLichessUserStudies();
|
||||||
|
if (result.ok) {
|
||||||
|
setUserStudies(result.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStudyClick = async (studyId: string) => {
|
||||||
|
const study = userStudies.find((s) => s.id === studyId);
|
||||||
|
setSelectedStudyId(studyId);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
toast.success(`Loaded study "${study?.name ?? studyId}"`);
|
||||||
|
};
|
||||||
|
|
||||||
|
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClickHandler}
|
||||||
|
>
|
||||||
|
Select Lichess Study
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="absolute z-3 bg-(--bg-base) mt-2 max-h-80 border border-(--neutral-content)/30 rounded-lg p-3 overflow-y-scroll transition-all duration-300 ease-in-out opacity-100 scale-100 shadow-lg">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--neutral-content) pointer-events-none">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<title>search</title>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full h-12 pl-10 pr-4 rounded-lg border border-(--neutral-content) text-(--base-content) placeholder-(--neutral-content)/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-(--neutral-content)/15 my-2" />
|
||||||
|
|
||||||
|
{filteredStudies.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{filteredStudies.map((study) => (
|
||||||
|
<li key={study.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-2 cursor-pointer hover:bg-(--neutral-content)/15 focus-visible:outline-2 focus-visible:outline-(--accent) focus-visible:outline-offset-2 rounded text-left w-full transition-colors duration-200"
|
||||||
|
onClick={() => handleStudyClick(study.id)}
|
||||||
|
>
|
||||||
|
{study.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-(--neutral-content) text-sm text-center py-4">
|
||||||
|
No results found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectLichessStudy;
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
type Dispatch,
|
||||||
|
useActionState,
|
||||||
|
useEffect,
|
||||||
|
// useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
// import { useClickOutside } from "#/hooks/useClickOutside.ts";
|
||||||
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
import type { IUserStudyChapter } from "#/interfaces.ts";
|
||||||
|
import type { GameAction } from "#/reducers/gameReducer.ts";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
selectedStudyId: string;
|
||||||
|
gameDispatch: Dispatch<GameAction>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChapterState = {
|
||||||
|
status: "idle" | "success" | "error";
|
||||||
|
chapters: IUserStudyChapter[];
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialChapterState: ChapterState = {
|
||||||
|
status: "idle",
|
||||||
|
chapters: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
// const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess();
|
||||||
|
|
||||||
|
const [chapterState, fetchChaptersAction, isPending] = useActionState(
|
||||||
|
async (_prev: ChapterState, _formData: FormData): Promise<ChapterState> => {
|
||||||
|
if (!selectedStudyId) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
chapters: [],
|
||||||
|
error: "No study selected.",
|
||||||
|
} satisfies ChapterState;
|
||||||
|
}
|
||||||
|
const result = await getLichessStudyChapters({
|
||||||
|
studyId: selectedStudyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
chapters: result.data,
|
||||||
|
error: null,
|
||||||
|
} satisfies ChapterState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
chapters: [],
|
||||||
|
error: result.error,
|
||||||
|
} satisfies ChapterState;
|
||||||
|
},
|
||||||
|
initialChapterState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredChapters = chapterState.chapters.filter((chapter) =>
|
||||||
|
chapter.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPending || chapterState.status === "success") {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [isPending, chapterState.status]);
|
||||||
|
|
||||||
|
const handleChapterClick = async (chapter: IUserStudyChapter) => {
|
||||||
|
const { headers, pgn } = await loadLichessStudyChapter({ chapter });
|
||||||
|
|
||||||
|
gameDispatch({ type: "SET_GAME", payload: { pgn, headers } });
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
toast.success(`Loaded chapter "${chapter.name}"`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
key={selectedStudyId}
|
||||||
|
action={fetchChaptersAction}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="studyId" value={selectedStudyId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={!selectedStudyId || isPending}
|
||||||
|
>
|
||||||
|
{isPending ? "Loading chapters…" : "Import Chapter"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="absolute z-3 bg-(--bg-base) mt-2 max-h-80 border border-(--neutral-content)/30 rounded-lg p-3 overflow-y-scroll transition-all duration-300 ease-in-out opacity-100 scale-100 shadow-lg">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--neutral-content) pointer-events-none">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<title>search</title>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full h-12 pl-10 pr-4 rounded-lg border border-(--neutral-content) text-(--base-content) placeholder-(--neutral-content)/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-(--neutral-content)/15 my-2" />
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<p className="text-(--neutral-content) text-sm text-center py-4">
|
||||||
|
Loading chapters…
|
||||||
|
</p>
|
||||||
|
) : filteredChapters.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{filteredChapters.map((chapter) => (
|
||||||
|
<li key={`${chapter.chapterId}${chapter.name}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-2 cursor-pointer hover:bg-(--neutral-content)/15 focus-visible:outline-2 focus-visible:outline-(--accent) focus-visible:outline-offset-2 rounded text-left w-full transition-colors duration-200"
|
||||||
|
onClick={() => handleChapterClick(chapter)}
|
||||||
|
>
|
||||||
|
{chapter.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-(--neutral-content) text-sm text-center py-4">
|
||||||
|
No results found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectStudyChapter;
|
||||||
@@ -0,0 +1,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;
|
||||||
|
};
|
||||||
+7
-1
@@ -1,7 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const clientSchema = z.object({
|
const clientSchema = z.object({
|
||||||
VITE_API_BASE_URL: z.string("Invalid API Base URL format"),
|
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({
|
const serverSchema = z.object({
|
||||||
@@ -23,6 +27,7 @@ const getEnv = () => {
|
|||||||
const _env = fullSchema.safeParse(rawEnv);
|
const _env = fullSchema.safeParse(rawEnv);
|
||||||
if (!_env.success) {
|
if (!_env.success) {
|
||||||
console.error("❌ Invalid Server Environment Variables:");
|
console.error("❌ Invalid Server Environment Variables:");
|
||||||
|
console.error(JSON.stringify(_env.error.format(), null, 2));
|
||||||
throw new Error("Invalid environment variables");
|
throw new Error("Invalid environment variables");
|
||||||
}
|
}
|
||||||
return _env.data;
|
return _env.data;
|
||||||
@@ -30,6 +35,7 @@ const getEnv = () => {
|
|||||||
const _env = clientSchema.safeParse(rawEnv);
|
const _env = clientSchema.safeParse(rawEnv);
|
||||||
if (!_env.success) {
|
if (!_env.success) {
|
||||||
console.error("❌ Invalid Client Environment Variables:");
|
console.error("❌ Invalid Client Environment Variables:");
|
||||||
|
console.error(JSON.stringify(_env.error.format(), null, 2));
|
||||||
throw new Error("Invalid environment variables");
|
throw new Error("Invalid environment variables");
|
||||||
}
|
}
|
||||||
return _env.data as z.infer<typeof fullSchema>;
|
return _env.data as z.infer<typeof fullSchema>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useServerFn } from "@tanstack/react-start";
|
||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -9,6 +10,7 @@ import { toast } from "react-toastify";
|
|||||||
import { env } from "#/env.ts";
|
import { env } from "#/env.ts";
|
||||||
import type { IHeader, IPosition } from "#/interfaces.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 { 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";
|
||||||
@@ -16,6 +18,8 @@ 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>({
|
const [currentPosition, setCurrentPosition] = useState<IPosition>({
|
||||||
ply: 0,
|
ply: 0,
|
||||||
fen: "",
|
fen: "",
|
||||||
@@ -66,7 +70,7 @@ export const useChessGame = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
downloadPDF(await response.blob());
|
downloadPDF(await response.blob());
|
||||||
|
|
||||||
// TODO add metrics logger - add success to analytics
|
await recordPdfSuccessFn();
|
||||||
|
|
||||||
toast.success("PDF successfully generated!", {
|
toast.success("PDF successfully generated!", {
|
||||||
toastId: "pdf-success",
|
toastId: "pdf-success",
|
||||||
@@ -189,5 +193,6 @@ export const useChessGame = () => {
|
|||||||
currentPosition,
|
currentPosition,
|
||||||
generatingPdf,
|
generatingPdf,
|
||||||
updateHeaders,
|
updateHeaders,
|
||||||
|
gameDispatch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { type RefObject, useEffect } from "react";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
ref: RefObject<HTMLDivElement | null>;
|
||||||
|
setSearch: (search: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClickOutside = ({
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
setSearch,
|
||||||
|
ref,
|
||||||
|
}: IProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
ref.current &&
|
||||||
|
!ref.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen, setIsOpen, setSearch, ref]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useServerFn } from "@tanstack/react-start";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLichessUser } from "#/context/LichessUserContext.tsx";
|
||||||
|
import type {
|
||||||
|
ITokenData,
|
||||||
|
IUserStudy,
|
||||||
|
IUserStudyChapter,
|
||||||
|
} from "#/interfaces.ts";
|
||||||
|
import {
|
||||||
|
getSession,
|
||||||
|
getStudyChapters,
|
||||||
|
getToken,
|
||||||
|
getUser,
|
||||||
|
getUserStudies,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setSession,
|
||||||
|
} from "#/server/lichess.ts";
|
||||||
|
import { getHeaders } from "#/utils/pgnUtils.ts";
|
||||||
|
|
||||||
|
type Status = "loading" | "success" | "error";
|
||||||
|
|
||||||
|
export type LichessResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
const LICHESS_STUDY_LINK_RE =
|
||||||
|
/^https?:\/\/lichess\.org\/study\/([A-Za-z0-9]{8})(?:\.pgn)?(?:$|\/|\?|#)/i;
|
||||||
|
|
||||||
|
const lichessFetch = async <T>(
|
||||||
|
fn: () => Promise<T | undefined>,
|
||||||
|
fallbackError: string,
|
||||||
|
): Promise<LichessResult<T>> => {
|
||||||
|
try {
|
||||||
|
const data = await fn();
|
||||||
|
if (data === undefined || data === null) {
|
||||||
|
return { ok: false, error: fallbackError };
|
||||||
|
}
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : fallbackError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLichess = () => {
|
||||||
|
const { user, setUser } = useLichessUser();
|
||||||
|
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
|
||||||
|
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loginFn = useServerFn(login);
|
||||||
|
const logoutFn = useServerFn(logout);
|
||||||
|
const accessTokenFn = useServerFn(getToken);
|
||||||
|
const getUserFn = useServerFn(getUser);
|
||||||
|
const setSessionFn = useServerFn(setSession);
|
||||||
|
const getSessionFn = useServerFn(getSession);
|
||||||
|
const getUserStudiesFn = useServerFn(getUserStudies);
|
||||||
|
const getStudyChaptersFn = useServerFn(getStudyChapters);
|
||||||
|
|
||||||
|
const lichessAccessToken = async ({ code }: { code: string }) => {
|
||||||
|
return accessTokenFn({ data: { code } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLichessUser = async ({
|
||||||
|
username,
|
||||||
|
id,
|
||||||
|
token,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
}) => {
|
||||||
|
await setSessionFn({ data: { username, id, token } });
|
||||||
|
setUser({ username, id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => {
|
||||||
|
return getUserFn({
|
||||||
|
data: { access_token: tokenData.access_token },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const lichessLogin = async () => await loginFn();
|
||||||
|
|
||||||
|
const lichessLogout = async () => {
|
||||||
|
await logoutFn();
|
||||||
|
setSelectedStudyId(null);
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLichessSession = async () => {
|
||||||
|
return getSessionFn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const useLichessCallback = ({ code }: { code: string }) => {
|
||||||
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
const isMounted = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
(async () => {
|
||||||
|
const tokenData = await lichessAccessToken({ code });
|
||||||
|
const userData = await getLichessUser({ tokenData });
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
await setLichessUser({
|
||||||
|
username: userData.username,
|
||||||
|
id: userData.id,
|
||||||
|
token: tokenData.access_token,
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatus("success");
|
||||||
|
})();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isMounted && err instanceof Error) {
|
||||||
|
console.error("Lichess Authentication Error:", err);
|
||||||
|
setError(err.message || "Failed to authenticate with Lichess");
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [code]);
|
||||||
|
|
||||||
|
return { status, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLichessUserStudies = async (): Promise<
|
||||||
|
LichessResult<IUserStudy[]>
|
||||||
|
> => {
|
||||||
|
if (!user) return { ok: false, error: "Not signed in to Lichess." };
|
||||||
|
return lichessFetch(() => getUserStudiesFn(), "Failed to load studies.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLichessStudyChapters = async ({
|
||||||
|
studyId,
|
||||||
|
}: {
|
||||||
|
studyId: string;
|
||||||
|
}): Promise<LichessResult<IUserStudyChapter[]>> => {
|
||||||
|
if (!user) return { ok: false, error: "Not signed in to Lichess." };
|
||||||
|
return lichessFetch(
|
||||||
|
() => getStudyChaptersFn({ data: { studyId } }),
|
||||||
|
"Failed to load chapters.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// return headers and pgn from the Lichess chapter
|
||||||
|
const loadLichessStudyChapter = async ({
|
||||||
|
chapter,
|
||||||
|
}: {
|
||||||
|
chapter: IUserStudyChapter;
|
||||||
|
}) => {
|
||||||
|
const pgn = chapter.pgn.trim();
|
||||||
|
|
||||||
|
if (!chapter.pgn) throw new Error("No valid PGN found for chapter");
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...getHeaders(pgn),
|
||||||
|
title: "",
|
||||||
|
subtitle: "",
|
||||||
|
author: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { headers, pgn };
|
||||||
|
};
|
||||||
|
|
||||||
|
// parse and process lichess study link
|
||||||
|
const parseLichessStudyLink = (studyLink: string): LichessResult<string> => {
|
||||||
|
const trimmed = studyLink.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return { ok: false, error: "Please paste a Lichess study link." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = trimmed.match(LICHESS_STUDY_LINK_RE);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { ok: false, error: "Invalid Lichess study link." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, data: match[1] };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
lichessLogin,
|
||||||
|
lichessLogout,
|
||||||
|
lichessAccessToken,
|
||||||
|
getLichessUser,
|
||||||
|
setLichessUser,
|
||||||
|
getLichessSession,
|
||||||
|
useLichessCallback,
|
||||||
|
getLichessUserStudies,
|
||||||
|
userStudies,
|
||||||
|
setUserStudies,
|
||||||
|
setSelectedStudyId,
|
||||||
|
selectedStudyId,
|
||||||
|
getLichessStudyChapters,
|
||||||
|
loadLichessStudyChapter,
|
||||||
|
parseLichessStudyLink,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,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]);
|
||||||
|
};
|
||||||
@@ -1,3 +1,22 @@
|
|||||||
|
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 {
|
export interface IPosition {
|
||||||
ply: number;
|
ply: number;
|
||||||
fen: string;
|
fen: string;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
export const base64UrlEncode = (str: Buffer) => {
|
||||||
|
return str
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sha256 = (buffer: string) =>
|
||||||
|
createHash("sha256").update(buffer).digest();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getRouteApi, useNavigate } from "@tanstack/react-router";
|
||||||
|
import GenericErrorView from "#/components/GenericErrorView.tsx";
|
||||||
|
import LoadingView from "#/components/LoadingView.tsx";
|
||||||
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
|
const routeApi = getRouteApi("/callback");
|
||||||
|
|
||||||
|
const Callback = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { useLichessCallback } = useLichess();
|
||||||
|
const { code } = routeApi.useSearch();
|
||||||
|
const { status, error } = useLichessCallback({ code });
|
||||||
|
|
||||||
|
if (status === "success") navigate({ to: "/chessboard" });
|
||||||
|
|
||||||
|
if (status === "error") return <GenericErrorView error={error} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingView title="Logging in..." description="Please wait a moment..." />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Callback;
|
||||||
@@ -2,8 +2,13 @@ import { lazy, Suspense } from "react";
|
|||||||
import CustomHeaders from "#/components/CustomHeaders.tsx";
|
import CustomHeaders from "#/components/CustomHeaders.tsx";
|
||||||
import HeaderFields from "#/components/HeaderFields.tsx";
|
import HeaderFields from "#/components/HeaderFields.tsx";
|
||||||
import LichessButton from "#/components/LichessButton.tsx";
|
import LichessButton from "#/components/LichessButton.tsx";
|
||||||
|
import LichessStudyLinkInput from "#/components/LichessStudyLinkInput.tsx";
|
||||||
import Section from "#/components/Section.tsx";
|
import Section from "#/components/Section.tsx";
|
||||||
|
import SelectLichessStudy from "#/components/SelectLichessStudy.tsx";
|
||||||
|
import SelectStudyChapter from "#/components/SelectStudyChapter.tsx";
|
||||||
|
import { useLichessUser } from "#/context/LichessUserContext.tsx";
|
||||||
import { useChessGame } from "#/hooks/useChessGame.ts";
|
import { useChessGame } from "#/hooks/useChessGame.ts";
|
||||||
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
const PgnViewer = lazy(() => import("#/components/PgnViewer.tsx"));
|
const PgnViewer = lazy(() => import("#/components/PgnViewer.tsx"));
|
||||||
|
|
||||||
@@ -31,12 +36,17 @@ const Chessboard = () => {
|
|||||||
handleToggleDiagram,
|
handleToggleDiagram,
|
||||||
handlePlyChange,
|
handlePlyChange,
|
||||||
updateHeaders,
|
updateHeaders,
|
||||||
|
gameDispatch,
|
||||||
} = useChessGame();
|
} = useChessGame();
|
||||||
|
|
||||||
|
const { selectedStudyId, setSelectedStudyId } = useLichess();
|
||||||
|
|
||||||
|
const { user } = useLichessUser();
|
||||||
|
|
||||||
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">
|
||||||
<LichessButton onClickHandler={() => console.log("Lichess Login")} /> or{" "}
|
<LichessButton /> or{" "}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
id="file-input"
|
id="file-input"
|
||||||
@@ -44,6 +54,22 @@ 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="grid lg:grid-cols-2 gap-4">
|
<div className="grid lg:grid-cols-2 gap-4">
|
||||||
@@ -53,15 +79,15 @@ const Chessboard = () => {
|
|||||||
gamePgn={gameState.pgn || ""}
|
gamePgn={gameState.pgn || ""}
|
||||||
handlePlyChange={handlePlyChange}
|
handlePlyChange={handlePlyChange}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-3 gap-2 max-w-150 mt-2">
|
<div className="grid grid-cols-12 gap-4 max-w-150 mt-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary col-span-5 flex items-center justify-center"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClearGame}
|
onClick={handleClearGame}
|
||||||
>
|
>
|
||||||
Clear Game
|
<p className="text-sm font-semibold">Clear Game</p>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-1 items-center col-span-3">
|
||||||
<label
|
<label
|
||||||
htmlFor="diagram-add"
|
htmlFor="diagram-add"
|
||||||
className="text-sm text-(--base-content)"
|
className="text-sm text-(--base-content)"
|
||||||
@@ -80,12 +106,13 @@ const Chessboard = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="toggle text-sm text-(--base-content)">
|
<label className="toggle text-sm text-(--base-content) col-span-4">
|
||||||
Render move times
|
Render move times
|
||||||
<input
|
<input
|
||||||
id="render-times"
|
id="render-times"
|
||||||
name="render-times"
|
name="render-times"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
disabled={!gameState.pgn}
|
||||||
checked={gameState.diagramClock}
|
checked={gameState.diagramClock}
|
||||||
onChange={handleToggleClock}
|
onChange={handleToggleClock}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import Contact from "#/pages/Contact/Contact.tsx";
|
import Contact from "#/pages/Contact/Contact.tsx";
|
||||||
|
import { sendToDiscord } from "#/server/contact.ts";
|
||||||
|
|
||||||
const ContactContainer = () => {
|
const ContactContainer = () => {
|
||||||
const handleSubmit = async (_prevState: unknown, data: FormData) => {
|
const handleSubmit = async (_prevState: unknown, data: FormData) => {
|
||||||
const name = data.get("name");
|
const payload = {
|
||||||
const email = data.get("email");
|
name: (data.get("name") as string) || "",
|
||||||
const subject = data.get("subject");
|
email: (data.get("email") as string) || "",
|
||||||
const message = data.get("message");
|
subject: (data.get("subject") as string) || "",
|
||||||
|
message: (data.get("message") as string) || "",
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return { success: true, message: "Message sent!" };
|
await sendToDiscord({ data: payload });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Message sent successfully!",
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// TODO logger
|
// TODO logger
|
||||||
console.error("Error sending message:", error);
|
console.error("Error sending message:", error);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Failed to send message. Please try again.",
|
message: "Failed to send message. Please try again.",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
|
import { LuSend } from "react-icons/lu";
|
||||||
import Section from "#/components/Section.tsx";
|
import Section from "#/components/Section.tsx";
|
||||||
|
import { useToastStateChange } from "#/hooks/useToastStateChange.ts";
|
||||||
|
|
||||||
interface IFormField {
|
interface IFormField {
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
type: "text" | "email" | "textarea";
|
type: "text" | "email" | "textarea";
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
label: string;
|
required?: boolean;
|
||||||
required: boolean;
|
textarea?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IProps = {
|
type IProps = {
|
||||||
@@ -20,23 +22,42 @@ const FormField = ({
|
|||||||
type,
|
type,
|
||||||
fieldName,
|
fieldName,
|
||||||
placeholder,
|
placeholder,
|
||||||
label,
|
required = false,
|
||||||
required,
|
textarea = false,
|
||||||
}: IFormField) => (
|
}: IFormField) => (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label htmlFor={fieldName}>{label}</label>
|
<label
|
||||||
<input
|
htmlFor={fieldName}
|
||||||
type={type}
|
className="capitalize text-(--neutral-content) text-left"
|
||||||
id={fieldName}
|
>
|
||||||
name={fieldName}
|
{fieldName}
|
||||||
placeholder={placeholder}
|
{required && <span>*</span>}
|
||||||
required={required}
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Contact = ({ handleSubmit }: IProps) => {
|
const Contact = ({ handleSubmit }: IProps) => {
|
||||||
const [state, formAction, isPending] = useActionState(handleSubmit, null);
|
const [state, formAction, isPending] = useActionState(handleSubmit, null);
|
||||||
|
useToastStateChange({ state });
|
||||||
|
|
||||||
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)]">
|
||||||
@@ -44,43 +65,47 @@ const Contact = ({ handleSubmit }: IProps) => {
|
|||||||
title="Contact Us"
|
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."
|
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}>
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="col-span-full max-w-150 flex flex-col gap-6 w-full"
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
type="text"
|
type="text"
|
||||||
fieldName="name"
|
fieldName="name"
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
label="Name"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
fieldName="email"
|
fieldName="email"
|
||||||
type="text"
|
type="email"
|
||||||
placeholder="name@example.com"
|
placeholder="name@example.com"
|
||||||
label="Email"
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<FormField
|
||||||
|
fieldName="subject"
|
||||||
type="text"
|
type="text"
|
||||||
name="subject"
|
|
||||||
placeholder="Reason for contacting us"
|
placeholder="Reason for contacting us"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<textarea
|
<FormField
|
||||||
name="message"
|
fieldName="message"
|
||||||
|
type="text"
|
||||||
placeholder="Your message here..."
|
placeholder="Your message here..."
|
||||||
required
|
required
|
||||||
|
textarea
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary flex items-center justify-center gap-2 w-full"
|
||||||
>
|
>
|
||||||
{isPending ? "Sending..." : "Send"}
|
{isPending ? "Sending..." : "Send"}
|
||||||
|
<LuSend className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
{state && <p>{state.message}</p>}
|
{/*{state && <p>{state.message}</p>}*/}
|
||||||
</form>
|
</form>
|
||||||
</Section>
|
</Section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+17
-1
@@ -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">
|
||||||
@@ -37,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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IGameState, IHeader, IPosition } from "#/interfaces.ts";
|
import type { IGameState, IHeader, IPosition } from "#/interfaces.ts";
|
||||||
|
|
||||||
type GameAction =
|
export 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 }
|
||||||
|
|||||||
+21
-3
@@ -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
@@ -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>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-15
@@ -3,7 +3,11 @@ 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 { 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";
|
||||||
@@ -38,10 +42,27 @@ export const Route = createRootRoute({
|
|||||||
{
|
{
|
||||||
rel: "icon",
|
rel: "icon",
|
||||||
href: "/favicon.svg",
|
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 }) {
|
||||||
@@ -51,21 +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 />
|
||||||
<ToastContainer position="bottom-right" />
|
{children}
|
||||||
<Footer />
|
<ToastContainer position="bottom-right" />
|
||||||
<TanStackDevtools
|
<Footer />
|
||||||
config={{
|
<TanStackDevtools
|
||||||
position: "bottom-right",
|
config={{
|
||||||
}}
|
position: "bottom-right",
|
||||||
plugins={[
|
}}
|
||||||
{
|
plugins={[
|
||||||
name: "Tanstack Router",
|
{
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
name: "Tanstack Router",
|
||||||
},
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
]}
|
},
|
||||||
/>
|
]}
|
||||||
|
/>
|
||||||
|
</LichessUserProvider>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<MatomoAnalytics />
|
<MatomoAnalytics />
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||||
|
import { z } from "zod";
|
||||||
|
import GenericErrorView from "#/components/GenericErrorView.tsx";
|
||||||
|
import Callback from "#/pages/Callback.tsx";
|
||||||
|
|
||||||
|
const callbackSchema = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/callback")({
|
||||||
|
head: () => ({
|
||||||
|
meta: [{ name: "robots", content: "noindex, nofollow" }],
|
||||||
|
}),
|
||||||
|
|
||||||
|
validateSearch: (search) => callbackSchema.parse(search),
|
||||||
|
|
||||||
|
beforeLoad: ({ search }) => {
|
||||||
|
if (!search.code) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
component: RouteComponent,
|
||||||
|
errorComponent: ({ error }) => <GenericErrorView error={error} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <Callback />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const SessionSchema = z.object({
|
||||||
|
username: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TokenResponseSchema = z.object({
|
||||||
|
access_token: z.string(),
|
||||||
|
expires_in: z.number(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
token_type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LichessAccountSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
username: z.string(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { env } from "#/env.ts";
|
||||||
|
|
||||||
|
export const sendToDiscord = createServerFn({ method: "POST" })
|
||||||
|
.inputValidator(
|
||||||
|
(data: { name: string; email: string; subject: string; message: string }) =>
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const response = await fetch(env.DISCORD_CONTACT_WEBHOOK, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: "Contact Form Submission",
|
||||||
|
fields: [
|
||||||
|
{ name: "Name", value: data.name.trim() || "Not provided" },
|
||||||
|
{ name: "Email", value: data.email.trim() || "Not provided" },
|
||||||
|
{ name: "Subject", value: data.subject.trim() || "No Subject" },
|
||||||
|
{
|
||||||
|
name: "Message",
|
||||||
|
value: data.message.trim() || "Empty message",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Discord API rejected the request");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { redirect } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import {
|
||||||
|
deleteCookie,
|
||||||
|
getCookie,
|
||||||
|
setCookie,
|
||||||
|
} from "@tanstack/react-start/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { env } from "#/env.ts";
|
||||||
|
import { base64UrlEncode, sha256 } from "#/lib/encodeDecode.ts";
|
||||||
|
import {
|
||||||
|
LichessAccountSchema,
|
||||||
|
SessionSchema,
|
||||||
|
TokenResponseSchema,
|
||||||
|
} from "#/schemas.ts";
|
||||||
|
|
||||||
|
const createVerifier = () => base64UrlEncode(randomBytes(32));
|
||||||
|
const createChallenge = (verifier: string) => base64UrlEncode(sha256(verifier));
|
||||||
|
|
||||||
|
const getRedirectUri = () =>
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? `https://${env.LICHESS_CLIENT_ID}/callback`
|
||||||
|
: "http://localhost:3000/callback";
|
||||||
|
|
||||||
|
export const getSession = createServerFn({ method: "GET" }).handler(
|
||||||
|
async () => {
|
||||||
|
const session = getCookie("lichess-session");
|
||||||
|
if (!session) return null;
|
||||||
|
try {
|
||||||
|
const parsed = SessionSchema.safeParse(JSON.parse(session));
|
||||||
|
return parsed.success ? parsed.data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const setSession = createServerFn({ method: "POST" })
|
||||||
|
.inputValidator(SessionSchema)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
setCookie("lichess-session", JSON.stringify(data), {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCookie("lichess-oauth-verifier", "", {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { username: data.username, id: data.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getToken = createServerFn({ method: "POST" })
|
||||||
|
.inputValidator(z.object({ code: z.string() }))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const verifier = getCookie("lichess-oauth-verifier");
|
||||||
|
|
||||||
|
if (!verifier) {
|
||||||
|
throw new Error("OAuth session expired. Please login again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("https://lichess.org/api/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: env.LICHESS_CLIENT_ID || "",
|
||||||
|
code: data.code,
|
||||||
|
redirect_uri: getRedirectUri(),
|
||||||
|
code_verifier: verifier,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token exchange failed: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return TokenResponseSchema.parse(json);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUser = createServerFn({ method: "POST" })
|
||||||
|
.inputValidator(z.object({ access_token: z.string() }))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const response = await fetch("https://lichess.org/api/account", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${data.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch Lichess account");
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
return LichessAccountSchema.parse(json);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteAccessToken = createServerFn().handler(async () => {
|
||||||
|
const session = await getSession();
|
||||||
|
const token = session?.token;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const response = await fetch("https://lichess.org/api/token", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete access token: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: "No token found to delete." };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logout = createServerFn().handler(async () => {
|
||||||
|
await deleteAccessToken();
|
||||||
|
deleteCookie("lichess-session", { path: "/" });
|
||||||
|
deleteCookie("lichess-oauth-verifier", { path: "/" }); // cookie shouldn't exist
|
||||||
|
});
|
||||||
|
|
||||||
|
export const login = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
|
const verifier = createVerifier();
|
||||||
|
const challenge = createChallenge(verifier);
|
||||||
|
|
||||||
|
setCookie("lichess-oauth-verifier", verifier, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: "code",
|
||||||
|
client_id: env.LICHESS_CLIENT_ID || "",
|
||||||
|
redirect_uri: getRedirectUri(),
|
||||||
|
scope: "study:read",
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
code_challenge: challenge,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw redirect({
|
||||||
|
href: `https://lichess.org/oauth?${params.toString()}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUserStudies = createServerFn({ method: "POST" }).handler(
|
||||||
|
async () => {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://lichess.org/api/study/by/${session.username}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const studies = await response.text();
|
||||||
|
|
||||||
|
return studies
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((study) => JSON.parse(study));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getStudyChapters = createServerFn({ method: "POST" })
|
||||||
|
.inputValidator((data: { studyId: string }) => data)
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://lichess.org/api/study/${data.studyId}.pgn`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
return text
|
||||||
|
.trim()
|
||||||
|
.split(/\n{3,}/)
|
||||||
|
.map((game) => {
|
||||||
|
const eventHeaderText = game.match(/\[Event\s+"([^"]+)"]/)?.[1];
|
||||||
|
const eventName = eventHeaderText?.match(/(?<=:\s+)[^"]+/)?.[0] || "";
|
||||||
|
const siteHeaderText = game.match(/\[Site\s+"([^"]+)"]/)?.[1];
|
||||||
|
const chapterId = siteHeaderText?.match(/\/([^/]+)$/)?.[1] || "";
|
||||||
|
return { chapterId, pgn: game, name: eventName };
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
|
||||||
|
const METRICS_DIR = join(process.cwd(), "data");
|
||||||
|
const LOG_FILE = join(METRICS_DIR, "pdf-events.log");
|
||||||
|
const COUNT_FILE = join(METRICS_DIR, "pdf-count.txt");
|
||||||
|
|
||||||
|
const readCount = async (): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const n = Number.parseInt(
|
||||||
|
(await fs.readFile(COUNT_FILE, "utf-8")).trim(),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeCountAtomic = async (count: number) => {
|
||||||
|
const tmp = `${COUNT_FILE}.tmp`;
|
||||||
|
await fs.writeFile(tmp, String(count), "utf-8");
|
||||||
|
await fs.rename(tmp, COUNT_FILE);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordPdfSuccess = createServerFn({ method: "POST" }).handler(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(METRICS_DIR, { recursive: true });
|
||||||
|
const next = (await readCount()) + 1;
|
||||||
|
await Promise.all([
|
||||||
|
fs.appendFile(LOG_FILE, `${new Date().toISOString()}\n`, "utf-8"),
|
||||||
|
writeCountAtomic(next),
|
||||||
|
]);
|
||||||
|
return { count: next };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to record PDF success:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
@import "./styles/_button.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";
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -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;
|
||||||
@@ -53,19 +66,6 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
:root {
|
||||||
|
/* biome-ignore lint/complexity/noImportantStyles: <override vendor style requires !important> */
|
||||||
|
--toastify-color-success: var(--accent) !important;
|
||||||
|
}
|
||||||
+688
-1
File diff suppressed because one or more lines are too long
@@ -19,6 +19,17 @@ const config = defineConfig({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
crawlLinks: true,
|
crawlLinks: true,
|
||||||
},
|
},
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
path: "/callback",
|
||||||
|
prerender: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
sitemap: {
|
||||||
|
exclude: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
sitemap: {
|
sitemap: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
host: HOST_URL,
|
host: HOST_URL,
|
||||||
|
|||||||
Reference in New Issue
Block a user