Compare commits

..

2 Commits

Author SHA1 Message Date
owenrees f9d02efe49 remove extraneous container_name from docker-compose.yml 2026-06-25 14:56:44 +02:00
owenrees 2c657c7598 Import study link (#1)
* add bug to todo

* disabled import study button if no study ID is selected, but always show if logged in as not to break the layout

* basic import of lichess study link

* change interface import

* Minmax M3 solution to import study chapter/game functionality

* change button text

* fix toast to display on study and chapter load

* add lint and format fix scripts

* apply auto formatting

* update ignored directories

* fix specificity

* add rudimentary PDF count metrics

* allow docker build

* create maintenance mode banner, triggered by env variables
2026-06-25 14:29:50 +02:00
23 changed files with 1098 additions and 123 deletions
+2
View File
@@ -1,6 +1,8 @@
VITE_API_BASE_URL= VITE_API_BASE_URL=
VITE_MATOMO_URL= VITE_MATOMO_URL=
VITE_MATOMO_SITE_ID= VITE_MATOMO_SITE_ID=
VITE_MAINTENANCE_MODE= # nothing = false, anything else = true
VITE_MAINTENANCE_MODE_MESSAGE="ChessScribe is currently undergoing maintenance. Some features may be unavailable."
DISCORD_CONTACT_WEBHOOK= DISCORD_CONTACT_WEBHOOK=
DISCORD_ERROR_LOG_WEBHOOK= DISCORD_ERROR_LOG_WEBHOOK=
+1
View File
@@ -11,3 +11,4 @@ dist-ssr
.vinxi .vinxi
__unconfig* __unconfig*
todos.json todos.json
data
+23
View File
@@ -0,0 +1,23 @@
# --- Stage 1: Build Stage ---
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile
COPY . .
RUN pnpm run build
# --- Stage 2: Production Stage ---
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# Nitro compiles everything needed into the .output directory
COPY --from=builder /app/.output ./.output
EXPOSE 3000
# Run the compiled Nitro server
CMD ["node", ".output/server/index.mjs"]
+4
View File
@@ -9,3 +9,7 @@
- checkbox and toggle disabled states - checkbox and toggle disabled states
- react query on for study and chapter load, to allow caching - react query on for study and chapter load, to allow caching
- https://specification.website/checklist/ - https://specification.website/checklist/
- Custom headers do not work - the subtitle is appearing above every thing on first page, and the game starts 2nd page
- react query to cache the study and chapters
+6 -2
View File
@@ -13,7 +13,12 @@
"**/index.html", "**/index.html",
"**/vite.config.ts", "**/vite.config.ts",
"!**/src/routeTree.gen.ts", "!**/src/routeTree.gen.ts",
"!**/src/styles.css" "!**/src/styles.css",
"!**/.tanstack/*",
"!**/.output/*",
"!**/.nitro/*",
"!**/node_modules/*",
"!**/styles/vendor/*"
] ]
}, },
"formatter": { "formatter": {
@@ -33,4 +38,3 @@
} }
} }
} }
+13
View File
@@ -0,0 +1,13 @@
services:
chess-scribe-frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
env_file:
- .env
restart: unless-stopped
+3 -6
View File
@@ -9,8 +9,11 @@
"dev": "vite dev --port 3000 --host", "dev": "vite dev --port 3000 --host",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "node .output/server/index.mjs",
"test": "vitest run", "test": "vitest run",
"lint:fix": "biome lint --write",
"format": "biome format", "format": "biome format",
"format:fix": "biome format --write",
"lint": "biome lint", "lint": "biome lint",
"check": "biome check" "check": "biome check"
}, },
@@ -48,11 +51,5 @@
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^8.0.0", "vite": "^8.0.0",
"vitest": "^4.1.5" "vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
} }
} }
+13 -5
View File
@@ -315,6 +315,9 @@ packages:
'@emnapi/runtime@1.10.0': '@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -1903,8 +1906,8 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
semver@7.8.1: semver@7.8.5:
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
@@ -2489,6 +2492,11 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.1': '@emnapi/wasi-threads@1.2.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -2583,7 +2591,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
dependencies: dependencies:
'@emnapi/runtime': 1.10.0 '@emnapi/runtime': 1.11.1
optional: true optional: true
'@img/sharp-win32-arm64@0.34.5': '@img/sharp-win32-arm64@0.34.5':
@@ -3912,7 +3920,7 @@ snapshots:
semver@6.3.1: {} semver@6.3.1: {}
semver@7.8.1: semver@7.8.5:
optional: true optional: true
seroval-plugins@1.5.4(seroval@1.5.4): seroval-plugins@1.5.4(seroval@1.5.4):
@@ -3925,7 +3933,7 @@ snapshots:
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.8.1 semver: 7.8.5
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5
+9
View File
@@ -0,0 +1,9 @@
# pnpm v11 configuration file
packages:
- "."
# This replaces 'onlyBuiltDependencies'
allowBuilds:
"sharp": true
"esbuild": true
"lightningcss": true
+8 -2
View File
@@ -1,12 +1,18 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { Image } from "@unpic/react"; import { Image } from "@unpic/react";
import MaintenanceModeBanner from "#/components/MaintenanceModeBanner.tsx";
import { env } from "#/env.ts";
import logo from "@/assets/images/logo.svg?url"; import logo from "@/assets/images/logo.svg?url";
export default function Header() { export default function Header() {
return ( return (
<header> <header>
<nav className="w-full max-w-screen-2xl mx-auto p-8"> {env.VITE_MAINTENANCE_MODE ? (
<div className="absolute w-full">
<MaintenanceModeBanner message={env.VITE_MAINTENANCE_MODE_MESSAGE} />
</div>
) : null}
<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="container mx-auto flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<Link to="/" className="flex flex-row items-center justify-center"> <Link to="/" className="flex flex-row items-center justify-center">
+59
View File
@@ -0,0 +1,59 @@
import type { FormEvent } from "react";
import { toast } from "react-toastify";
import { useLichess } from "#/hooks/useLichess.ts";
interface IProps {
setSelectedStudyId: (id: string) => void;
}
const LichessStudyLinkInput = ({ setSelectedStudyId }: IProps) => {
const { parseLichessStudyLink } = useLichess();
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const studyLink = formData.get("lichess-study-link");
if (!studyLink) return;
if (typeof studyLink !== "string") {
toast.error("Invalid study link");
return;
}
const result = parseLichessStudyLink(studyLink);
if (!result.ok) {
toast.error(result.error);
return;
}
setSelectedStudyId(result.data);
toast.success("Study loaded — pick a chapter");
};
return (
<form className="flex items-end gap-2" onSubmit={handleSubmit}>
<div>
<label htmlFor="lichess-study-link" className="text-sm capitalize">
Lichess Study Link
</label>
<input
className="border rounded-md p-2 w-full"
type="text"
id="lichess-study-link"
name="lichess-study-link"
placeholder="https://lichess.org/study/..."
/>
</div>
<button
type="submit"
className="border py-2 px-3 rounded-md cursor-pointer bg-(--accent) text-white hover:bg-transparent hover:text-(--accent) transition-colors duration-300 ease-in-out"
>
Submit
</button>
</form>
);
};
export default LichessStudyLinkInput;
+13
View File
@@ -0,0 +1,13 @@
interface IProps {
message?: string;
}
const DEFAULT_MESSAGE = "Maintenance Mode";
const MaintenanceModeBanner = ({ message }: IProps) => (
<div className="bg-orange-400 text-white text-center py-1 w-full text-sm">
<p>{message || DEFAULT_MESSAGE}</p>
</div>
);
export default MaintenanceModeBanner;
+10 -5
View File
@@ -1,16 +1,16 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { toast } from "react-toastify";
import { useClickOutside } from "#/hooks/useClickOutside.ts"; import { useClickOutside } from "#/hooks/useClickOutside.ts";
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts"; import { useLichess } from "#/hooks/useLichess.ts";
interface IProps { interface IProps {
setSelectedStudyId: (studyId: string) => void; setSelectedStudyId: (studyId: string) => void;
setStudyChapters: (chapters: IUserStudyChapter[]) => void;
} }
const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => { const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { getLichessUserStudies, userStudies } = useLichess(); const { getLichessUserStudies, userStudies, setUserStudies } = useLichess();
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const filteredStudies = userStudies.filter((study) => const filteredStudies = userStudies.filter((study) =>
@@ -20,15 +20,20 @@ const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
const onClickHandler = async () => { const onClickHandler = async () => {
setIsOpen(!isOpen); setIsOpen(!isOpen);
if (!userStudies.length) { if (userStudies.length) return;
await getLichessUserStudies();
const result = await getLichessUserStudies();
if (result.ok) {
setUserStudies(result.data);
} }
}; };
const handleStudyClick = async (studyId: string) => { const handleStudyClick = async (studyId: string) => {
const study = userStudies.find((s) => s.id === studyId);
setSelectedStudyId(studyId); setSelectedStudyId(studyId);
setIsOpen(false); setIsOpen(false);
setSearch(""); setSearch("");
toast.success(`Loaded study "${study?.name ?? studyId}"`);
}; };
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef }); useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
+74 -18
View File
@@ -1,6 +1,14 @@
import { type Dispatch, useRef, useState } from "react"; import {
type Dispatch,
useActionState,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "react-toastify";
import { useClickOutside } from "#/hooks/useClickOutside.ts"; import { useClickOutside } from "#/hooks/useClickOutside.ts";
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts"; import { useLichess } from "#/hooks/useLichess.ts";
import type { IUserStudyChapter } from "#/interfaces.ts";
import type { GameAction } from "#/reducers/gameReducer.ts"; import type { GameAction } from "#/reducers/gameReducer.ts";
interface IProps { interface IProps {
@@ -8,26 +16,64 @@ interface IProps {
gameDispatch: Dispatch<GameAction>; gameDispatch: Dispatch<GameAction>;
} }
type ChapterState = {
status: "idle" | "success" | "error";
chapters: IUserStudyChapter[];
error: string | null;
};
const initialChapterState: ChapterState = {
status: "idle",
chapters: [],
error: null,
};
const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => { const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [studyChapters, setStudyChapters] = useState<IUserStudyChapter[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess(); const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess();
const filteredChapters = studyChapters.filter((chapter) => const [chapterState, fetchChaptersAction, isPending] = useActionState(
async (_prev: ChapterState, _formData: FormData): Promise<ChapterState> => {
if (!selectedStudyId) {
return {
status: "error",
chapters: [],
error: "No study selected.",
} satisfies ChapterState;
}
const result = await getLichessStudyChapters({
studyId: selectedStudyId,
});
if (result.ok) {
return {
status: "success",
chapters: result.data,
error: null,
} satisfies ChapterState;
}
return {
status: "error",
chapters: [],
error: result.error,
} satisfies ChapterState;
},
initialChapterState,
);
const filteredChapters = chapterState.chapters.filter((chapter) =>
chapter.name.toLowerCase().includes(search.toLowerCase()), chapter.name.toLowerCase().includes(search.toLowerCase()),
); );
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef }); useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
const onClickHandler = async () => { useEffect(() => {
const studies = await getLichessStudyChapters({ studyId: selectedStudyId }); if (isPending || chapterState.status === "success") {
if (!studies) return; setIsOpen(true);
setStudyChapters(studies); }
setIsOpen(!isOpen); }, [isPending, chapterState.status]);
};
const handleChapterClick = async (chapter: IUserStudyChapter) => { const handleChapterClick = async (chapter: IUserStudyChapter) => {
const { headers, pgn } = await loadLichessStudyChapter({ chapter }); const { headers, pgn } = await loadLichessStudyChapter({ chapter });
@@ -36,16 +82,22 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
setIsOpen(false); setIsOpen(false);
setSearch(""); setSearch("");
toast.success(`Loaded chapter "${chapter.name}"`);
}; };
return ( return (
<div ref={dropdownRef} className="relative"> <form
<button key={selectedStudyId}
type="button" action={fetchChaptersAction}
className="btn btn-secondary" className="relative"
onClick={onClickHandler}
> >
Import Study Chapter/Game <input type="hidden" name="studyId" value={selectedStudyId} />
<button
type="submit"
className="btn btn-secondary"
disabled={!selectedStudyId || isPending}
>
{isPending ? "Loading chapters…" : "Import Chapter"}
</button> </button>
{isOpen ? ( {isOpen ? (
@@ -79,7 +131,11 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
<hr className="border-(--neutral-content)/15 my-2" /> <hr className="border-(--neutral-content)/15 my-2" />
{filteredChapters.length > 0 ? ( {isPending ? (
<p className="text-(--neutral-content) text-sm text-center py-4">
Loading chapters
</p>
) : filteredChapters.length > 0 ? (
<ul> <ul>
{filteredChapters.map((chapter) => ( {filteredChapters.map((chapter) => (
<li key={`${chapter.chapterId}${chapter.name}`}> <li key={`${chapter.chapterId}${chapter.name}`}>
@@ -100,7 +156,7 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
)} )}
</div> </div>
) : null} ) : null}
</div> </form>
); );
}; };
+2
View File
@@ -4,6 +4,8 @@ const clientSchema = z.object({
VITE_API_BASE_URL: z.string("Invalid API Base URL format"), VITE_API_BASE_URL: z.string("Invalid API Base URL format"),
VITE_MATOMO_URL: z.string("Invalid URL format"), VITE_MATOMO_URL: z.string("Invalid URL format"),
VITE_MATOMO_SITE_ID: z.string("Invalid Site ID"), VITE_MATOMO_SITE_ID: z.string("Invalid Site ID"),
VITE_MAINTENANCE_MODE: z.string("Invalid Maintenance Mode"),
VITE_MAINTENANCE_MODE_MESSAGE: z.string().optional(),
}); });
const serverSchema = z.object({ const serverSchema = z.object({
+5 -1
View File
@@ -1,3 +1,4 @@
import { useServerFn } from "@tanstack/react-start";
import { import {
type ChangeEvent, type ChangeEvent,
useCallback, useCallback,
@@ -9,6 +10,7 @@ import { toast } from "react-toastify";
import { env } from "#/env.ts"; import { env } from "#/env.ts";
import type { IHeader, IPosition } from "#/interfaces.ts"; import type { IHeader, IPosition } from "#/interfaces.ts";
import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts"; import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts";
import { recordPdfSuccess } from "#/server/pdfMetrics.ts";
import { downloadPDF } from "#/utils/pdfUtils.ts"; import { downloadPDF } from "#/utils/pdfUtils.ts";
import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts"; import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
import { downloadString } from "#/utils/stringUtils.ts"; import { downloadString } from "#/utils/stringUtils.ts";
@@ -16,6 +18,8 @@ import { downloadString } from "#/utils/stringUtils.ts";
export const useChessGame = () => { export const useChessGame = () => {
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState); const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState);
const recordPdfSuccessFn = useServerFn(recordPdfSuccess);
const [currentPosition, setCurrentPosition] = useState<IPosition>({ const [currentPosition, setCurrentPosition] = useState<IPosition>({
ply: 0, ply: 0,
fen: "", fen: "",
@@ -66,7 +70,7 @@ export const useChessGame = () => {
if (response.ok) { if (response.ok) {
downloadPDF(await response.blob()); downloadPDF(await response.blob());
// TODO add metrics logger - add success to analytics await recordPdfSuccessFn();
toast.success("PDF successfully generated!", { toast.success("PDF successfully generated!", {
toastId: "pdf-success", toastId: "pdf-success",
+58 -17
View File
@@ -20,13 +20,35 @@ import { getHeaders } from "#/utils/pgnUtils.ts";
type Status = "loading" | "success" | "error"; type Status = "loading" | "success" | "error";
export type LichessResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
const LICHESS_STUDY_LINK_RE =
/^https?:\/\/lichess\.org\/study\/([A-Za-z0-9]{8})(?:\.pgn)?(?:$|\/|\?|#)/i;
const lichessFetch = async <T>(
fn: () => Promise<T | undefined>,
fallbackError: string,
): Promise<LichessResult<T>> => {
try {
const data = await fn();
if (data === undefined || data === null) {
return { ok: false, error: fallbackError };
}
return { ok: true, data };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : fallbackError,
};
}
};
export const useLichess = () => { export const useLichess = () => {
const { user, setUser } = useLichessUser(); const { user, setUser } = useLichessUser();
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]); const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null); const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
const [studyChapters, setStudyChapters] = useState<
IUserStudyChapter[] | null
>(null);
const loginFn = useServerFn(login); const loginFn = useServerFn(login);
const logoutFn = useServerFn(logout); const logoutFn = useServerFn(logout);
@@ -108,20 +130,23 @@ export const useLichess = () => {
return { status, error }; return { status, error };
}; };
const getLichessUserStudies = async () => { const getLichessUserStudies = async (): Promise<
if (!user) throw new Error("User not found"); LichessResult<IUserStudy[]>
const studies: IUserStudy[] | undefined = await getUserStudiesFn(); > => {
if (!studies) return; if (!user) return { ok: false, error: "Not signed in to Lichess." };
setUserStudies(studies); return lichessFetch(() => getUserStudiesFn(), "Failed to load studies.");
}; };
const getLichessStudyChapters = async ({ studyId }: { studyId: string }) => { const getLichessStudyChapters = async ({
if (!user) throw new Error("User not found"); studyId,
const chapters: IUserStudyChapter[] | undefined = await getStudyChaptersFn({ }: {
data: { studyId }, studyId: string;
}); }): Promise<LichessResult<IUserStudyChapter[]>> => {
if (!chapters) return; if (!user) return { ok: false, error: "Not signed in to Lichess." };
return chapters; return lichessFetch(
() => getStudyChaptersFn({ data: { studyId } }),
"Failed to load chapters.",
);
}; };
// return headers and pgn from the Lichess chapter // return headers and pgn from the Lichess chapter
@@ -144,6 +169,23 @@ export const useLichess = () => {
return { headers, pgn }; return { headers, pgn };
}; };
// parse and process lichess study link
const parseLichessStudyLink = (studyLink: string): LichessResult<string> => {
const trimmed = studyLink.trim();
if (!trimmed) {
return { ok: false, error: "Please paste a Lichess study link." };
}
const match = trimmed.match(LICHESS_STUDY_LINK_RE);
if (!match) {
return { ok: false, error: "Invalid Lichess study link." };
}
return { ok: true, data: match[1] };
};
return { return {
lichessLogin, lichessLogin,
lichessLogout, lichessLogout,
@@ -158,8 +200,7 @@ export const useLichess = () => {
setSelectedStudyId, setSelectedStudyId,
selectedStudyId, selectedStudyId,
getLichessStudyChapters, getLichessStudyChapters,
studyChapters,
setStudyChapters,
loadLichessStudyChapter, loadLichessStudyChapter,
parseLichessStudyLink,
}; };
}; };
+7 -7
View File
@@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
import CustomHeaders from "#/components/CustomHeaders.tsx"; import CustomHeaders from "#/components/CustomHeaders.tsx";
import HeaderFields from "#/components/HeaderFields.tsx"; import HeaderFields from "#/components/HeaderFields.tsx";
import LichessButton from "#/components/LichessButton.tsx"; import LichessButton from "#/components/LichessButton.tsx";
import LichessStudyLinkInput from "#/components/LichessStudyLinkInput.tsx";
import Section from "#/components/Section.tsx"; import Section from "#/components/Section.tsx";
import SelectLichessStudy from "#/components/SelectLichessStudy.tsx"; import SelectLichessStudy from "#/components/SelectLichessStudy.tsx";
import SelectStudyChapter from "#/components/SelectStudyChapter.tsx"; import SelectStudyChapter from "#/components/SelectStudyChapter.tsx";
@@ -38,8 +39,7 @@ const Chessboard = () => {
gameDispatch, gameDispatch,
} = useChessGame(); } = useChessGame();
const { selectedStudyId, setSelectedStudyId, setStudyChapters } = const { selectedStudyId, setSelectedStudyId } = useLichess();
useLichess();
const { user } = useLichessUser(); const { user } = useLichessUser();
@@ -55,18 +55,18 @@ const Chessboard = () => {
onChange={handleLoadPgn} onChange={handleLoadPgn}
/> />
{user ? ( {user ? (
<SelectLichessStudy <SelectLichessStudy setSelectedStudyId={setSelectedStudyId} />
setSelectedStudyId={setSelectedStudyId}
setStudyChapters={setStudyChapters}
/>
) : null} ) : null}
<div /> <div />
{selectedStudyId && user ? ( {user ? (
<SelectStudyChapter <SelectStudyChapter
selectedStudyId={selectedStudyId || ""} selectedStudyId={selectedStudyId || ""}
gameDispatch={gameDispatch} gameDispatch={gameDispatch}
/> />
) : null} ) : null}
{user ? (
<LichessStudyLinkInput setSelectedStudyId={setSelectedStudyId} />
) : null}
</Section> </Section>
<div className="grid lg:grid-cols-2 gap-4"> <div className="grid lg:grid-cols-2 gap-4">
+7 -7
View File
@@ -1,19 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router' import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from './routeTree.gen' import { routeTree } from "./routeTree.gen";
export function getRouter() { export function getRouter() {
const router = createTanStackRouter({ const router = createTanStackRouter({
routeTree, routeTree,
scrollRestoration: true, scrollRestoration: true,
defaultPreload: 'intent', defaultPreload: "intent",
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
}) });
return router return router;
} }
declare module '@tanstack/react-router' { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: ReturnType<typeof getRouter> router: ReturnType<typeof getRouter>;
} }
} }
+41
View File
@@ -0,0 +1,41 @@
import { promises as fs } from "node:fs";
import { join } from "node:path";
import { createServerFn } from "@tanstack/react-start";
const METRICS_DIR = join(process.cwd(), "data");
const LOG_FILE = join(METRICS_DIR, "pdf-events.log");
const COUNT_FILE = join(METRICS_DIR, "pdf-count.txt");
const readCount = async (): Promise<number> => {
try {
const n = Number.parseInt(
(await fs.readFile(COUNT_FILE, "utf-8")).trim(),
10,
);
return Number.isFinite(n) ? n : 0;
} catch {
return 0;
}
};
const writeCountAtomic = async (count: number) => {
const tmp = `${COUNT_FILE}.tmp`;
await fs.writeFile(tmp, String(count), "utf-8");
await fs.rename(tmp, COUNT_FILE);
};
export const recordPdfSuccess = createServerFn({ method: "POST" }).handler(
async () => {
try {
await fs.mkdir(METRICS_DIR, { recursive: true });
const next = (await readCount()) + 1;
await Promise.all([
fs.appendFile(LOG_FILE, `${new Date().toISOString()}\n`, "utf-8"),
writeCountAtomic(next),
]);
return { count: next };
} catch (err) {
console.error("Failed to record PDF success:", err);
}
},
);
+13 -13
View File
@@ -17,6 +17,19 @@
border-bottom: 1px solid var(--neutral-content); border-bottom: 1px solid var(--neutral-content);
} }
.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-details[open] + .hero__accordion-item__content { .hero__accordion-details[open] + .hero__accordion-item__content {
grid-template-rows: 1fr; grid-template-rows: 1fr;
padding: 10px; padding: 10px;
@@ -53,19 +66,6 @@
font-weight: 600; 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 { .hero__accordion-item__content__inner {
color: var(--neutral-content); color: var(--neutral-content);
overflow: hidden; overflow: hidden;
File diff suppressed because one or more lines are too long