import chapter

This commit is contained in:
2026-06-09 14:08:35 +02:00
parent 7b2bf3161b
commit fcb1433403
7 changed files with 240 additions and 16 deletions
+59 -3
View File
@@ -1,13 +1,49 @@
import { useLichess } from "#/hooks/useLichess.ts";
import { useEffect, useRef, useState } from "react";
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
const SelectLichessStudy = () => {
const { getLichessUserStudies } = useLichess();
interface IProps {
setSelectedStudyId: (studyId: string) => void;
setStudyChapters: (chapters: IUserStudyChapter[]) => void;
}
const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
const [isOpen, setIsOpen] = useState(false);
const { getLichessUserStudies, userStudies } = useLichess();
const dropdownRef = useRef<HTMLDivElement>(null);
const onClickHandler = async () => {
setIsOpen(!isOpen);
if (!userStudies.length) {
await getLichessUserStudies();
}
};
const handleStudyClick = async (studyId: string) => {
setSelectedStudyId(studyId);
setIsOpen(false);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
return (
<div ref={dropdownRef} className="relative">
<button
type="button"
className="btn btn-secondary"
@@ -15,6 +51,26 @@ const SelectLichessStudy = () => {
>
Select Lichess Study
</button>
{isOpen ? (
<div className="absolute z-3 bg-(--bg-base) mt-2 max-h-75 border rounded-lg p-2 overflow-y-scroll">
<input type="text" placeholder="Search..." />
<ul>
{userStudies.map((study) => (
<li key={study.id}>
<button
type="button"
className="p-2 cursor-pointer hover:bg-(--neutral-content)/20 text-left w-full"
onClick={() => handleStudyClick(study.id)}
>
{study.name}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>
);
};
+63
View File
@@ -0,0 +1,63 @@
import { useRef, useState } from "react";
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
interface IProps {
selectedStudyId: string;
gameDispatch: any;
}
const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
const [isOpen, setIsOpen] = useState(false);
const [studyChapters, setStudyChapters] = useState<IUserStudyChapter[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess();
const onClickHandler = async () => {
const studies = await getLichessStudyChapters({ studyId: selectedStudyId });
if (!studies) return;
setStudyChapters(studies);
setIsOpen(!isOpen);
};
const handleChapterClick = async (chapter: IUserStudyChapter) => {
const { headers, pgn } = await loadLichessStudyChapter({ chapter });
gameDispatch({ type: "SET_PGN", payload: headers, pgn });
setIsOpen(false);
};
return (
<div ref={dropdownRef} className="relative">
<button
type="button"
className="btn btn-secondary"
onClick={onClickHandler}
>
Import Study Chapter/Game
</button>
{isOpen ? (
<div className="absolute z-3 bg-(--bg-base) mt-2 max-h-75 border rounded-lg p-2 overflow-y-scroll">
<input type="text" placeholder="Search..." />
<ul>
{studyChapters?.map((chapter) => (
<li key={`${chapter.chapterId}${chapter.name}`}>
<button
type="button"
className="p-2 cursor-pointer hover:bg-(--neutral-content)/20 text-left w-full"
onClick={() => handleChapterClick(chapter)}
>
{chapter.name}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>
);
};
export default SelectStudyChapter;
+1
View File
@@ -189,5 +189,6 @@ export const useChessGame = () => {
currentPosition,
generatingPdf,
updateHeaders,
gameDispatch,
};
};
View File
+64 -2
View File
@@ -1,9 +1,11 @@
import { useNavigate } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useLichessUser } from "#/context/LichessUserContext.tsx";
import { useChessGame } from "#/hooks/useChessGame.ts";
import {
getSession,
getStudyChapters,
getToken,
getUser,
getUserStudies,
@@ -11,6 +13,7 @@ import {
logout,
setSession,
} from "#/server/lichess.ts";
import { getHeaders } from "#/utils/pgnUtils.ts";
interface ITokenData {
access_token: string;
@@ -18,8 +21,26 @@ interface ITokenData {
token_type: string;
}
export type IUserStudy = {
id: string;
name: string;
createdAt: number;
updatedAt: number;
};
export interface IUserStudyChapter {
chapterId: string;
name: string;
pgn: string;
}
export const useLichess = () => {
const { user, setUser } = useLichessUser();
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
const [studyChapters, setStudyChapters] = useState<
IUserStudyChapter[] | null
>(null);
const loginFn = useServerFn(login);
const logoutFn = useServerFn(logout);
@@ -28,6 +49,7 @@ export const useLichess = () => {
const setSessionFn = useServerFn(setSession);
const getSessionFn = useServerFn(getSession);
const getUserStudiesFn = useServerFn(getUserStudies);
const getStudyChaptersFn = useServerFn(getStudyChapters);
const lichessAccessToken = async ({ code }: { code: string }) => {
return accessTokenFn({ data: { code } });
@@ -85,7 +107,39 @@ export const useLichess = () => {
const getLichessUserStudies = async () => {
if (!user) throw new Error("User not found");
await getUserStudiesFn();
const studies: IUserStudy[] | undefined = await getUserStudiesFn();
if (!studies) return;
setUserStudies(studies);
};
const getLichessStudyChapters = async ({ studyId }: { studyId: string }) => {
if (!user) throw new Error("User not found");
const chapters: IUserStudyChapter[] | undefined = await getStudyChaptersFn({
data: { studyId },
});
if (!chapters) return;
return chapters;
};
// return headers and pgn from the Lichess chapter
const loadLichessStudyChapter = async ({
chapter,
}: {
chapter: IUserStudyChapter;
}) => {
const pgn = chapter.pgn.trim();
if (!chapter.pgn) throw new Error("No valid PGN found for chapter");
const headers = {
...getHeaders(pgn),
title: "",
subtitle: "",
author: "",
};
console.log(headers, pgn);
return { headers, pgn };
};
return {
@@ -97,5 +151,13 @@ export const useLichess = () => {
getLichessSession,
useLichessCallback,
getLichessUserStudies,
userStudies,
setUserStudies,
setSelectedStudyId,
selectedStudyId,
getLichessStudyChapters,
studyChapters,
setStudyChapters,
loadLichessStudyChapter,
};
};
+15 -1
View File
@@ -5,7 +5,9 @@ import LichessButton from "#/components/LichessButton.tsx";
import LichessStudyUrlInput from "#/components/LichessStudyUrlInput.tsx";
import Section from "#/components/Section.tsx";
import SelectLichessStudy from "#/components/SelectLichessStudy.tsx";
import SelectStudyChapter from "#/components/SelectStudyChapter.tsx";
import { useChessGame } from "#/hooks/useChessGame.ts";
import { useLichess } from "#/hooks/useLichess.ts";
const PgnViewer = lazy(() => import("#/components/PgnViewer.tsx"));
@@ -33,8 +35,12 @@ const Chessboard = () => {
handleToggleDiagram,
handlePlyChange,
updateHeaders,
gameDispatch,
} = useChessGame();
const { selectedStudyId, setSelectedStudyId, setStudyChapters } =
useLichess();
return (
<main className="px-4 pb-8 place-self-center min-h-[calc(100vh-226px)]">
<Section title="Convert PGN to PDF">
@@ -46,7 +52,15 @@ const Chessboard = () => {
className="file-input max-w-xs"
onChange={handleLoadPgn}
/>
<SelectLichessStudy /> or <LichessStudyUrlInput />
<SelectLichessStudy
setSelectedStudyId={setSelectedStudyId}
setStudyChapters={setStudyChapters}
/>{" "}
or <LichessStudyUrlInput />
<SelectStudyChapter
selectedStudyId={selectedStudyId || ""}
gameDispatch={gameDispatch}
/>
</Section>
<div className="grid lg:grid-cols-2 gap-4">
+30 -2
View File
@@ -96,8 +96,6 @@ export const getToken = createServerFn({ method: "POST" })
export const getUser = createServerFn({ method: "GET" })
.inputValidator((data: { access_token: string }) => data)
.handler(async ({ data }) => {
console.log(data.access_token);
const response = await fetch("https://lichess.org/api/account", {
headers: {
Authorization: `Bearer ${data.access_token}`,
@@ -178,3 +176,33 @@ export const getUserStudies = createServerFn({ method: "POST" }).handler(
.map((study) => JSON.parse(study));
},
);
export const getStudyChapters = createServerFn({ method: "POST" })
.inputValidator((data: { studyId: string }) => data)
.handler(async ({ data }) => {
const session = await getSession();
if (!session) return;
const response = await fetch(
`https://lichess.org/api/study/${data.studyId}.pgn`,
{
headers: {
Authorization: `Bearer ${session.token}`,
},
},
);
const text = await response.text();
return text
.trim()
.split(/\n{3,}/)
.map((game) => {
const eventHeaderText = game.match(/\[Event\s+"([^"]+)"]/)?.[1];
const eventName = eventHeaderText?.match(/(?<=:\s+)[^"]+/)?.[0] || "";
const siteHeaderText = game.match(/\[Site\s+"([^"]+)"]/)?.[1];
const chapterId = siteHeaderText?.match(/\/([^/]+)$/)?.[1] || "";
return { chapterId, pgn: game, name: eventName };
});
});