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
+67 -11
View File
@@ -1,20 +1,76 @@
import { useLichess } from "#/hooks/useLichess.ts"; import { useEffect, useRef, useState } from "react";
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
const SelectLichessStudy = () => { interface IProps {
const { getLichessUserStudies } = useLichess(); 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 () => { const onClickHandler = async () => {
await getLichessUserStudies(); 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 ( return (
<button <div ref={dropdownRef} className="relative">
type="button" <button
className="btn btn-secondary" type="button"
onClick={onClickHandler} className="btn btn-secondary"
> onClick={onClickHandler}
Select Lichess Study >
</button> 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, currentPosition,
generatingPdf, generatingPdf,
updateHeaders, updateHeaders,
gameDispatch,
}; };
}; };
View File
+64 -2
View File
@@ -1,9 +1,11 @@
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start"; import { useServerFn } from "@tanstack/react-start";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useLichessUser } from "#/context/LichessUserContext.tsx"; import { useLichessUser } from "#/context/LichessUserContext.tsx";
import { useChessGame } from "#/hooks/useChessGame.ts";
import { import {
getSession, getSession,
getStudyChapters,
getToken, getToken,
getUser, getUser,
getUserStudies, getUserStudies,
@@ -11,6 +13,7 @@ import {
logout, logout,
setSession, setSession,
} from "#/server/lichess.ts"; } from "#/server/lichess.ts";
import { getHeaders } from "#/utils/pgnUtils.ts";
interface ITokenData { interface ITokenData {
access_token: string; access_token: string;
@@ -18,8 +21,26 @@ interface ITokenData {
token_type: string; 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 = () => { export const useLichess = () => {
const { user, setUser } = useLichessUser(); 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 loginFn = useServerFn(login);
const logoutFn = useServerFn(logout); const logoutFn = useServerFn(logout);
@@ -28,6 +49,7 @@ export const useLichess = () => {
const setSessionFn = useServerFn(setSession); const setSessionFn = useServerFn(setSession);
const getSessionFn = useServerFn(getSession); const getSessionFn = useServerFn(getSession);
const getUserStudiesFn = useServerFn(getUserStudies); const getUserStudiesFn = useServerFn(getUserStudies);
const getStudyChaptersFn = useServerFn(getStudyChapters);
const lichessAccessToken = async ({ code }: { code: string }) => { const lichessAccessToken = async ({ code }: { code: string }) => {
return accessTokenFn({ data: { code } }); return accessTokenFn({ data: { code } });
@@ -85,7 +107,39 @@ export const useLichess = () => {
const getLichessUserStudies = async () => { const getLichessUserStudies = async () => {
if (!user) throw new Error("User not found"); 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 { return {
@@ -97,5 +151,13 @@ export const useLichess = () => {
getLichessSession, getLichessSession,
useLichessCallback, useLichessCallback,
getLichessUserStudies, 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 LichessStudyUrlInput from "#/components/LichessStudyUrlInput.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 { useChessGame } from "#/hooks/useChessGame.ts"; import { useChessGame } from "#/hooks/useChessGame.ts";
import { useLichess } from "#/hooks/useLichess.ts";
const PgnViewer = lazy(() => import("#/components/PgnViewer.tsx")); const PgnViewer = lazy(() => import("#/components/PgnViewer.tsx"));
@@ -33,8 +35,12 @@ const Chessboard = () => {
handleToggleDiagram, handleToggleDiagram,
handlePlyChange, handlePlyChange,
updateHeaders, updateHeaders,
gameDispatch,
} = useChessGame(); } = useChessGame();
const { selectedStudyId, setSelectedStudyId, setStudyChapters } =
useLichess();
return ( return (
<main className="px-4 pb-8 place-self-center min-h-[calc(100vh-226px)]"> <main className="px-4 pb-8 place-self-center min-h-[calc(100vh-226px)]">
<Section title="Convert PGN to PDF"> <Section title="Convert PGN to PDF">
@@ -46,7 +52,15 @@ const Chessboard = () => {
className="file-input max-w-xs" className="file-input max-w-xs"
onChange={handleLoadPgn} onChange={handleLoadPgn}
/> />
<SelectLichessStudy /> or <LichessStudyUrlInput /> <SelectLichessStudy
setSelectedStudyId={setSelectedStudyId}
setStudyChapters={setStudyChapters}
/>{" "}
or <LichessStudyUrlInput />
<SelectStudyChapter
selectedStudyId={selectedStudyId || ""}
gameDispatch={gameDispatch}
/>
</Section> </Section>
<div className="grid lg:grid-cols-2 gap-4"> <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" }) export const getUser = createServerFn({ method: "GET" })
.inputValidator((data: { access_token: string }) => data) .inputValidator((data: { access_token: string }) => data)
.handler(async ({ data }) => { .handler(async ({ data }) => {
console.log(data.access_token);
const response = await fetch("https://lichess.org/api/account", { const response = await fetch("https://lichess.org/api/account", {
headers: { headers: {
Authorization: `Bearer ${data.access_token}`, Authorization: `Bearer ${data.access_token}`,
@@ -178,3 +176,33 @@ export const getUserStudies = createServerFn({ method: "POST" }).handler(
.map((study) => JSON.parse(study)); .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 };
});
});