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