mirror of
https://github.com/TheRealOwenRees/chess-scribe-website.git
synced 2026-07-23 01:46:57 +00:00
Compare commits
6 Commits
0b1a0e6457
...
55989f09c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
55989f09c0
|
|||
|
43d7eaa77f
|
|||
|
07068ab4fa
|
|||
|
63fffb11de
|
|||
|
fcb1433403
|
|||
|
7b2bf3161b
|
@@ -1,8 +1,8 @@
|
||||
import { useLichessUser } from "#/context/LichessUserContext.tsx";
|
||||
import { useLichessOAuth } from "#/hooks/useLichessOAuth.ts";
|
||||
import { useLichess } from "#/hooks/useLichess.ts";
|
||||
|
||||
const LichessButton = () => {
|
||||
const { lichessLogin, lichessLogout } = useLichessOAuth();
|
||||
const { lichessLogin, lichessLogout } = useLichess();
|
||||
const { user } = useLichessUser();
|
||||
|
||||
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,
|
||||
generatingPdf,
|
||||
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 { useLichessOAuth } from "#/hooks/useLichessOAuth.ts";
|
||||
import { useLichess } from "#/hooks/useLichess.ts";
|
||||
|
||||
const routeApi = getRouteApi("/callback");
|
||||
|
||||
const Callback = () => {
|
||||
const { useLichessCallback } = useLichessOAuth();
|
||||
const { useLichessCallback } = useLichess();
|
||||
const { code } = routeApi.useSearch();
|
||||
useLichessCallback({ code }).then();
|
||||
|
||||
return <div>{code}</div>;
|
||||
return <div>Loading...</div>;
|
||||
};
|
||||
|
||||
export default Callback;
|
||||
|
||||
@@ -2,8 +2,12 @@ import { lazy, Suspense } from "react";
|
||||
import CustomHeaders from "#/components/CustomHeaders.tsx";
|
||||
import HeaderFields from "#/components/HeaderFields.tsx";
|
||||
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"));
|
||||
|
||||
@@ -31,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">
|
||||
@@ -44,6 +52,15 @@ const Chessboard = () => {
|
||||
className="file-input max-w-xs"
|
||||
onChange={handleLoadPgn}
|
||||
/>
|
||||
<SelectLichessStudy
|
||||
setSelectedStudyId={setSelectedStudyId}
|
||||
setStudyChapters={setStudyChapters}
|
||||
/>{" "}
|
||||
or <LichessStudyUrlInput />
|
||||
<SelectStudyChapter
|
||||
selectedStudyId={selectedStudyId || ""}
|
||||
gameDispatch={gameDispatch}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IGameState, IHeader, IPosition } from "#/interfaces.ts";
|
||||
|
||||
type GameAction =
|
||||
export type GameAction =
|
||||
| { type: "SET_GAME"; payload: { pgn: string; headers: IHeader } }
|
||||
| { type: "CLEAR_GAME" }
|
||||
| { type: "SET_HEADERS"; payload: IHeader }
|
||||
|
||||
+85
-4
@@ -22,7 +22,11 @@ export const getSession = createServerFn({ method: "GET" }).handler(
|
||||
const session = getCookie("lichess-session");
|
||||
if (!session) return null;
|
||||
try {
|
||||
return JSON.parse(session) as { username: string; id: string };
|
||||
return JSON.parse(session) as {
|
||||
username: string;
|
||||
id: string;
|
||||
token: string;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -30,11 +34,17 @@ export const getSession = createServerFn({ method: "GET" }).handler(
|
||||
);
|
||||
|
||||
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 }) => {
|
||||
setCookie(
|
||||
"lichess-session",
|
||||
JSON.stringify({ username: data.username, id: data.id }),
|
||||
JSON.stringify({
|
||||
username: data.username,
|
||||
id: data.id,
|
||||
token: data.token,
|
||||
}),
|
||||
{
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
@@ -95,9 +105,26 @@ export const getUser = createServerFn({ method: "GET" })
|
||||
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 () => {
|
||||
await deleteAccessToken();
|
||||
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 () => {
|
||||
@@ -125,3 +152,57 @@ export const login = createServerFn({ method: "GET" }).handler(async () => {
|
||||
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