Import study link (#1)

* add bug to todo

* disabled import study button if no study ID is selected, but always show if logged in as not to break the layout

* basic import of lichess study link

* change interface import

* Minmax M3 solution to import study chapter/game functionality

* change button text

* fix toast to display on study and chapter load

* add lint and format fix scripts

* apply auto formatting

* update ignored directories

* fix specificity

* add rudimentary PDF count metrics

* allow docker build

* create maintenance mode banner, triggered by env variables
This commit is contained in:
2026-06-25 14:29:50 +02:00
committed by GitHub
parent fbc704dc30
commit 2c657c7598
23 changed files with 1099 additions and 123 deletions
+5 -1
View File
@@ -1,3 +1,4 @@
import { useServerFn } from "@tanstack/react-start";
import {
type ChangeEvent,
useCallback,
@@ -9,6 +10,7 @@ import { toast } from "react-toastify";
import { env } from "#/env.ts";
import type { IHeader, IPosition } from "#/interfaces.ts";
import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts";
import { recordPdfSuccess } from "#/server/pdfMetrics.ts";
import { downloadPDF } from "#/utils/pdfUtils.ts";
import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
import { downloadString } from "#/utils/stringUtils.ts";
@@ -16,6 +18,8 @@ import { downloadString } from "#/utils/stringUtils.ts";
export const useChessGame = () => {
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState);
const recordPdfSuccessFn = useServerFn(recordPdfSuccess);
const [currentPosition, setCurrentPosition] = useState<IPosition>({
ply: 0,
fen: "",
@@ -66,7 +70,7 @@ export const useChessGame = () => {
if (response.ok) {
downloadPDF(await response.blob());
// TODO add metrics logger - add success to analytics
await recordPdfSuccessFn();
toast.success("PDF successfully generated!", {
toastId: "pdf-success",
+58 -17
View File
@@ -20,13 +20,35 @@ import { getHeaders } from "#/utils/pgnUtils.ts";
type Status = "loading" | "success" | "error";
export type LichessResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
const LICHESS_STUDY_LINK_RE =
/^https?:\/\/lichess\.org\/study\/([A-Za-z0-9]{8})(?:\.pgn)?(?:$|\/|\?|#)/i;
const lichessFetch = async <T>(
fn: () => Promise<T | undefined>,
fallbackError: string,
): Promise<LichessResult<T>> => {
try {
const data = await fn();
if (data === undefined || data === null) {
return { ok: false, error: fallbackError };
}
return { ok: true, data };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : fallbackError,
};
}
};
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);
@@ -108,20 +130,23 @@ export const useLichess = () => {
return { status, error };
};
const getLichessUserStudies = async () => {
if (!user) throw new Error("User not found");
const studies: IUserStudy[] | undefined = await getUserStudiesFn();
if (!studies) return;
setUserStudies(studies);
const getLichessUserStudies = async (): Promise<
LichessResult<IUserStudy[]>
> => {
if (!user) return { ok: false, error: "Not signed in to Lichess." };
return lichessFetch(() => getUserStudiesFn(), "Failed to load 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;
const getLichessStudyChapters = async ({
studyId,
}: {
studyId: string;
}): Promise<LichessResult<IUserStudyChapter[]>> => {
if (!user) return { ok: false, error: "Not signed in to Lichess." };
return lichessFetch(
() => getStudyChaptersFn({ data: { studyId } }),
"Failed to load chapters.",
);
};
// return headers and pgn from the Lichess chapter
@@ -144,6 +169,23 @@ export const useLichess = () => {
return { headers, pgn };
};
// parse and process lichess study link
const parseLichessStudyLink = (studyLink: string): LichessResult<string> => {
const trimmed = studyLink.trim();
if (!trimmed) {
return { ok: false, error: "Please paste a Lichess study link." };
}
const match = trimmed.match(LICHESS_STUDY_LINK_RE);
if (!match) {
return { ok: false, error: "Invalid Lichess study link." };
}
return { ok: true, data: match[1] };
};
return {
lichessLogin,
lichessLogout,
@@ -158,8 +200,7 @@ export const useLichess = () => {
setSelectedStudyId,
selectedStudyId,
getLichessStudyChapters,
studyChapters,
setStudyChapters,
loadLichessStudyChapter,
parseLichessStudyLink,
};
};