mirror of
https://github.com/TheRealOwenRees/chess-scribe-website.git
synced 2026-07-23 01:46:57 +00:00
Compare commits
17 Commits
f475732715
...
2e05f86d5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
2e05f86d5f
|
|||
|
4577755c93
|
|||
|
a110c7490c
|
|||
|
0b10637df4
|
|||
|
41cbd5db16
|
|||
|
af6a8426ad
|
|||
|
0dde848cfe
|
|||
|
ee9968e99b
|
|||
|
7384a3f825
|
|||
|
887a43636f
|
|||
|
734c631356
|
|||
|
802d46191e
|
|||
|
4edcb49f58
|
|||
|
030d44226d
|
|||
|
f12176e713
|
|||
|
3da81680a1
|
|||
|
0b32e77d7e
|
+4
-1
@@ -1,4 +1,7 @@
|
||||
VITE_API_BASE_URL=
|
||||
VITE_MATOMO_URL=
|
||||
VITE_MATOMO_SITE_ID=
|
||||
|
||||
DISCORD_CONTACT_WEBHOOK=
|
||||
DISCORD_ERROR_LOG_WEBHOOK=
|
||||
LICHESS_CLIENT_ID=
|
||||
VITE_API_BASE_URL=
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
- 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/
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
const NotFoundView = () => {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-194px)] flex-col items-center justify-center p-6">
|
||||
<div className="relative flex max-w-md flex-col items-center text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight mb-4 text-(--accent)">
|
||||
The page you are looking for does not exist.
|
||||
</h1>
|
||||
|
||||
<div className="flex w-full gap-3 justify-center">
|
||||
<Link to="/" className="btn btn-primary">
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundView;
|
||||
@@ -14,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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from "zod";
|
||||
|
||||
const clientSchema = z.object({
|
||||
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({
|
||||
|
||||
+6
-20
@@ -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,25 +20,6 @@ import { getHeaders } from "#/utils/pgnUtils.ts";
|
||||
|
||||
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 = () => {
|
||||
const { user, setUser } = useLichessUser();
|
||||
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
|
||||
@@ -65,7 +51,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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -47,6 +48,7 @@ export const Route = createRootRoute({
|
||||
shellComponent: RootDocument,
|
||||
errorComponent: ({ error }) => <GenericErrorView error={error} />,
|
||||
pendingComponent: () => <LoadingView />,
|
||||
notFoundComponent: () => <NotFoundView />,
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -19,6 +19,14 @@ const config = defineConfig({
|
||||
enabled: true,
|
||||
crawlLinks: true,
|
||||
},
|
||||
pages: [
|
||||
{
|
||||
path: "/callback",
|
||||
prerender: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
sitemap: {
|
||||
enabled: true,
|
||||
host: HOST_URL,
|
||||
|
||||
Reference in New Issue
Block a user