Compare commits

..

17 Commits

Author SHA1 Message Date
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
18 changed files with 131 additions and 83 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
VITE_API_BASE_URL=
VITE_MATOMO_URL=
VITE_MATOMO_SITE_ID=
DISCORD_CONTACT_WEBHOOK= DISCORD_CONTACT_WEBHOOK=
DISCORD_ERROR_LOG_WEBHOOK= DISCORD_ERROR_LOG_WEBHOOK=
LICHESS_CLIENT_ID= LICHESS_CLIENT_ID=
VITE_API_BASE_URL=
+3 -7
View File
@@ -1,15 +1,11 @@
- og / json-sd - og / json-sd
- logger - 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) - set game current position on game load to ply 0 and whatever the FEN is (starting position probably, but could be custom position)
- privacy policy - have no access to anything you generate, only keep a count of success/failed pdf generations with timestamp and error message - privacy policy - have no access to anything you generate, only keep a count of success/failed pdf generations with timestamp and error message
- a11y and lighthouse fixes - check wcag compliance - footer colour contrast
- check wcag compliance - 429 lichess error handling - retry after 10 seconds
- 429 lichess error handling
- add throw errors or return errors in server code
- try/catch where needed in client code - 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 - generate state and check it against the state returned from lichess https://lichess.org/api#tag/oauth/GET/oauth
- checkbox and toggle disabled states - checkbox and toggle disabled states
- 404 page
- lichess data with zod validation
- react query on for study and chapter load, to allow caching - react query on for study and chapter load, to allow caching
- https://specification.website/checklist/
+1 -4
View File
@@ -9,10 +9,7 @@ interface IProps {
const AccordionItem = ({ title, id, children }: IProps) => ( const AccordionItem = ({ title, id, children }: IProps) => (
<div className="hero__accordion-item"> <div className="hero__accordion-item">
<details className="hero__accordion-details" name="linked"> <details className="hero__accordion-details" name="linked">
<summary <summary className="hero__accordion-item__summary" id={`${id}-summary`}>
className="hero__accordion-item__summary"
aria-describedby={title}
>
<span className="hero__accordion-item__title-container"> <span className="hero__accordion-item__title-container">
<span className="hero__accordion-item__title">{title}</span> <span className="hero__accordion-item__title">{title}</span>
</span> </span>
+1
View File
@@ -13,6 +13,7 @@ const CoffeeWidget = ({ height, width }: IProps) => {
target="_blank" target="_blank"
className="ml-auto flex items-center gap-2 text-sm" className="ml-auto flex items-center gap-2 text-sm"
rel="noopener" rel="noopener"
aria-label="Buy Me A Coffee"
> >
<Image <Image
src={buyMeACoffeeLogo} src={buyMeACoffeeLogo}
+1 -1
View File
@@ -7,7 +7,7 @@ interface IProps {
const Feature = ({ title, children }: IProps) => ( const Feature = ({ title, children }: IProps) => (
<div className="mb-auto"> <div className="mb-auto">
<h6 className="text-(--neutral-content) text-2xl font-bold">{title}</h6> <h5 className="text-(--neutral-content) text-2xl font-bold">{title}</h5>
<p className="text-(--neutral-content) font-medium" data-testid="text"> <p className="text-(--neutral-content) font-medium" data-testid="text">
{children} {children}
</p> </p>
+6 -5
View File
@@ -10,26 +10,26 @@ import footerLogo from "@/assets/images/footerLogo.svg?url";
export default function Footer() { export default function Footer() {
return ( return (
<footer className="text-white w-full grid-cols-2 items-center bg-(--accent) px-10 py-5"> <footer className="text-white w-full grid-cols-2 items-center bg-(--accent) px-10 py-5">
<div className="flex w-full items-center"> <div className="flex w-full items-center gap-2">
<Link to="/"> <Link to="/" aria-label="Home">
<Image <Image
src={footerLogo} src={footerLogo}
alt="ChessScribe Logo" alt="ChessScribe Logo"
width={10} width={10}
height={10} height={10}
className="hidden h-10 w-10 sm:block" className="hidden h-10 w-10 sm:block"
aria-label="ChessScribe Logo"
/> />
</Link> </Link>
<div> <div>
<p data-testid="copyright"> <p>© 2023 - {new Date().getFullYear()}</p>
Copyright © 2023 - {new Date().getFullYear()}
</p>
</div> </div>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<a <a
href={GITHUB_URL} href={GITHUB_URL}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="GitHub"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out cursor-pointer" className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out cursor-pointer"
> >
<LuGithub /> <LuGithub />
@@ -38,6 +38,7 @@ export default function Footer() {
<Link <Link
to="/contact" to="/contact"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="Contact"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out" className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out"
> >
<LuMail /> <LuMail />
+2 -4
View File
@@ -6,7 +6,7 @@ const LichessButton = () => {
const { user } = useLichessUser(); const { user } = useLichessUser();
const onClickHandler = () => { const onClickHandler = () => {
return user?.isLoggedIn ? lichessLogout() : lichessLogin(); return user ? lichessLogout() : lichessLogin();
}; };
return ( return (
@@ -26,9 +26,7 @@ const LichessButton = () => {
</svg> </svg>
</div> </div>
<p> <p>{user ? `Logout ${user.username}` : "Log into Lichess.org"}</p>
{user?.isLoggedIn ? `Logout ${user.username}` : "Log into Lichess.org"}
</p>
</button> </button>
); );
}; };
+3 -2
View File
@@ -1,9 +1,10 @@
import { trackAppRouter } from "@socialgouv/matomo-next"; import { trackAppRouter } from "@socialgouv/matomo-next";
import { useLocation, useSearch } from "@tanstack/react-router"; import { useLocation, useSearch } from "@tanstack/react-router";
import { useEffect } from "react"; import { useEffect } from "react";
import { env } from "#/env.ts";
const MATOMO_URL = import.meta.env.NEXT_PUBLIC_MATOMO_URL; const MATOMO_URL = env.VITE_MATOMO_URL;
const MATOMO_SITE_ID = import.meta.env.NEXT_PUBLIC_MATOMO_SITE_ID; const MATOMO_SITE_ID = env.VITE_MATOMO_SITE_ID;
export function MatomoAnalytics() { export function MatomoAnalytics() {
const location = useLocation(); const location = useLocation();
+21
View File
@@ -0,0 +1,21 @@
import { Link } from "@tanstack/react-router";
const NotFoundView = () => {
return (
<div className="flex min-h-[calc(100vh-194px)] flex-col items-center justify-center p-6">
<div className="relative flex max-w-md flex-col items-center text-center">
<h1 className="text-2xl font-bold tracking-tight mb-4 text-(--accent)">
The page you are looking for does not exist.
</h1>
<div className="flex w-full gap-3 justify-center">
<Link to="/" className="btn btn-primary">
Go Home
</Link>
</div>
</div>
</div>
);
};
export default NotFoundView;
+1 -1
View File
@@ -14,7 +14,7 @@ const Section = ({ title, smallTitle, description, children }: IProps) => {
return ( return (
<section className="mx-auto mt-8 grid max-w-5xl place-items-center gap-4 p-8 text-center md:grid-cols-3 md:text-left text-(--accent)"> <section className="mx-auto mt-8 grid max-w-5xl place-items-center gap-4 p-8 text-center md:grid-cols-3 md:text-left text-(--accent)">
<h3 className="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} {smallTitle}
</h3> </h3>
<h4 className="col-span-full mb-4 text-center text-4xl font-extrabold text-(--neutral-content) md:text-5xl"> <h4 className="col-span-full mb-4 text-center text-4xl font-extrabold text-(--neutral-content) md:text-5xl">
+2 -7
View File
@@ -9,14 +9,10 @@ import {
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import type { ISession } from "#/interfaces.ts";
import { getSession } from "#/server/lichess.ts"; import { getSession } from "#/server/lichess.ts";
interface ILichessUser { interface ILichessUser extends Omit<ISession, "token"> {}
username: string;
id: string;
isLoggedIn: boolean;
}
interface ILichessUserContextType { interface ILichessUserContextType {
user: ILichessUser | null; user: ILichessUser | null;
@@ -38,7 +34,6 @@ export const LichessUserProvider = ({ children }: { children: ReactNode }) => {
setUser({ setUser({
username: session.username, username: session.username,
id: session.id, id: session.id,
isLoggedIn: true,
}); });
} }
})(); })();
+2
View File
@@ -2,6 +2,8 @@ import { z } from "zod";
const clientSchema = z.object({ const clientSchema = z.object({
VITE_API_BASE_URL: z.string("Invalid API Base URL format"), VITE_API_BASE_URL: z.string("Invalid API Base URL format"),
VITE_MATOMO_URL: z.string("Invalid URL format"),
VITE_MATOMO_SITE_ID: z.string("Invalid Site ID"),
}); });
const serverSchema = z.object({ const serverSchema = z.object({
+6 -20
View File
@@ -1,6 +1,11 @@
import { useServerFn } from "@tanstack/react-start"; import { useServerFn } from "@tanstack/react-start";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLichessUser } from "#/context/LichessUserContext.tsx"; import { useLichessUser } from "#/context/LichessUserContext.tsx";
import type {
ITokenData,
IUserStudy,
IUserStudyChapter,
} from "#/interfaces.ts";
import { import {
getSession, getSession,
getStudyChapters, getStudyChapters,
@@ -15,25 +20,6 @@ import { getHeaders } from "#/utils/pgnUtils.ts";
type Status = "loading" | "success" | "error"; type Status = "loading" | "success" | "error";
interface ITokenData {
access_token: string;
expires_in: number;
token_type: string;
}
export type IUserStudy = {
id: string;
name: string;
createdAt: number;
updatedAt: number;
};
export interface IUserStudyChapter {
chapterId: string;
name: string;
pgn: string;
}
export const useLichess = () => { export const useLichess = () => {
const { user, setUser } = useLichessUser(); const { user, setUser } = useLichessUser();
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]); const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
@@ -65,7 +51,7 @@ export const useLichess = () => {
token: string; token: string;
}) => { }) => {
await setSessionFn({ data: { username, id, token } }); await setSessionFn({ data: { username, id, token } });
setUser({ username, id, isLoggedIn: true }); setUser({ username, id });
}; };
const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => { const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => {
+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 { export interface IPosition {
ply: number; ply: number;
fen: string; fen: string;
+2
View File
@@ -6,6 +6,7 @@ import { ToastContainer } from "react-toastify";
import GenericErrorView from "#/components/GenericErrorView.tsx"; import GenericErrorView from "#/components/GenericErrorView.tsx";
import LoadingView from "#/components/LoadingView.tsx"; import LoadingView from "#/components/LoadingView.tsx";
import { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx"; import { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx";
import NotFoundView from "#/components/NotFoundView.tsx";
import { LichessUserProvider } from "#/context/LichessUserContext.tsx"; import { LichessUserProvider } from "#/context/LichessUserContext.tsx";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Header from "../components/Header"; import Header from "../components/Header";
@@ -47,6 +48,7 @@ export const Route = createRootRoute({
shellComponent: RootDocument, shellComponent: RootDocument,
errorComponent: ({ error }) => <GenericErrorView error={error} />, errorComponent: ({ error }) => <GenericErrorView error={error} />,
pendingComponent: () => <LoadingView />, pendingComponent: () => <LoadingView />,
notFoundComponent: () => <NotFoundView />,
}); });
function RootDocument({ children }: { children: ReactNode }) { function RootDocument({ children }: { children: ReactNode }) {
+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(),
});
+29 -30
View File
@@ -6,8 +6,14 @@ import {
getCookie, getCookie,
setCookie, setCookie,
} from "@tanstack/react-start/server"; } from "@tanstack/react-start/server";
import { z } from "zod";
import { env } from "#/env.ts"; import { env } from "#/env.ts";
import { base64UrlEncode, sha256 } from "#/lib/encodeDecode.ts"; import { base64UrlEncode, sha256 } from "#/lib/encodeDecode.ts";
import {
LichessAccountSchema,
SessionSchema,
TokenResponseSchema,
} from "#/schemas.ts";
const createVerifier = () => base64UrlEncode(randomBytes(32)); const createVerifier = () => base64UrlEncode(randomBytes(32));
const createChallenge = (verifier: string) => base64UrlEncode(sha256(verifier)); const createChallenge = (verifier: string) => base64UrlEncode(sha256(verifier));
@@ -22,11 +28,8 @@ export const getSession = createServerFn({ method: "GET" }).handler(
const session = getCookie("lichess-session"); const session = getCookie("lichess-session");
if (!session) return null; if (!session) return null;
try { try {
return JSON.parse(session) as { const parsed = SessionSchema.safeParse(JSON.parse(session));
username: string; return parsed.success ? parsed.data : null;
id: string;
token: string;
};
} catch { } catch {
return null; return null;
} }
@@ -34,25 +37,15 @@ export const getSession = createServerFn({ method: "GET" }).handler(
); );
export const setSession = createServerFn({ method: "POST" }) export const setSession = createServerFn({ method: "POST" })
.inputValidator( .inputValidator(SessionSchema)
(data: { username: string; id: string; token: string }) => data,
)
.handler(async ({ data }) => { .handler(async ({ data }) => {
setCookie( setCookie("lichess-session", JSON.stringify(data), {
"lichess-session", path: "/",
JSON.stringify({ httpOnly: true,
username: data.username, secure: process.env.NODE_ENV === "production",
id: data.id, sameSite: "lax",
token: data.token, maxAge: 60 * 60 * 24 * 7,
}), });
{
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
},
);
setCookie("lichess-oauth-verifier", "", { setCookie("lichess-oauth-verifier", "", {
path: "/", path: "/",
@@ -66,12 +59,13 @@ export const setSession = createServerFn({ method: "POST" })
}); });
export const getToken = createServerFn({ method: "POST" }) export const getToken = createServerFn({ method: "POST" })
.inputValidator((data: { code: string }) => data) .inputValidator(z.object({ code: z.string() }))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const verifier = getCookie("lichess-oauth-verifier"); const verifier = getCookie("lichess-oauth-verifier");
if (!verifier) if (!verifier) {
throw new Error("OAuth session expired. Please login again."); throw new Error("OAuth session expired. Please login again.");
}
const response = await fetch("https://lichess.org/api/token", { const response = await fetch("https://lichess.org/api/token", {
method: "POST", 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()}`); 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" }) export const getUser = createServerFn({ method: "POST" })
.inputValidator((data: { access_token: string }) => data) .inputValidator(z.object({ access_token: z.string() }))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const response = await fetch("https://lichess.org/api/account", { const response = await fetch("https://lichess.org/api/account", {
headers: { 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 () => { const deleteAccessToken = createServerFn().handler(async () => {
+8
View File
@@ -19,6 +19,14 @@ const config = defineConfig({
enabled: true, enabled: true,
crawlLinks: true, crawlLinks: true,
}, },
pages: [
{
path: "/callback",
prerender: {
enabled: false,
},
},
],
sitemap: { sitemap: {
enabled: true, enabled: true,
host: HOST_URL, host: HOST_URL,