initial commit

This commit is contained in:
2026-06-01 22:27:42 +02:00
commit 2805bd74df
57 changed files with 6036 additions and 0 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 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="#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>

After

Width:  |  Height:  |  Size: 2.7 KiB

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

After

Width:  |  Height:  |  Size: 631 B

+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

+34
View File
@@ -0,0 +1,34 @@
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"
aria-describedby={title}
>
<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;
+28
View File
@@ -0,0 +1,28 @@
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"
>
<Image
src={buyMeACoffeeLogo}
alt="Buy Me A Coffee Logo"
width={width}
height={height}
/>
<p>Support This Project</p>
</a>
);
};
export default CoffeeWidget;
+17
View File
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
interface IProps {
title: string;
children?: ReactNode;
}
const Feature = ({ title, children }: IProps) => (
<div className="mb-auto">
<h6 className="text-(--base-content) text-2xl font-bold">{title}</h6>
<p className="text-(--neutral-content) font-medium" data-testid="text">
{children}
</p>
</div>
);
export default Feature;
+49
View File
@@ -0,0 +1,49 @@
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 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="/">
<Image
src={footerLogo}
alt="ChessScribe Logo"
width={10}
height={10}
className="hidden h-10 w-10 sm:block"
/>
</Link>
<div>
<p data-testid="copyright">
Copyright © 2023 - {new Date().getFullYear()}
</p>
</div>
<div className="ml-auto flex gap-2">
<a
href={import.meta.env.VITE_GITHUB_URL} // TODO ZOD
target="_blank"
rel="noopener noreferrer"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out cursor-pointer"
>
<LuGithub />
</a>
<Link
to="/contact"
rel="noopener noreferrer"
className="text-2xl hover:text-(--neutral-content) transition-colors duration-300 ease-in-out"
>
<LuMail />
</Link>
</div>
<CoffeeWidget width={20} height={50} />
</div>
</footer>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react";
import logo from "@/assets/images/logo.svg?url";
export default function Header() {
return (
<header>
<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>
<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>
);
}
+28
View File
@@ -0,0 +1,28 @@
interface IProps {
onClickHandler: () => void;
}
const LichessButton = ({ onClickHandler }: IProps) => {
return (
<button
className="text-(--accent) border rounded-xl px-6 py-3 cursor-pointer flex items-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>Log into Lichess.org</p>
</button>
);
};
export default LichessButton;
+33
View File
@@ -0,0 +1,33 @@
import { trackAppRouter } from "@socialgouv/matomo-next";
import { useLocation, useSearch } from "@tanstack/react-router";
import { useEffect } from "react";
const MATOMO_URL = import.meta.env.NEXT_PUBLIC_MATOMO_URL;
const MATOMO_SITE_ID = import.meta.env.NEXT_PUBLIC_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;
}
+32
View File
@@ -0,0 +1,32 @@
import LichessPgnViewer from "lichess-pgn-viewer";
import type PgnViewerType from "lichess-pgn-viewer/pgnViewer";
import { useEffect, useRef } from "react";
interface IProps {
gamePgn: string;
}
const PgnViewer = ({ gamePgn }: IProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewerRef = useRef<PgnViewerType | null>(null);
useEffect(() => {
const element: HTMLElement | null = document.querySelector(".lpv-board");
if (!element) return;
viewerRef.current = LichessPgnViewer(element, {
pgn: gamePgn,
scrollToMove: false,
});
return () => {
if (containerRef.current) {
containerRef.current = null;
}
};
}, [gamePgn]);
return <div ref={containerRef} className="lpv-board" />;
};
export default PgnViewer;
+31
View File
@@ -0,0 +1,31 @@
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="md:col-span-3 mb-4 text-center text-lg font-bold tracking-wider text-primary">
{smallTitle}
</h3>
<h4 className="md:col-span-3 mb-4 text-center text-4xl font-extrabold text-(--neutral-content) md:text-5xl">
{mainTitle} <span className="text-(--accent)">{lastWord}</span>
</h4>
<p className="md:col-span-3 mb-8 w-1/2 text-center font-semibold text-(--neutral-content) ">
{description}
</p>
{children}
</section>
);
};
export default Section;
+84
View File
@@ -0,0 +1,84 @@
import { type ChangeEvent, useReducer, useRef } from "react";
import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts";
import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
import { downloadString } from "#/utils/stringUtils.ts";
export const useChessGame = () => {
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleSavePgn = () => {
const pgnString = buildPgnString(gameState);
downloadString(pgnString, "game.pgn");
};
const handleSavePdf = () => {
console.log("Save PDF");
};
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: "",
};
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",
});
};
return {
gameState,
fileInputRef,
handleSavePgn,
handleSavePdf,
handleClearGame,
handleLoadPgn,
handleToggleClock,
};
};
+20
View File
@@ -0,0 +1,20 @@
import { useChessGame } from "#/hooks/useChessGame.ts";
import Chessboard from "#/pages/Chessboard.tsx";
const ChessboardContainer = () => {
const chessGameProps = useChessGame();
return (
<Chessboard
gameState={chessGameProps.gameState}
handleLoadPgn={chessGameProps.handleLoadPgn}
handleClearGame={chessGameProps.handleClearGame}
handleSavePgn={chessGameProps.handleSavePgn}
handleSavePdf={chessGameProps.handleSavePdf}
handleToggleClock={chessGameProps.handleToggleClock}
fileInputRef={chessGameProps.fileInputRef}
/>
);
};
export default ChessboardContainer;
+80
View File
@@ -0,0 +1,80 @@
import type { ChangeEvent, RefObject } from "react";
import LichessButton from "#/components/LichessButton.tsx";
import PgnViewer from "#/components/PgnViewer.tsx";
import Section from "#/components/Section.tsx";
import type { IGameState } from "#/reducers/gameReducer.ts";
interface IProps {
gameState: IGameState;
handleLoadPgn: (e: ChangeEvent<HTMLInputElement>) => void;
handleClearGame: () => void;
handleSavePgn: () => void;
handleSavePdf: () => void;
handleToggleClock: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
}
const Chessboard = ({
gameState,
handleLoadPgn,
handleClearGame,
handleSavePgn,
handleSavePdf,
handleToggleClock,
fileInputRef,
}: IProps) => {
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}
/>
</Section>
<div className="flex gap-4">
<button
className="cursor-pointer"
type="button"
onClick={handleClearGame}
>
Clear Game
</button>
<button
className="cursor-pointer"
type="button"
onClick={handleSavePgn}
disabled={!gameState.pgn}
>
Save PGN
</button>
<button
className="cursor-pointer"
type="button"
onClick={handleSavePdf}
disabled={!gameState.pgn}
>
Save PDF
</button>
</div>
<PgnViewer gamePgn={gameState.pgn || ""} />
<label htmlFor="diagram-clock">
Render Clock Times
<input
id="diagram-clock"
name="diagram-clock"
type="checkbox"
onChange={handleToggleClock}
/>
</label>
</main>
);
};
export default Chessboard;
+3
View File
@@ -0,0 +1,3 @@
const Contact = () => <div>Hello "/contact"!</div>;
export default Contact;
+96
View File
@@ -0,0 +1,96 @@
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="grid max-w-screen-2xl items-center gap-8 p-8 text-center md:grid-cols-2 md:text-left mx-auto"
>
<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="rounded-3xl text-xl font-bold btn btn-primary"
>
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>
</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;
+100
View File
@@ -0,0 +1,100 @@
interface IDiagrams {
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;
[key: string]: string;
}
export interface IGameState {
pgn: string;
headers: IHeader;
diagrams: IDiagrams[];
diagramClock: boolean;
}
type GameAction =
| { type: "SET_GAME"; payload: { pgn: string; headers: IHeader } }
| { type: "CLEAR_GAME" }
| { type: "SET_HEADERS"; payload: IHeader }
| { type: "ADD_DIAGRAM"; payload: IDiagrams }
| { 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: IDiagrams) => diagram.ply !== action.payload.ply,
),
};
case "TOGGLE_DIAGRAM_CLOCK":
return {
...state,
diagramClock: !state.diagramClock,
};
default:
return state;
}
};
+104
View File
@@ -0,0 +1,104 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// 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 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 IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/chessboard': typeof ChessboardRoute
'/contact': typeof ContactRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/chessboard' | '/contact'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/chessboard' | '/contact'
id: '__root__' | '/' | '/chessboard' | '/contact'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ChessboardRoute: typeof ChessboardRoute
ContactRoute: typeof ContactRoute
}
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
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ChessboardRoute: ChessboardRoute,
ContactRoute: ContactRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+19
View File
@@ -0,0 +1,19 @@
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,
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}
+62
View File
@@ -0,0 +1,62 @@
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 { MatomoAnalytics } from "#/components/MatomoAnalytics.tsx";
import Footer from "../components/Footer";
import Header from "../components/Header";
import appCss from "../styles.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "ChessScribe",
},
],
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">
<Header />
{children}
<Footer />
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
<Suspense fallback={null}>
<MatomoAnalytics />
</Suspense>
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import ChessboardContainer from "#/pages/Chessboard.container.tsx";
export const Route = createFileRoute("/chessboard")({
component: RouteComponent,
});
function RouteComponent() {
return <ChessboardContainer />;
}
+10
View File
@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import Contact from "#/pages/Contact.tsx";
export const Route = createFileRoute("/contact")({
component: RouteComponent,
});
function RouteComponent() {
return <Contact />;
}
+8
View File
@@ -0,0 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import Home from "#/pages";
export const Route = createFileRoute("/")({ component: App });
function App() {
return <Home />;
}
+113
View File
@@ -0,0 +1,113 @@
@import "tailwindcss";
@import "styles/_accordion.css";
@import "./styles/_file-input.css";
@import './styles/_pgn-viewer.css';
@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;
}
:root {
--base-content: #1f2937;
--neutral-content: #4b5563;
--accent: #6f884d;
--bg-base: #e7f3ec;
--header-bg: rgba(251, 255, 248, 0.84);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
background-color: var(--bg-base);
}
body {
margin: 0;
font-family: var(--font-sans), sans-serif;
background-color: var(--bg-base);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.nav-container {
background-color: var(--header-bg);
}
.nav-title {
color: var(--accent);
}
.nav-item {
color: var(--neutral-content);
&:hover {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 8px;
}
}
.btn {
padding: 1rem 4rem;
}
.btn-primary {
background-color: var(--accent);
color: var(--bg-base);
transition: filter 0.3s ease;
&:hover {
filter: brightness(0.85);
}
}
.example-pdfs {
max-width: 450px;
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%;
}
}
}
.rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+73
View File
@@ -0,0 +1,73 @@
.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-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(--base-content);
margin-right: 1rem;
transform: rotate(90deg);
transition: transform 0.3s ease;
}
}
.hero__accordion-item__title {
color: var(--base-content);
font-size: 1.25rem;
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;
margin: 0;
}
+22
View File
@@ -0,0 +1,22 @@
.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;
}
+7
View File
@@ -0,0 +1,7 @@
@import "./vendor/lichess-pgn-viewer.css";
.lpv-board {
max-width: 600px;
--board-color: #b58863;
font-family: "Noto Sans", sans-serif;
}
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
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();
};
+61
View File
@@ -0,0 +1,61 @@
import type { IGameState } from "#/reducers/gameReducer.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}`;
};
+13
View File
@@ -0,0 +1,13 @@
export const downloadString = (string: string, filename: string) => {
try {
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();
} catch (error) {
throw new Error("Failed to download file");
}
};