From fcb14334035cdbba5fd28cd2205f69ef2667998d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 9 Jun 2026 14:08:35 +0200 Subject: [PATCH] import chapter --- src/components/SelectLichessStudy.tsx | 78 +++++++++++++++++++++++---- src/components/SelectStudyChapter.tsx | 63 ++++++++++++++++++++++ src/hooks/useChessGame.ts | 1 + src/hooks/useClickOutside.ts | 0 src/hooks/useLichess.ts | 66 ++++++++++++++++++++++- src/pages/Chessboard.tsx | 16 +++++- src/server/lichess.ts | 32 ++++++++++- 7 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 src/components/SelectStudyChapter.tsx create mode 100644 src/hooks/useClickOutside.ts diff --git a/src/components/SelectLichessStudy.tsx b/src/components/SelectLichessStudy.tsx index febe7d6..86ded09 100644 --- a/src/components/SelectLichessStudy.tsx +++ b/src/components/SelectLichessStudy.tsx @@ -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 = () => { - 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(null); 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 ( - +
+ + + {isOpen ? ( +
+ +
    + {userStudies.map((study) => ( +
  • + +
  • + ))} +
+
+ ) : null} +
); }; diff --git a/src/components/SelectStudyChapter.tsx b/src/components/SelectStudyChapter.tsx new file mode 100644 index 0000000..b3dca22 --- /dev/null +++ b/src/components/SelectStudyChapter.tsx @@ -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([]); + const dropdownRef = useRef(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 ( +
+ + + {isOpen ? ( +
+ +
    + {studyChapters?.map((chapter) => ( +
  • + +
  • + ))} +
+
+ ) : null} +
+ ); +}; + +export default SelectStudyChapter; diff --git a/src/hooks/useChessGame.ts b/src/hooks/useChessGame.ts index c6e5296..f5e1b21 100644 --- a/src/hooks/useChessGame.ts +++ b/src/hooks/useChessGame.ts @@ -189,5 +189,6 @@ export const useChessGame = () => { currentPosition, generatingPdf, updateHeaders, + gameDispatch, }; }; diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/useLichess.ts b/src/hooks/useLichess.ts index 33e4f1c..1958977 100644 --- a/src/hooks/useLichess.ts +++ b/src/hooks/useLichess.ts @@ -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([]); + const [selectedStudyId, setSelectedStudyId] = useState(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, }; }; diff --git a/src/pages/Chessboard.tsx b/src/pages/Chessboard.tsx index ad929b9..19818d4 100644 --- a/src/pages/Chessboard.tsx +++ b/src/pages/Chessboard.tsx @@ -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 (
@@ -46,7 +52,15 @@ const Chessboard = () => { className="file-input max-w-xs" onChange={handleLoadPgn} /> - or + {" "} + or +
diff --git a/src/server/lichess.ts b/src/server/lichess.ts index c7a9f72..80b0b01 100644 --- a/src/server/lichess.ts +++ b/src/server/lichess.ts @@ -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 }; + }); + });