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
+19
View File
@@ -0,0 +1,19 @@
{
"projectName": "chess-scribe-website",
"mode": "file-router",
"typescript": true,
"packageManager": "npm",
"includeExamples": true,
"tailwind": true,
"addOnOptions": {},
"envVarValues": {},
"git": true,
"install": true,
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": [
"biome",
"nitro"
]
}
+13
View File
@@ -0,0 +1,13 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="BiomeSettings">
<option name="applySafeFixesOnSave" value="true" />
<option name="enableLspFormat" value="true" />
<option name="formatOnSave" value="true" />
<option name="sortImportOnSave" value="true" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GitToolBoxProjectSettings">
<option name="commitMessageIssueKeyValidationOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
<option name="commitMessageValidationEnabledOverride">
<BoolValueOverride>
<option name="enabled" value="true" />
</BoolValueOverride>
</option>
</component>
</project>
+51
View File
@@ -0,0 +1,51 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="38">
<item index="0" class="java.lang.String" itemvalue="fastapi" />
<item index="1" class="java.lang.String" itemvalue="pydantic" />
<item index="2" class="java.lang.String" itemvalue="dotenv" />
<item index="3" class="java.lang.String" itemvalue="geopy" />
<item index="4" class="java.lang.String" itemvalue="async-timeout" />
<item index="5" class="java.lang.String" itemvalue="requests-cache" />
<item index="6" class="java.lang.String" itemvalue="python-dateutil" />
<item index="7" class="java.lang.String" itemvalue="python-dotenv" />
<item index="8" class="java.lang.String" itemvalue="wcwidth" />
<item index="9" class="java.lang.String" itemvalue="requests" />
<item index="10" class="java.lang.String" itemvalue="importlib-metadata" />
<item index="11" class="java.lang.String" itemvalue="frozenlist" />
<item index="12" class="java.lang.String" itemvalue="pyrate-limiter" />
<item index="13" class="java.lang.String" itemvalue="appdirs" />
<item index="14" class="java.lang.String" itemvalue="url-normalize" />
<item index="15" class="java.lang.String" itemvalue="Pygments" />
<item index="16" class="java.lang.String" itemvalue="zipp" />
<item index="17" class="java.lang.String" itemvalue="certifi" />
<item index="18" class="java.lang.String" itemvalue="soupsieve" />
<item index="19" class="java.lang.String" itemvalue="urllib3" />
<item index="20" class="java.lang.String" itemvalue="commonmark" />
<item index="21" class="java.lang.String" itemvalue="beautifulsoup4" />
<item index="22" class="java.lang.String" itemvalue="cattrs" />
<item index="23" class="java.lang.String" itemvalue="python-forge" />
<item index="24" class="java.lang.String" itemvalue="orjson" />
<item index="25" class="java.lang.String" itemvalue="rich" />
<item index="26" class="java.lang.String" itemvalue="attrs" />
<item index="27" class="java.lang.String" itemvalue="typing_extensions" />
<item index="28" class="java.lang.String" itemvalue="charset-normalizer" />
<item index="29" class="java.lang.String" itemvalue="requests-ratelimiter" />
<item index="30" class="java.lang.String" itemvalue="aiohttp" />
<item index="31" class="java.lang.String" itemvalue="multidict" />
<item index="32" class="java.lang.String" itemvalue="yarl" />
<item index="33" class="java.lang.String" itemvalue="aiosignal" />
<item index="34" class="java.lang.String" itemvalue="py-cord" />
<item index="35" class="java.lang.String" itemvalue="pyyaml" />
<item index="36" class="java.lang.String" itemvalue="httptools" />
<item index="37" class="java.lang.String" itemvalue="websockets" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/chess-scribe-website.iml" filepath="$PROJECT_DIR$/.idea/chess-scribe-website.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+35
View File
@@ -0,0 +1,35 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit"
}
}
+218
View File
@@ -0,0 +1,218 @@
Welcome to your new TanStack Start app!
# Getting Started
To run this application:
```bash
npm install
npm run dev
```
# Building For Production
To build this application for production:
```bash
npm run build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
npm run test
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
### Removing Tailwind CSS
If you prefer not to use Tailwind CSS:
1. Remove the demo pages in `src/routes/demo/`
2. Replace the Tailwind import in `src/styles.css` with your own styles
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
4. Uninstall the packages: `npm install @tailwindcss/vite tailwindcss -D`
## Linting & Formatting
This project uses [Biome](https://biomejs.dev/) for linting and formatting. The following scripts are available:
```bash
npm run lint
npm run format
npm run check
```
## Deploy with Nitro
This project uses Nitro as a generic server adapter, so it can run on any Node-compatible host.
```bash
npm run build
node dist/server/index.mjs
```
The build output is a self-contained Node server. To deploy, push the `dist/` directory to your host (Render, Fly.io, your own VPS, etc.) and run the server command above.
For host-specific presets (Vercel, Netlify, Cloudflare, AWS Lambda, etc.) and tuning, see https://v3.nitro.build/deploy.
## Routing
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
Here is an example layout that includes a header:
```tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
],
}),
shellComponent: ({ children }) => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
{children}
<Scripts />
</body>
</html>
),
})
```
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Server Functions
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
```tsx
import { createServerFn } from '@tanstack/react-start'
const getServerTime = createServerFn({
method: 'GET',
}).handler(async () => {
return new Date().toISOString()
})
// Use in a component
function MyComponent() {
const [time, setTime] = useState('')
useEffect(() => {
getServerTime().then(setTime)
}, [])
return <div>Server time: {time}</div>
}
```
## API Routes
You can create API routes by using the `server` property in your route definitions:
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/hello')({
server: {
handlers: {
GET: () => json({ message: 'Hello, World!' }),
},
},
})
```
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/people')({
loader: async () => {
const response = await fetch('https://swapi.dev/api/people')
return response.json()
},
component: PeopleComponent,
})
function PeopleComponent() {
const data = Route.useLoaderData()
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
)
}
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"includes": [
"**/src/**/*",
"**/.vscode/**/*",
"**/index.html",
"**/vite.config.ts",
"!**/src/routeTree.gen.ts",
"!**/src/styles.css"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}
+2
View File
@@ -0,0 +1,2 @@
[tools]
node = "24"
+57
View File
@@ -0,0 +1,57 @@
{
"name": "chess-scribe-website",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"format": "biome format",
"lint": "biome lint",
"check": "biome check"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@socialgouv/matomo-next": "^1.13.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/router-plugin": "^1.132.0",
"@unpic/react": "^1.0.2",
"lichess-pgn-viewer": "^2.4.5",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.6.0",
"tailwindcss": "^4.1.18",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^28.1.0",
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}
+4124
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"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
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
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");
}
};
+28
View File
@@ -0,0 +1,28 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"paths": {
"#/*": ["./src/*"],
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-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'
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
devtools(),
nitro({ rollupConfig: { external: [/^@sentry\//] } }),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
export default config