mirror of
https://github.com/TheRealOwenRees/chess-scribe-website.git
synced 2026-07-23 01:46:57 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
55989f09c0
|
|||
|
43d7eaa77f
|
|||
|
07068ab4fa
|
|||
|
63fffb11de
|
|||
|
fcb1433403
|
|||
|
7b2bf3161b
|
@@ -1,8 +1,8 @@
|
|||||||
import { useLichessUser } from "#/context/LichessUserContext.tsx";
|
import { useLichessUser } from "#/context/LichessUserContext.tsx";
|
||||||
import { useLichessOAuth } from "#/hooks/useLichessOAuth.ts";
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
const LichessButton = () => {
|
const LichessButton = () => {
|
||||||
const { lichessLogin, lichessLogout } = useLichessOAuth();
|
const { lichessLogin, lichessLogout } = useLichess();
|
||||||
const { user } = useLichessUser();
|
const { user } = useLichessUser();
|
||||||
|
|
||||||
const onClickHandler = () => {
|
const onClickHandler = () => {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
|
const parseStudyId = (url: string): string | null => {
|
||||||
|
const match = url.match(
|
||||||
|
/(?:https?:\/\/)?(?:www\.)?lichess\.org\/study\/([a-zA-Z0-9]+)/,
|
||||||
|
);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LichessStudyUrlInput = () => {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [feedback, setFeedback] = useState<{
|
||||||
|
type: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const { setSelectedStudyId, selectedStudyId } = useLichess();
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
setFeedback({ type: "error", message: "Please enter a study URL" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const studyId = parseStudyId(url.trim());
|
||||||
|
|
||||||
|
if (!studyId) {
|
||||||
|
setFeedback({
|
||||||
|
type: "error",
|
||||||
|
message: "Invalid Lichess study URL",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (studyId === selectedStudyId) {
|
||||||
|
setFeedback({
|
||||||
|
type: "success",
|
||||||
|
message: "Study already selected",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedStudyId(studyId);
|
||||||
|
setFeedback({ type: "success", message: "Study loaded — pick a chapter" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") handleSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 min-w-[500px]">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--neutral-content) pointer-events-none">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="Lichess Study URL"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUrl(e.target.value);
|
||||||
|
if (feedback) setFeedback(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={`w-full h-12 pl-10 pr-4 rounded-lg border text-(--base-content) placeholder-(--neutral-content)/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50 ${
|
||||||
|
feedback?.type === "error"
|
||||||
|
? "border-red-500"
|
||||||
|
: "border-(--neutral-content)"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary px-6 whitespace-nowrap"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Load Study
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{feedback ? (
|
||||||
|
<span
|
||||||
|
className={`text-sm whitespace-nowrap ${
|
||||||
|
feedback.type === "error" ? "text-red-500" : "text-(--accent)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{feedback.message}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LichessStudyUrlInput;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useClickOutside } from "#/hooks/useClickOutside.ts";
|
||||||
|
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
setSelectedStudyId: (studyId: string) => void;
|
||||||
|
setStudyChapters: (chapters: IUserStudyChapter[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const { getLichessUserStudies, userStudies } = useLichess();
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const filteredStudies = userStudies.filter((study) =>
|
||||||
|
study.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClickHandler = async () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
if (!userStudies.length) {
|
||||||
|
await getLichessUserStudies();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStudyClick = async (studyId: string) => {
|
||||||
|
setSelectedStudyId(studyId);
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClickHandler}
|
||||||
|
>
|
||||||
|
Select Lichess Study
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="absolute z-3 bg-(--bg-base) mt-2 max-h-80 border border-(--neutral-content)/30 rounded-lg p-3 overflow-y-scroll transition-all duration-300 ease-in-out opacity-100 scale-100 shadow-lg">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--neutral-content) pointer-events-none">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<title>search</title>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full h-12 pl-10 pr-4 rounded-lg border border-(--neutral-content) text-(--base-content) placeholder-(--neutral-content)/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-(--neutral-content)/15 my-2" />
|
||||||
|
|
||||||
|
{filteredStudies.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{filteredStudies.map((study) => (
|
||||||
|
<li key={study.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-2 cursor-pointer hover:bg-(--neutral-content)/15 focus-visible:outline-2 focus-visible:outline-(--accent) focus-visible:outline-offset-2 rounded text-left w-full transition-colors duration-200"
|
||||||
|
onClick={() => handleStudyClick(study.id)}
|
||||||
|
>
|
||||||
|
{study.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-(--neutral-content) text-sm text-center py-4">
|
||||||
|
No results found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectLichessStudy;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { type Dispatch, useRef, useState } from "react";
|
||||||
|
import { useClickOutside } from "#/hooks/useClickOutside.ts";
|
||||||
|
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
import type { GameAction } from "#/reducers/gameReducer.ts";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
selectedStudyId: string;
|
||||||
|
gameDispatch: Dispatch<GameAction>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [studyChapters, setStudyChapters] = useState<IUserStudyChapter[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess();
|
||||||
|
|
||||||
|
const filteredChapters = studyChapters.filter((chapter) =>
|
||||||
|
chapter.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
|
||||||
|
|
||||||
|
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_GAME", payload: { pgn, headers } });
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
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-80 border border-(--neutral-content)/30 rounded-lg p-3 overflow-y-scroll transition-all duration-300 ease-in-out opacity-100 scale-100 shadow-lg">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-(--neutral-content) pointer-events-none">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<title>search</title>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full h-12 pl-10 pr-4 rounded-lg border border-(--neutral-content) text-(--base-content) placeholder-(--neutral-content)/50 outline-hidden transition-all duration-200 focus:border-(--accent) focus:outline-3 focus:outline-offset-2 focus:outline-(--accent)/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-(--neutral-content)/15 my-2" />
|
||||||
|
|
||||||
|
{filteredChapters.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
{filteredChapters.map((chapter) => (
|
||||||
|
<li key={`${chapter.chapterId}${chapter.name}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-2 cursor-pointer hover:bg-(--neutral-content)/15 focus-visible:outline-2 focus-visible:outline-(--accent) focus-visible:outline-offset-2 rounded text-left w-full transition-colors duration-200"
|
||||||
|
onClick={() => handleChapterClick(chapter)}
|
||||||
|
>
|
||||||
|
{chapter.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-(--neutral-content) text-sm text-center py-4">
|
||||||
|
No results found
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SelectStudyChapter;
|
||||||
@@ -189,5 +189,6 @@ export const useChessGame = () => {
|
|||||||
currentPosition,
|
currentPosition,
|
||||||
generatingPdf,
|
generatingPdf,
|
||||||
updateHeaders,
|
updateHeaders,
|
||||||
|
gameDispatch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { type RefObject, useEffect } from "react";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
ref: RefObject<HTMLDivElement | null>;
|
||||||
|
setSearch: (search: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClickOutside = ({
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
setSearch,
|
||||||
|
ref,
|
||||||
|
}: IProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
ref.current &&
|
||||||
|
!ref.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen, setIsOpen, setSearch, ref]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useServerFn } from "@tanstack/react-start";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLichessUser } from "#/context/LichessUserContext.tsx";
|
||||||
|
import {
|
||||||
|
getSession,
|
||||||
|
getStudyChapters,
|
||||||
|
getToken,
|
||||||
|
getUser,
|
||||||
|
getUserStudies,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setSession,
|
||||||
|
} from "#/server/lichess.ts";
|
||||||
|
import { getHeaders } from "#/utils/pgnUtils.ts";
|
||||||
|
|
||||||
|
interface ITokenData {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
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);
|
||||||
|
const accessTokenFn = useServerFn(getToken);
|
||||||
|
const getUserFn = useServerFn(getUser);
|
||||||
|
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 } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLichessUser = async ({
|
||||||
|
username,
|
||||||
|
id,
|
||||||
|
token,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
}) => {
|
||||||
|
await setSessionFn({ data: { username, id, token } });
|
||||||
|
setUser({ username, id, isLoggedIn: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => {
|
||||||
|
return getUserFn({
|
||||||
|
data: { access_token: tokenData.access_token },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const lichessLogin = async () => await loginFn();
|
||||||
|
|
||||||
|
const lichessLogout = async () => {
|
||||||
|
await logoutFn();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLichessSession = async () => {
|
||||||
|
return getSessionFn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const useLichessCallback = async ({ code }: { code: string }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const tokenData = await lichessAccessToken({ code });
|
||||||
|
const userData = await getLichessUser({ tokenData });
|
||||||
|
|
||||||
|
await setLichessUser({
|
||||||
|
username: userData.username,
|
||||||
|
id: userData.id,
|
||||||
|
token: tokenData.access_token,
|
||||||
|
});
|
||||||
|
await navigate({ to: "/chessboard" });
|
||||||
|
})();
|
||||||
|
}, [navigate, code]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLichessUserStudies = async () => {
|
||||||
|
if (!user) throw new Error("User not found");
|
||||||
|
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: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { headers, pgn };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
lichessLogin,
|
||||||
|
lichessLogout,
|
||||||
|
lichessAccessToken,
|
||||||
|
getLichessUser,
|
||||||
|
setLichessUser,
|
||||||
|
getLichessSession,
|
||||||
|
useLichessCallback,
|
||||||
|
getLichessUserStudies,
|
||||||
|
userStudies,
|
||||||
|
setUserStudies,
|
||||||
|
setSelectedStudyId,
|
||||||
|
selectedStudyId,
|
||||||
|
getLichessStudyChapters,
|
||||||
|
studyChapters,
|
||||||
|
setStudyChapters,
|
||||||
|
loadLichessStudyChapter,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useServerFn } from "@tanstack/react-start";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useLichessUser } from "#/context/LichessUserContext.tsx";
|
|
||||||
import {
|
|
||||||
getSession,
|
|
||||||
getToken,
|
|
||||||
getUser,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
setSession,
|
|
||||||
} from "#/server/lichess.ts";
|
|
||||||
|
|
||||||
interface ITokenData {
|
|
||||||
access_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
token_type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useLichessOAuth = () => {
|
|
||||||
const { setUser } = useLichessUser();
|
|
||||||
|
|
||||||
const loginFn = useServerFn(login);
|
|
||||||
const logoutFn = useServerFn(logout);
|
|
||||||
const accessTokenFn = useServerFn(getToken);
|
|
||||||
const getUserFn = useServerFn(getUser);
|
|
||||||
const setSessionFn = useServerFn(setSession);
|
|
||||||
const getSessionFn = useServerFn(getSession);
|
|
||||||
|
|
||||||
const lichessAccessToken = async ({ code }: { code: string }) => {
|
|
||||||
return accessTokenFn({ data: { code } });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLichessUser = async ({
|
|
||||||
username,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
username: string;
|
|
||||||
id: string;
|
|
||||||
}) => {
|
|
||||||
await setSessionFn({ data: { username, id } });
|
|
||||||
setUser({ username, id, isLoggedIn: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLichessUser = async ({ tokenData }: { tokenData: ITokenData }) => {
|
|
||||||
return getUserFn({
|
|
||||||
data: { access_token: tokenData.access_token },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const lichessLogin = async () => {
|
|
||||||
await loginFn();
|
|
||||||
};
|
|
||||||
|
|
||||||
const lichessLogout = async () => {
|
|
||||||
await logoutFn();
|
|
||||||
setUser(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLichessSession = async () => {
|
|
||||||
return getSessionFn();
|
|
||||||
};
|
|
||||||
|
|
||||||
const useLichessCallback = async ({ code }: { code: string }) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!code) return;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const tokenData = await lichessAccessToken({ code });
|
|
||||||
const userData = await getLichessUser({ tokenData });
|
|
||||||
await setLichessUser({ username: userData.username, id: userData.id });
|
|
||||||
await navigate({ to: "/chessboard" });
|
|
||||||
})();
|
|
||||||
}, [navigate, code]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
lichessLogin,
|
|
||||||
lichessLogout,
|
|
||||||
lichessAccessToken,
|
|
||||||
getLichessUser,
|
|
||||||
setLichessUser,
|
|
||||||
getLichessSession,
|
|
||||||
useLichessCallback,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { getRouteApi } from "@tanstack/react-router";
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
import { useLichessOAuth } from "#/hooks/useLichessOAuth.ts";
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
const routeApi = getRouteApi("/callback");
|
const routeApi = getRouteApi("/callback");
|
||||||
|
|
||||||
const Callback = () => {
|
const Callback = () => {
|
||||||
const { useLichessCallback } = useLichessOAuth();
|
const { useLichessCallback } = useLichess();
|
||||||
const { code } = routeApi.useSearch();
|
const { code } = routeApi.useSearch();
|
||||||
useLichessCallback({ code }).then();
|
useLichessCallback({ code }).then();
|
||||||
|
|
||||||
return <div>{code}</div>;
|
return <div>Loading...</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Callback;
|
export default Callback;
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ 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 LichessStudyUrlInput from "#/components/LichessStudyUrlInput.tsx";
|
||||||
import Section from "#/components/Section.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 { 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"));
|
||||||
|
|
||||||
@@ -31,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">
|
||||||
@@ -44,6 +52,15 @@ const Chessboard = () => {
|
|||||||
className="file-input max-w-xs"
|
className="file-input max-w-xs"
|
||||||
onChange={handleLoadPgn}
|
onChange={handleLoadPgn}
|
||||||
/>
|
/>
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IGameState, IHeader, IPosition } from "#/interfaces.ts";
|
import type { IGameState, IHeader, IPosition } from "#/interfaces.ts";
|
||||||
|
|
||||||
type GameAction =
|
export type GameAction =
|
||||||
| { type: "SET_GAME"; payload: { pgn: string; headers: IHeader } }
|
| { type: "SET_GAME"; payload: { pgn: string; headers: IHeader } }
|
||||||
| { type: "CLEAR_GAME" }
|
| { type: "CLEAR_GAME" }
|
||||||
| { type: "SET_HEADERS"; payload: IHeader }
|
| { type: "SET_HEADERS"; payload: IHeader }
|
||||||
|
|||||||
+85
-4
@@ -22,7 +22,11 @@ export const getSession = createServerFn({ method: "GET" }).handler(
|
|||||||
const session = getCookie("lichess-session");
|
const session = getCookie("lichess-session");
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(session) as { username: string; id: string };
|
return JSON.parse(session) as {
|
||||||
|
username: string;
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -30,11 +34,17 @@ export const getSession = createServerFn({ method: "GET" }).handler(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const setSession = createServerFn({ method: "POST" })
|
export const setSession = createServerFn({ method: "POST" })
|
||||||
.inputValidator((data: { username: string; id: string }) => data)
|
.inputValidator(
|
||||||
|
(data: { username: string; id: string; token: string }) => data,
|
||||||
|
)
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
setCookie(
|
setCookie(
|
||||||
"lichess-session",
|
"lichess-session",
|
||||||
JSON.stringify({ username: data.username, id: data.id }),
|
JSON.stringify({
|
||||||
|
username: data.username,
|
||||||
|
id: data.id,
|
||||||
|
token: data.token,
|
||||||
|
}),
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -95,9 +105,26 @@ export const getUser = createServerFn({ method: "GET" })
|
|||||||
return response.json();
|
return response.json();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteAccessToken = createServerFn().handler(async () => {
|
||||||
|
const session = await getSession();
|
||||||
|
const token = session?.token;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const response = await fetch("https://lichess.org/api/token", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const logout = createServerFn().handler(async () => {
|
export const logout = createServerFn().handler(async () => {
|
||||||
|
await deleteAccessToken();
|
||||||
deleteCookie("lichess-session", { path: "/" });
|
deleteCookie("lichess-session", { path: "/" });
|
||||||
deleteCookie("lichess-oauth-verifier", { path: "/" });
|
deleteCookie("lichess-oauth-verifier", { path: "/" }); // cookie shouldn't exist
|
||||||
});
|
});
|
||||||
|
|
||||||
export const login = createServerFn({ method: "GET" }).handler(async () => {
|
export const login = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
@@ -125,3 +152,57 @@ export const login = createServerFn({ method: "GET" }).handler(async () => {
|
|||||||
href: `https://lichess.org/oauth?${params.toString()}`,
|
href: `https://lichess.org/oauth?${params.toString()}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getUserStudies = createServerFn({ method: "POST" }).handler(
|
||||||
|
async () => {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://lichess.org/api/study/by/${session.username}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${session.token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const studies = await response.text();
|
||||||
|
|
||||||
|
return studies
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.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 };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user