Compare commits

...

8 Commits

21 changed files with 318 additions and 133 deletions
+6 -2
View File
@@ -1,6 +1,10 @@
- favicon and page titles - SEO related things
- rearrange buttons / header space - collapse on smaller screens
- og / json-sd
- Lichess login
- logger
- contact form
- bring sections headers on all pages other than home page further up (reduce top margin / padding)
- set game current position on game load to ply 0 and whatever the FEN is (starting position probably, but could be custom position)
- 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
- white background?
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

+44
View File
@@ -0,0 +1,44 @@
<?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>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

-25
View File
@@ -1,25 +0,0 @@
{
"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"
}
+3 -2
View File
@@ -1,3 +1,4 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Allow: /
Sitemap: https://chess-scribe.org/sitemap.xml
+2 -2
View File
@@ -9,7 +9,7 @@ interface IProps {
const CustomHeaders = ({ headers, updateHeaders }: IProps) => {
return (
<div className="mt-4 w-full">
<div className="mt-4 w-full max-w-175">
<details className="bg-(--neutral-content)/20 p-4 rounded-lg">
<summary>
Custom Headers (PDF)
@@ -19,7 +19,7 @@ const CustomHeaders = ({ headers, updateHeaders }: IProps) => {
</p>
</summary>
<div>
<form className="grid place-items-center gap-4 sm:grid-cols-2 mt-2">
<form className="flex flex-wrap mt-2 justify-center gap-4">
<FormField
fieldName="title"
type="text"
+2 -2
View File
@@ -9,14 +9,14 @@ interface IProps {
const HeaderFields = ({ headers, updateHeaders }: IProps) => {
return (
<div className="mt-4 w-full">
<div className="mt-4 w-full max-w-175">
<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="grid place-items-center gap-4 sm:grid-cols-2 mt-2">
<form className="flex flex-wrap mt-2 justify-center gap-4">
<FormField
fieldName="event"
type="text"
+3 -75
View File
@@ -1,5 +1,4 @@
import LichessPgnViewer from "lichess-pgn-viewer";
import { useEffect, useRef } from "react";
import { useLichessPgnViewer } from "#/hooks/useLichessPgnViewer.ts";
import type { IPosition } from "#/interfaces.ts";
interface IProps {
@@ -8,80 +7,9 @@ interface IProps {
}
const PgnViewer = ({ gamePgn, handlePlyChange }: IProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewerRef = useRef(null);
useLichessPgnViewer({ gamePgn, handlePlyChange });
// TODO make thin, and pull functions out
useEffect(() => {
const element: HTMLElement | null = document.querySelector(".lpv-board");
if (!element) return;
viewerRef.current = LichessPgnViewer(element, {
pgn: gamePgn,
scrollToMove: false,
});
// set initial position - TODO change to on file load
// handlePlyChange({
// ply: 0,
// fen: viewerRef.current?.curData().fen,
// });
const boardButtons = document.querySelectorAll(".lpv__controls");
const movesList = document.querySelectorAll(".lpv__moves");
if (!boardButtons || !movesList) return;
const clickHandlerDelay = () => {
setTimeout(() => handleClick(), 250);
};
// Hack using DOM manipulation
const boardEventListener = () => {
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 handleClick = () => {
const { ply } = boardEventListener();
const fen = viewerRef.current?.curData().fen;
handlePlyChange({ ply, fen });
};
boardButtons.forEach((button) => {
button.addEventListener("click", handleClick);
button.addEventListener("touchstart", handleClick);
});
movesList.forEach((move) => {
move.addEventListener("click", clickHandlerDelay);
move.addEventListener("touchstart", clickHandlerDelay);
});
return () => {
if (containerRef.current) {
containerRef.current = null;
}
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 <div ref={containerRef} className="lpv-board" />;
return <div className="lpv-board" />;
};
export default PgnViewer;
+2
View File
@@ -1,2 +1,4 @@
export const HOST_URL = "https://chess-scribe.org";
export const GITHUB_URL =
"https://github.com/therealowenrees/chess-scribe-website";
+80
View File
@@ -0,0 +1,80 @@
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;
};
+2 -2
View File
@@ -46,9 +46,9 @@ const Chessboard = () => {
/>
</Section>
<div className="grid grid-cols-2 gap-4">
<div className="grid lg:grid-cols-2 gap-4">
<Suspense fallback={<LoadingBoard />}>
<div>
<div className="mx-auto">
<PgnViewer
gamePgn={gameState.pgn || ""}
handlePlyChange={handlePlyChange}
-3
View File
@@ -1,3 +0,0 @@
const Contact = () => <div>Hello "/contact"!</div>;
export default Contact;
+25
View File
@@ -0,0 +1,25 @@
import Contact from "#/pages/Contact/Contact.tsx";
const ContactContainer = () => {
const handleSubmit = async (_prevState: unknown, data: FormData) => {
const name = data.get("name");
const email = data.get("email");
const subject = data.get("subject");
const message = data.get("message");
try {
return { success: true, message: "Message sent!" };
} 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;
+90
View File
@@ -0,0 +1,90 @@
import { useActionState } from "react";
import Section from "#/components/Section.tsx";
interface IFormField {
fieldName: string;
type: "text" | "email" | "textarea";
placeholder: string;
label: string;
required: boolean;
}
type IProps = {
handleSubmit: (
prevState: unknown,
formData: FormData,
) => Promise<{ success: boolean; message: string }>;
};
const FormField = ({
type,
fieldName,
placeholder,
label,
required,
}: IFormField) => (
<div className="flex flex-col gap-2">
<label htmlFor={fieldName}>{label}</label>
<input
type={type}
id={fieldName}
name={fieldName}
placeholder={placeholder}
required={required}
/>
</div>
);
const Contact = ({ handleSubmit }: IProps) => {
const [state, formAction, isPending] = useActionState(handleSubmit, null);
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}>
<FormField
type="text"
fieldName="name"
placeholder="Your name"
label="Name"
required
/>
<FormField
fieldName="email"
type="text"
placeholder="name@example.com"
label="Email"
required
/>
<input
type="text"
name="subject"
placeholder="Reason for contacting us"
/>
<textarea
name="message"
placeholder="Your message here..."
required
/>
<button
type="submit"
disabled={isPending}
className="btn btn-primary"
>
{isPending ? "Sending..." : "Send"}
</button>
{state && <p>{state.message}</p>}
</form>
</Section>
</main>
);
};
export default Contact;
+12
View File
@@ -21,12 +21,24 @@ export const Route = createRootRoute({
{
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",
},
],
}),
shellComponent: RootDocument,
+5
View File
@@ -1,7 +1,12 @@
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,
});
+7 -2
View File
@@ -1,10 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import Contact from "#/pages/Contact.tsx";
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 <Contact />;
return <ContactContainer />;
}
+8 -1
View File
@@ -1,7 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { HOST_URL } from "#/config.ts";
import Home from "#/pages";
export const Route = createFileRoute("/")({ component: App });
export const Route = createFileRoute("/")({
head: () => ({
meta: [{ title: "ChessScribe | Home" }],
links: [{ rel: "canonical", href: HOST_URL }],
}),
component: App,
});
function App() {
return <Home />;
+19 -9
View File
@@ -1,11 +1,12 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import tailwindcss from "@tailwindcss/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 tailwindcss from '@tailwindcss/vite'
import { nitro } from 'nitro/vite'
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
import { HOST_URL } from "#/config.ts";
const config = defineConfig({
resolve: { tsconfigPaths: true },
@@ -13,9 +14,18 @@ const config = defineConfig({
devtools(),
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
tailwindcss(),
tanstackStart(),
tanstackStart({
prerender: {
enabled: true,
crawlLinks: true,
},
sitemap: {
enabled: true,
host: HOST_URL,
},
}),
viteReact(),
],
})
});
export default config
export default config;