Compare commits

...

34 Commits

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

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

* basic import of lichess study link

* change interface import

* Minmax M3 solution to import study chapter/game functionality

* change button text

* fix toast to display on study and chapter load

* add lint and format fix scripts

* apply auto formatting

* update ignored directories

* fix specificity

* add rudimentary PDF count metrics

* allow docker build

* create maintenance mode banner, triggered by env variables
2026-06-25 14:29:50 +02:00
owenrees fbc704dc30 center nav items on firefox as well 2026-06-13 22:17:19 +02:00
owenrees c082a356fd add multiple favicons 2026-06-13 21:30:25 +02:00
owenrees 6120c48377 noindex nofollow on callback route 2026-06-13 20:56:22 +02:00
owenrees 99f8aa8ac2 exclude callback route from sitemap 2026-06-13 20:51:06 +02:00
owenrees 2e05f86d5f update lighthouse todos 2026-06-12 16:57:10 +02:00
owenrees 4577755c93 add aria label to footer logo link 2026-06-12 16:52:40 +02:00
owenrees a110c7490c change h6 to h5 to fix heading structure 2026-06-12 16:49:55 +02:00
owenrees 0b10637df4 avoid prerendering callback page 2026-06-12 16:46:41 +02:00
owenrees 41cbd5db16 fix aria reference 2026-06-12 16:45:05 +02:00
owenrees af6a8426ad aria labels and font size changes 2026-06-12 15:17:29 +02:00
owenrees 0dde848cfe add aria labels to non-labelled icons 2026-06-12 15:04:53 +02:00
owenrees ee9968e99b pass matomo analytics env vars with zod 2026-06-12 14:48:52 +02:00
owenrees 7384a3f825 custom 404 page 2026-06-12 14:17:29 +02:00
owenrees 887a43636f export session interface to create a user interface for context 2026-06-12 11:27:12 +02:00
owenrees 734c631356 export interfaces 2026-06-12 11:22:54 +02:00
owenrees 802d46191e do not set isLoggedInBoolean since it is now removed 2026-06-12 11:22:41 +02:00
owenrees 4edcb49f58 fix button text based on user existing, rather that the removed isLogged in boolean 2026-06-12 11:21:50 +02:00
owenrees 030d44226d remove isLoggedIn booelan from lichess user, either a user exists or doesnt 2026-06-12 11:21:05 +02:00
owenrees f12176e713 remove isLoggedIn booelan from lichess user, either a user exists or doesnt 2026-06-12 11:20:55 +02:00
owenrees 3da81680a1 split schema for server functions 2026-06-12 11:04:43 +02:00
owenrees 0b32e77d7e add zod schema and validation to server 2026-06-12 10:58:59 +02:00
46 changed files with 1342 additions and 205 deletions
+6 -1
View File
@@ -1,4 +1,9 @@
VITE_API_BASE_URL=
VITE_MATOMO_URL=
VITE_MATOMO_SITE_ID=
VITE_MAINTENANCE_MODE= # nothing = false, anything else = true
VITE_MAINTENANCE_MODE_MESSAGE="ChessScribe is currently undergoing maintenance. Some features may be unavailable."
DISCORD_CONTACT_WEBHOOK=
DISCORD_ERROR_LOG_WEBHOOK=
LICHESS_CLIENT_ID=
VITE_API_BASE_URL=
+59
View File
@@ -0,0 +1,59 @@
name: Production Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
GH_USER: therealowenrees
IMAGE_NAME: therealowenrees/chess-scribe:latest
jobs:
publish:
name: publish image
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Login to Github Container Registry
run: echo "${{ secrets.GHCR_SECRET }}" | docker login ghcr.io -u ${{ env.GH_USER }} --password-stdin
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image and publish with Cache
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VITE_API_BASE_URL=https://api.chess-scribe.org/api/v1
VITE_MATOMO_URL=https://analytics.owenrees.eu
VITE_MATOMO_SITE_ID="3"
VITE_MAINTENANCE_MODE=
VITE_MAINTENANCE_MODE_MESSAGE="ChessScribe is currently undergoing maintenance. Some features may be unavailable."
deploy:
needs: publish
name: deploy image
runs-on: ubuntu-24.04
steps:
- name: Connect and pull image from GHCR
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.PORT }}
script: |
cd /home/${{ secrets.USERNAME }}
echo "${{ secrets.GHCR_SECRET }}" | docker login ghcr.io -u ${{ env.GH_USER }} --password-stdin
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
docker compose up -d
+1
View File
@@ -11,3 +11,4 @@ dist-ssr
.vinxi
__unconfig*
todos.json
data
+38
View File
@@ -0,0 +1,38 @@
# --- Stage 1: Build Stage ---
FROM node:24-alpine AS builder
WORKDIR /app
# 1. Declare the build-time arguments Vite needs
ARG VITE_API_BASE_URL
ARG VITE_MATOMO_URL
ARG VITE_MATOMO_SITE_ID
ARG VITE_MAINTENANCE_MODE
ARG VITE_MAINTENANCE_MODE_MESSAGE
# 2. Assign them to environment variables so Vite detects them
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_MATOMO_URL=$VITE_MATOMO_URL
ENV VITE_MATOMO_SITE_ID=$VITE_MATOMO_SITE_ID
ENV VITE_MAINTENANCE_MODE=$VITE_MAINTENANCE_MODE
ENV VITE_MAINTENANCE_MODE_MESSAGE=$VITE_MAINTENANCE_MODE_MESSAGE
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile
COPY . .
RUN pnpm run build
# --- Stage 2: Production Stage ---
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# Nitro compiles everything needed into the .output directory
COPY --from=builder /app/.output ./.output
EXPOSE 3000
# Run the compiled Nitro server
CMD ["node", ".output/server/index.mjs"]
+5 -7
View File
@@ -1,15 +1,13 @@
- og / json-sd
- logger
- bring sections headers on all pages other than home page further up (reduce top margin / padding)
- set game current position on game load to ply 0 and whatever the FEN is (starting position probably, but could be custom position)
- privacy policy - have no access to anything you generate, only keep a count of success/failed pdf generations with timestamp and error message
- a11y and lighthouse fixes
- check wcag compliance
- 429 lichess error handling
- add throw errors or return errors in server code
- 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
- 404 page
- lichess data with zod validation
- react query on for study and chapter load, to allow caching
- https://specification.website/checklist/
- react query to cache the study and chapters
+6 -2
View File
@@ -13,7 +13,12 @@
"**/index.html",
"**/vite.config.ts",
"!**/src/routeTree.gen.ts",
"!**/src/styles.css"
"!**/src/styles.css",
"!**/.tanstack/*",
"!**/.output/*",
"!**/.nitro/*",
"!**/node_modules/*",
"!**/styles/vendor/*"
]
},
"formatter": {
@@ -33,4 +38,3 @@
}
}
}
+13
View File
@@ -0,0 +1,13 @@
services:
chess-scribe-frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
env_file:
- .env
restart: unless-stopped
+3 -6
View File
@@ -9,8 +9,11 @@
"dev": "vite dev --port 3000 --host",
"build": "vite build",
"preview": "vite preview",
"start": "node .output/server/index.mjs",
"test": "vitest run",
"lint:fix": "biome lint --write",
"format": "biome format",
"format:fix": "biome format --write",
"lint": "biome lint",
"check": "biome check"
},
@@ -48,11 +51,5 @@
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}
+13 -5
View File
@@ -315,6 +315,9 @@ packages:
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -1903,8 +1906,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.1:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
@@ -2489,6 +2492,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
@@ -2583,7 +2591,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
'@emnapi/runtime': 1.11.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -3912,7 +3920,7 @@ snapshots:
semver@6.3.1: {}
semver@7.8.1:
semver@7.8.5:
optional: true
seroval-plugins@1.5.4(seroval@1.5.4):
@@ -3925,7 +3933,7 @@ snapshots:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.1
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
+9
View File
@@ -0,0 +1,9 @@
# pnpm v11 configuration file
packages:
- "."
# This replaces 'onlyBuiltDependencies'
allowBuilds:
"sharp": true
"esbuild": true
"lightningcss": true
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+1 -4
View File
@@ -9,10 +9,7 @@ interface IProps {
const AccordionItem = ({ title, id, children }: IProps) => (
<div className="hero__accordion-item">
<details className="hero__accordion-details" name="linked">
<summary
className="hero__accordion-item__summary"
aria-describedby={title}
>
<summary className="hero__accordion-item__summary" id={`${id}-summary`}>
<span className="hero__accordion-item__title-container">
<span className="hero__accordion-item__title">{title}</span>
</span>
+1
View File
@@ -13,6 +13,7 @@ const CoffeeWidget = ({ height, width }: IProps) => {
target="_blank"
className="ml-auto flex items-center gap-2 text-sm"
rel="noopener"
aria-label="Buy Me A Coffee"
>
<Image
src={buyMeACoffeeLogo}
+1 -1
View File
@@ -7,7 +7,7 @@ interface IProps {
const Feature = ({ title, children }: IProps) => (
<div className="mb-auto">
<h6 className="text-(--neutral-content) text-2xl font-bold">{title}</h6>
<h5 className="text-(--neutral-content) text-2xl font-bold">{title}</h5>
<p className="text-(--neutral-content) font-medium" data-testid="text">
{children}
</p>
+6 -5
View File
@@ -10,26 +10,26 @@ import footerLogo from "@/assets/images/footerLogo.svg?url";
export default function Footer() {
return (
<footer className="text-white w-full grid-cols-2 items-center bg-(--accent) px-10 py-5">
<div className="flex w-full items-center">
<Link to="/">
<div className="flex w-full items-center gap-2">
<Link to="/" aria-label="Home">
<Image
src={footerLogo}
alt="ChessScribe Logo"
width={10}
height={10}
className="hidden h-10 w-10 sm:block"
aria-label="ChessScribe Logo"
/>
</Link>
<div>
<p data-testid="copyright">
Copyright © 2023 - {new Date().getFullYear()}
</p>
<p>© 2023 - {new Date().getFullYear()}</p>
</div>
<div className="ml-auto flex gap-2">
<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 />
@@ -38,6 +38,7 @@ export default function Footer() {
<Link
to="/contact"
rel="noopener noreferrer"
aria-label="Contact"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out"
>
<LuMail />
+16 -1
View File
@@ -1,11 +1,26 @@
import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react";
import { z } from "zod";
import MaintenanceModeBanner from "#/components/MaintenanceModeBanner.tsx";
import { env } from "#/env.ts";
import logo from "@/assets/images/logo.svg?url";
export default function Header() {
const maintenanceModeMessage = env.VITE_MAINTENANCE_MODE
? z
.string()
.transform((value) => (value.trim() === "" ? null : value))
.parse(env.VITE_MAINTENANCE_MODE_MESSAGE)
: null;
return (
<header>
{maintenanceModeMessage ? (
<div className="absolute w-full">
<MaintenanceModeBanner message={maintenanceModeMessage} />
</div>
) : null}
<nav className="w-full max-w-screen-2xl place-self-center p-8">
<div className="container mx-auto flex items-center justify-between">
<div className="flex items-center">
+2 -4
View File
@@ -6,7 +6,7 @@ const LichessButton = () => {
const { user } = useLichessUser();
const onClickHandler = () => {
return user?.isLoggedIn ? lichessLogout() : lichessLogin();
return user ? lichessLogout() : lichessLogin();
};
return (
@@ -26,9 +26,7 @@ const LichessButton = () => {
</svg>
</div>
<p>
{user?.isLoggedIn ? `Logout ${user.username}` : "Log into Lichess.org"}
</p>
<p>{user ? `Logout ${user.username}` : "Log into Lichess.org"}</p>
</button>
);
};
+61
View File
@@ -0,0 +1,61 @@
import type { FormEvent } from "react";
import { toast } from "react-toastify";
import { useLichess } from "#/hooks/useLichess.ts";
interface IProps {
setSelectedStudyId: (id: string) => void;
}
const LichessStudyLinkInput = ({ setSelectedStudyId }: IProps) => {
const { parseLichessStudyLink } = useLichess();
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const studyLink = formData.get("lichess-study-link");
if (!studyLink) return;
if (typeof studyLink !== "string") {
toast.error("Invalid study link");
return;
}
const result = parseLichessStudyLink(studyLink);
if (!result.ok) {
toast.error(result.error);
return;
}
console.log(result.data);
setSelectedStudyId(result.data);
toast.success("Study loaded — pick a chapter");
};
return (
<form className="flex items-end gap-2" onSubmit={handleSubmit}>
<div>
<label htmlFor="lichess-study-link" className="text-sm capitalize">
Lichess Study Link
</label>
<input
className="border rounded-md p-2 w-full"
type="text"
id="lichess-study-link"
name="lichess-study-link"
placeholder="https://lichess.org/study/..."
/>
</div>
<button
type="submit"
className="border py-2 px-3 rounded-md cursor-pointer bg-(--accent) text-white hover:bg-transparent hover:text-(--accent) transition-colors duration-300 ease-in-out"
>
Submit
</button>
</form>
);
};
export default LichessStudyLinkInput;
+13
View File
@@ -0,0 +1,13 @@
interface IProps {
message?: string;
}
const DEFAULT_MESSAGE = "Maintenance Mode";
const MaintenanceModeBanner = ({ message }: IProps) => (
<div className="bg-orange-400 text-white text-center py-1 w-full text-sm">
<p>{message || DEFAULT_MESSAGE}</p>
</div>
);
export default MaintenanceModeBanner;
+3 -2
View File
@@ -1,9 +1,10 @@
import { trackAppRouter } from "@socialgouv/matomo-next";
import { useLocation, useSearch } from "@tanstack/react-router";
import { useEffect } from "react";
import { env } from "#/env.ts";
const MATOMO_URL = import.meta.env.NEXT_PUBLIC_MATOMO_URL;
const MATOMO_SITE_ID = import.meta.env.NEXT_PUBLIC_MATOMO_SITE_ID;
const MATOMO_URL = env.VITE_MATOMO_URL;
const MATOMO_SITE_ID = env.VITE_MATOMO_SITE_ID;
export function MatomoAnalytics() {
const location = useLocation();
+21
View File
@@ -0,0 +1,21 @@
import { Link } from "@tanstack/react-router";
const NotFoundView = () => {
return (
<div className="flex min-h-[calc(100vh-194px)] flex-col items-center justify-center p-6">
<div className="relative flex max-w-md flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight mb-4 text-(--accent)">
The page you are looking for does not exist.
</h1>
<div className="flex w-full gap-3 justify-center">
<Link to="/" className="btn btn-primary">
Go Home
</Link>
</div>
</div>
</div>
);
};
export default NotFoundView;
+1 -1
View File
@@ -14,7 +14,7 @@ const Section = ({ title, smallTitle, description, children }: IProps) => {
return (
<section className="mx-auto mt-8 grid max-w-5xl place-items-center gap-4 p-8 text-center md:grid-cols-3 md:text-left text-(--accent)">
<h3 className="col-span-full mb-4 text-center text-lg font-bold tracking-wider text-primary">
<h3 className="col-span-full mb-4 text-center text-2xl font-bold tracking-wider text-primary">
{smallTitle}
</h3>
<h4 className="col-span-full mb-4 text-center text-4xl font-extrabold text-(--neutral-content) md:text-5xl">
+10 -5
View File
@@ -1,16 +1,16 @@
import { useRef, useState } from "react";
import { toast } from "react-toastify";
import { useClickOutside } from "#/hooks/useClickOutside.ts";
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
import { useLichess } from "#/hooks/useLichess.ts";
interface IProps {
setSelectedStudyId: (studyId: string) => void;
setStudyChapters: (chapters: IUserStudyChapter[]) => void;
}
const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const { getLichessUserStudies, userStudies } = useLichess();
const { getLichessUserStudies, userStudies, setUserStudies } = useLichess();
const dropdownRef = useRef<HTMLDivElement>(null);
const filteredStudies = userStudies.filter((study) =>
@@ -20,15 +20,20 @@ const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
const onClickHandler = async () => {
setIsOpen(!isOpen);
if (!userStudies.length) {
await getLichessUserStudies();
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 });
+81 -21
View File
@@ -1,6 +1,14 @@
import { type Dispatch, useRef, useState } from "react";
import { useClickOutside } from "#/hooks/useClickOutside.ts";
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
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 {
@@ -8,26 +16,68 @@ interface IProps {
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 [studyChapters, setStudyChapters] = useState<IUserStudyChapter[]>([]);
const [search, setSearch] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
// const dropdownRef = useRef<HTMLDivElement>(null);
const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess();
const filteredChapters = studyChapters.filter((chapter) =>
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 });
// useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
const onClickHandler = async () => {
const studies = await getLichessStudyChapters({ studyId: selectedStudyId });
if (!studies) return;
setStudyChapters(studies);
setIsOpen(!isOpen);
};
useEffect(() => {
if (isPending || chapterState.status === "success") {
setIsOpen(true);
}
}, [isPending, chapterState.status]);
const handleChapterClick = async (chapter: IUserStudyChapter) => {
const { headers, pgn } = await loadLichessStudyChapter({ chapter });
@@ -36,16 +86,22 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
setIsOpen(false);
setSearch("");
toast.success(`Loaded chapter "${chapter.name}"`);
};
return (
<div ref={dropdownRef} className="relative">
<button
type="button"
className="btn btn-secondary"
onClick={onClickHandler}
<form
key={selectedStudyId}
action={fetchChaptersAction}
className="relative"
>
Import Study Chapter/Game
<input type="hidden" name="studyId" value={selectedStudyId} />
<button
type="submit"
className="btn btn-secondary"
disabled={!selectedStudyId || isPending}
>
{isPending ? "Loading chapters…" : "Import Chapter"}
</button>
{isOpen ? (
@@ -79,7 +135,11 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
<hr className="border-(--neutral-content)/15 my-2" />
{filteredChapters.length > 0 ? (
{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}`}>
@@ -100,7 +160,7 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
)}
</div>
) : null}
</div>
</form>
);
};
+2 -7
View File
@@ -9,14 +9,10 @@ import {
useEffect,
useState,
} from "react";
import type { ISession } from "#/interfaces.ts";
import { getSession } from "#/server/lichess.ts";
interface ILichessUser {
username: string;
id: string;
isLoggedIn: boolean;
}
interface ILichessUser extends Omit<ISession, "token"> {}
interface ILichessUserContextType {
user: ILichessUser | null;
@@ -38,7 +34,6 @@ export const LichessUserProvider = ({ children }: { children: ReactNode }) => {
setUser({
username: session.username,
id: session.id,
isLoggedIn: true,
});
}
})();
+7 -1
View File
@@ -1,7 +1,11 @@
import { z } from "zod";
const clientSchema = z.object({
VITE_API_BASE_URL: z.string("Invalid API Base URL format"),
VITE_API_BASE_URL: z.url("Invalid API Base URL format"),
VITE_MATOMO_URL: z.url("Invalid URL format"),
VITE_MATOMO_SITE_ID: z.string("Invalid Site ID"),
VITE_MAINTENANCE_MODE: z.string("Invalid Maintenance Mode"),
VITE_MAINTENANCE_MODE_MESSAGE: z.string().optional(),
});
const serverSchema = z.object({
@@ -23,6 +27,7 @@ const getEnv = () => {
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;
@@ -30,6 +35,7 @@ const getEnv = () => {
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>;
+5 -1
View File
@@ -1,3 +1,4 @@
import { useServerFn } from "@tanstack/react-start";
import {
type ChangeEvent,
useCallback,
@@ -9,6 +10,7 @@ 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";
@@ -16,6 +18,8 @@ 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: "",
@@ -66,7 +70,7 @@ export const useChessGame = () => {
if (response.ok) {
downloadPDF(await response.blob());
// TODO add metrics logger - add success to analytics
await recordPdfSuccessFn();
toast.success("PDF successfully generated!", {
toastId: "pdf-success",
+61 -34
View File
@@ -1,6 +1,11 @@
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,
@@ -15,32 +20,35 @@ import { getHeaders } from "#/utils/pgnUtils.ts";
type Status = "loading" | "success" | "error";
interface ITokenData {
access_token: string;
expires_in: number;
token_type: string;
}
export type LichessResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
export type IUserStudy = {
id: string;
name: string;
createdAt: number;
updatedAt: number;
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 interface IUserStudyChapter {
chapterId: string;
name: string;
pgn: string;
}
export const useLichess = () => {
const { user, setUser } = useLichessUser();
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
const [studyChapters, setStudyChapters] = useState<
IUserStudyChapter[] | null
>(null);
const loginFn = useServerFn(login);
const logoutFn = useServerFn(logout);
@@ -65,7 +73,7 @@ export const useLichess = () => {
token: string;
}) => {
await setSessionFn({ data: { username, id, token } });
setUser({ username, id, isLoggedIn: true });
setUser({ username, id });
};
const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => {
@@ -122,20 +130,23 @@ export const useLichess = () => {
return { status, error };
};
const getLichessUserStudies = async () => {
if (!user) throw new Error("User not found");
const studies: IUserStudy[] | undefined = await getUserStudiesFn();
if (!studies) return;
setUserStudies(studies);
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 }) => {
if (!user) throw new Error("User not found");
const chapters: IUserStudyChapter[] | undefined = await getStudyChaptersFn({
data: { studyId },
});
if (!chapters) return;
return chapters;
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
@@ -158,6 +169,23 @@ export const useLichess = () => {
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,
@@ -172,8 +200,7 @@ export const useLichess = () => {
setSelectedStudyId,
selectedStudyId,
getLichessStudyChapters,
studyChapters,
setStudyChapters,
loadLichessStudyChapter,
parseLichessStudyLink,
};
};
+19
View File
@@ -1,3 +1,22 @@
import type { infer } from "zod";
import type { SessionSchema, TokenResponseSchema } from "#/schemas.ts";
export interface ISession extends infer<typeof SessionSchema> {}
export interface ITokenData extends infer<typeof TokenResponseSchema> {}
export interface IUserStudy {
id: string;
name: string;
createdAt: number;
updatedAt: number;
}
export interface IUserStudyChapter {
chapterId: string;
name: string;
pgn: string;
}
export interface IPosition {
ply: number;
fen: string;
+10 -7
View File
@@ -2,6 +2,7 @@ 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";
@@ -38,8 +39,7 @@ const Chessboard = () => {
gameDispatch,
} = useChessGame();
const { selectedStudyId, setSelectedStudyId, setStudyChapters } =
useLichess();
const { selectedStudyId, setSelectedStudyId } = useLichess();
const { user } = useLichessUser();
@@ -55,18 +55,21 @@ const Chessboard = () => {
onChange={handleLoadPgn}
/>
{user ? (
<SelectLichessStudy
setSelectedStudyId={setSelectedStudyId}
setStudyChapters={setStudyChapters}
/>
<SelectLichessStudy setSelectedStudyId={setSelectedStudyId} />
) : null}
<div />
{selectedStudyId && user ? (
{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">
+7 -7
View File
@@ -1,19 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
})
});
return router
return router;
}
declare module '@tanstack/react-router' {
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>
router: ReturnType<typeof getRouter>;
}
}
+16
View File
@@ -6,6 +6,7 @@ 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";
@@ -41,12 +42,27 @@ export const Route = createRootRoute({
{
rel: "icon",
href: "/favicon.svg",
type: "image/svg+xml",
},
{
rel: "icon",
href: "/favicon.ico",
sizes: "32x32",
},
{
rel: "apple-touch-icon",
href: "/apple-touch-icon.png",
},
{
rel: "manifest",
href: "/site.webmanifest",
},
],
}),
shellComponent: RootDocument,
errorComponent: ({ error }) => <GenericErrorView error={error} />,
pendingComponent: () => <LoadingView />,
notFoundComponent: () => <NotFoundView />,
});
function RootDocument({ children }: { children: ReactNode }) {
+4
View File
@@ -8,6 +8,10 @@ const callbackSchema = z.object({
});
export const Route = createFileRoute("/callback")({
head: () => ({
meta: [{ name: "robots", content: "noindex, nofollow" }],
}),
validateSearch: (search) => callbackSchema.parse(search),
beforeLoad: ({ search }) => {
+19
View File
@@ -0,0 +1,19 @@
import { z } from "zod";
export const SessionSchema = z.object({
username: z.string(),
id: z.string(),
token: z.string(),
});
export const TokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
scope: z.string().optional(),
token_type: z.string(),
});
export const LichessAccountSchema = z.object({
id: z.string(),
username: z.string(),
});
+24 -25
View File
@@ -6,8 +6,14 @@ import {
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));
@@ -22,11 +28,8 @@ export const getSession = createServerFn({ method: "GET" }).handler(
const session = getCookie("lichess-session");
if (!session) return null;
try {
return JSON.parse(session) as {
username: string;
id: string;
token: string;
};
const parsed = SessionSchema.safeParse(JSON.parse(session));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
@@ -34,25 +37,15 @@ export const getSession = createServerFn({ method: "GET" }).handler(
);
export const setSession = createServerFn({ method: "POST" })
.inputValidator(
(data: { username: string; id: string; token: string }) => data,
)
.inputValidator(SessionSchema)
.handler(async ({ data }) => {
setCookie(
"lichess-session",
JSON.stringify({
username: data.username,
id: data.id,
token: data.token,
}),
{
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: "/",
@@ -66,12 +59,13 @@ export const setSession = createServerFn({ method: "POST" })
});
export const getToken = createServerFn({ method: "POST" })
.inputValidator((data: { code: string }) => data)
.inputValidator(z.object({ code: z.string() }))
.handler(async ({ data }) => {
const verifier = getCookie("lichess-oauth-verifier");
if (!verifier)
if (!verifier) {
throw new Error("OAuth session expired. Please login again.");
}
const response = await fetch("https://lichess.org/api/token", {
method: "POST",
@@ -87,14 +81,16 @@ export const getToken = createServerFn({ method: "POST" })
}),
});
if (!response.ok)
if (!response.ok) {
throw new Error(`Token exchange failed: ${await response.text()}`);
}
return response.json();
const json = await response.json();
return TokenResponseSchema.parse(json);
});
export const getUser = createServerFn({ method: "GET" })
.inputValidator((data: { access_token: string }) => data)
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: {
@@ -102,7 +98,10 @@ export const getUser = createServerFn({ method: "GET" })
},
});
return response.json();
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 () => {
+41
View File
@@ -0,0 +1,41 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { createServerFn } from "@tanstack/react-start";
const METRICS_DIR = join(process.cwd(), "data");
const LOG_FILE = join(METRICS_DIR, "pdf-events.log");
const COUNT_FILE = join(METRICS_DIR, "pdf-count.txt");
const readCount = async (): Promise<number> => {
try {
const n = Number.parseInt(
(await fs.readFile(COUNT_FILE, "utf-8")).trim(),
10,
);
return Number.isFinite(n) ? n : 0;
} catch {
return 0;
}
};
const writeCountAtomic = async (count: number) => {
const tmp = `${COUNT_FILE}.tmp`;
await fs.writeFile(tmp, String(count), "utf-8");
await fs.rename(tmp, COUNT_FILE);
};
export const recordPdfSuccess = createServerFn({ method: "POST" }).handler(
async () => {
try {
await fs.mkdir(METRICS_DIR, { recursive: true });
const next = (await readCount()) + 1;
await Promise.all([
fs.appendFile(LOG_FILE, `${new Date().toISOString()}\n`, "utf-8"),
writeCountAtomic(next),
]);
return { count: next };
} catch (err) {
console.error("Failed to record PDF success:", err);
}
},
);
+13 -13
View File
@@ -17,6 +17,19 @@
border-bottom: 1px solid var(--neutral-content);
}
.hero__accordion-item__content {
padding: 0 10px;
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 0.3s ease,
padding 0.3s ease;
@media (prefers-reduced-motion) {
transition: none;
}
}
.hero__accordion-details[open] + .hero__accordion-item__content {
grid-template-rows: 1fr;
padding: 10px;
@@ -53,19 +66,6 @@
font-weight: 600;
}
.hero__accordion-item__content {
padding: 0 10px;
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 0.3s ease,
padding 0.3s ease;
@media (prefers-reduced-motion) {
transition: none;
}
}
.hero__accordion-item__content__inner {
color: var(--neutral-content);
overflow: hidden;
File diff suppressed because one or more lines are too long
+11
View File
@@ -19,6 +19,17 @@ const config = defineConfig({
enabled: true,
crawlLinks: true,
},
pages: [
{
path: "/callback",
prerender: {
enabled: false,
},
sitemap: {
exclude: true,
},
},
],
sitemap: {
enabled: true,
host: HOST_URL,