Introduce DataHandler to remove duplicate code

It allows every `Website` (from any `Info`) to get data

It tries to get updated data periodically
If there's an error getting data, it tries to get data more often

If `error -> success`, the website displays the data, not the error
If `success -> error`, the website displays the old data, not the error

I find this system to be pretty elegant
This commit is contained in:
Taevas 2025-02-18 16:12:28 +01:00
parent 1e71c12f60
commit 48051690a3
10 changed files with 127 additions and 189 deletions

View file

@ -1,6 +1,7 @@
import React, {useState, useEffect} from "react";
import Website from "../Website.js";
import ButtonLink from "#parts/ButtonLink.js";
import DataHandler from "#Infos/DataHandler.js";
export type AnilistInfo = {
title: string;
@ -17,50 +18,39 @@ export type AnilistInfo = {
} | undefined;
export default function Anilist() {
const [anilist, setAnilist]: [AnilistInfo, React.Dispatch<React.SetStateAction<AnilistInfo>>] = useState();
const {data, error, setError} = DataHandler<AnilistInfo>("/.netlify/functions/anilist", 60 * 30);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const getAnilist = async () => {
setAnilist(await fetch("/.netlify/functions/anilist").then(async r => r.json()));
};
useEffect(() => {
getAnilist().catch(() => {
setError(true);
});
}, []);
useEffect(() => {
if (anilist) {
if (data) {
try {
setElements([
<div key={"data"} className="flex mb-4">
<img className="m-auto w-16 h-22" alt="anime cover" src={anilist.cover} />
<img className="m-auto w-16 h-22" alt="anime cover" src={data.cover} />
<div className="m-auto pl-2">
<p className="font-bold">{anilist.title}</p>
<p className="mt-4">Started: <strong>{anilist.startDate}</strong></p>
<p className="font-bold">{data.title}</p>
<p className="mt-4">Started: <strong>{data.startDate}</strong></p>
{
anilist.episodes.watched >= anilist.episodes.total ?
<p>Finished: <strong>{anilist.endDate}</strong></p> :
<p>Ep. {anilist.episodes.watched}: <strong>{anilist.updateDate}</strong></p>
data.episodes.watched >= data.episodes.total ?
<p>Finished: <strong>{data.endDate}</strong></p> :
<p>Ep. {data.episodes.watched}: <strong>{data.updateDate}</strong></p>
}
</div>
</div>,
<>
{
anilist.episodes.watched >= anilist.episodes.total ?
<p>I gave it a <strong>{anilist.score}/10</strong></p> :
<p><strong>{anilist.episodes.watched}/{anilist.episodes.total}</strong> episodes watched</p>
data.episodes.watched >= data.episodes.total ?
<p>I gave it a <strong>{data.score}/10</strong></p> :
<p><strong>{data.episodes.watched}/{data.episodes.total}</strong> episodes watched</p>
}
</>,
<ButtonLink key={"more"} link={anilist.url} text="Anime Link" />,
<ButtonLink key={"more"} link={data.url} text="Anime Link" />,
]);
} catch {
setError(true);
}
}
}, [anilist]);
}, [data]);
return (
<Website

View file

@ -1,6 +1,7 @@
import React, {useState, useEffect} from "react";
import Website from "../Website.js";
import ButtonLink from "#parts/ButtonLink.js";
import DataHandler from "#Infos/DataHandler.js";
export interface GithubInfo {
public?: {
@ -13,37 +14,26 @@ export interface GithubInfo {
}
export default function GitHub() {
const [github, setGithub]: [GithubInfo, React.Dispatch<React.SetStateAction<GithubInfo>>] = useState({});
const {data, error, setError} = DataHandler<GithubInfo>("/.netlify/functions/github", 60 * 20);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const getGithub = async () => {
setGithub(await fetch("/.netlify/functions/github").then(async r => r.json()));
};
useEffect(() => {
getGithub().catch(() => {
setError(true);
});
}, []);
useEffect(() => {
if (github.private ?? github.public) {
if (data && (data.private ?? data.public)) {
try {
const elms: React.JSX.Element[] = [];
if (github.private) {
if (data.private) {
elms.push(
<p key={"github-date-private"} className={github.public ? "mb-2" : ""}>Latest <strong>private</strong> push: <strong>{github.private.date}</strong></p>,
<p key={"github-date-private"} className={data.public ? "mb-2" : ""}>Latest <strong>private</strong> push: <strong>{data.private.date}</strong></p>,
);
}
if (github.public) {
if (data.public) {
elms.push(
<p key={"github-date-public"}>Latest <strong>public</strong> push: <strong>{github.public.date} on {github.public.repo}</strong></p>,
<p key={"github-date-public"}>Latest <strong>public</strong> push: <strong>{data.public.date} on {data.public.repo}</strong></p>,
);
elms.push(
<ButtonLink key={"more"} link={`https://github.com/${github.public.repo}`} text="Repo Link" />,
<ButtonLink key={"more"} link={`https://github.com/${data.public.repo}`} text="Repo Link" />,
);
}
@ -52,7 +42,7 @@ export default function GitHub() {
setError(true);
}
}
}, [github]);
}, [data]);
return (
<Website

View file

@ -1,36 +1,26 @@
import React, {useState, useEffect} from "react";
import Website from "../Website.js";
import DataHandler from "#Infos/DataHandler.js";
export type GitlabInfo = {
date: string;
} | undefined;
export default function GitLab() {
const [gitlab, setGitlab]: [GitlabInfo, React.Dispatch<React.SetStateAction<GitlabInfo>>] = useState();
const {data, error, setError} = DataHandler<GitlabInfo>("/.netlify/functions/gitlab", 60 * 20);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const getGitlab = async () => {
setGitlab(await fetch("/.netlify/functions/gitlab").then(async r => r.json()));
};
useEffect(() => {
getGitlab().catch(() => {
setError(true);
});
}, []);
useEffect(() => {
if (gitlab) {
if (data) {
try {
setElements([
<p key={"gitlab-date"}>Latest push: <strong>{gitlab.date}</strong></p>,
<p key={"gitlab-date"}>Latest push: <strong>{data.date}</strong></p>,
]);
} catch {
setError(true);
}
}
}, [gitlab]);
}, [data]);
return (
<Website

40
src/Infos/DataHandler.tsx Normal file
View file

@ -0,0 +1,40 @@
import React, {useState, useEffect} from "react";
/** Takes care of getting data regularly */
export default function DataHandler<T extends unknown | undefined>(url: string, updateEveryXSeconds: number) {
const [data, setData]: [T | undefined, React.Dispatch<React.SetStateAction<T | undefined>>] = useState();
const [error, setError] = useState(false);
const [count, setCount] = useState(0);
// Try to get and set data
const updateData = async () => {
try {
setData(await fetch(url).then(async r => r.json()));
setError(false);
} catch {
setError(true);
} finally {
setCount(count + 1);
}
};
// After the number of requests done goes up, set a timeout for a new request
useEffect(() => {
// After a certain amount of time has passed, make a new request
const interval = (error || !data ? 60 : updateEveryXSeconds) * 1000;
const timeout = setTimeout(() => updateData(), interval);
return () => clearTimeout(timeout);
}, [count]);
// Make the first request
useEffect(() => {
updateData();
}, []);
return {
data,
setData,
error,
setError,
};
}

View file

@ -1,5 +1,6 @@
import React, {useState, useEffect} from "react";
import Website from "../Website.js";
import DataHandler from "#Infos/DataHandler.js";
export type KitsuclubInfo = {
id: string
@ -11,43 +12,32 @@ export type KitsuclubInfo = {
} | undefined;
export default function KitsuClub() {
const [kitsuclub, setKitsuclub]: [KitsuclubInfo, React.Dispatch<React.SetStateAction<KitsuclubInfo>>] = useState();
const {data, error, setError} = DataHandler<KitsuclubInfo>("/.netlify/functions/kitsuclub", 60 * 20);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const getKitsuclub = async () => {
setKitsuclub(await fetch("/.netlify/functions/kitsuclub").then(async r => r.json()));
};
useEffect(() => {
getKitsuclub().catch(() => {
setError(true);
});
}, []);
useEffect(() => {
if (kitsuclub) {
if (data) {
try {
const date = new Date(kitsuclub.date).toISOString();
const date = new Date(data.date).toISOString();
setElements([
<div key={"kitsuclub-details"} className="text-left mb-2">
<img key={"kitsuclub-avatar"} src={kitsuclub.avatar} alt="avatar" className="float-left rounded-lg w-12 mr-2"/>
<strong key={"kitsuclub-username"} className="inline-flex">{...emojify(kitsuclub.username, kitsuclub.emojis)}</strong>
<img key={"kitsuclub-avatar"} src={data.avatar} alt="avatar" className="float-left rounded-lg w-12 mr-2"/>
<strong key={"kitsuclub-username"} className="inline-flex">{...emojify(data.username, data.emojis)}</strong>
<br/>
<strong key={"kitsuclub-date"} className="inline-flex text-sm">{date.substring(0, date.indexOf("T"))}</strong>
</div>,
<p key={"kitsuclub-text"} className="text-left">{...emojify(kitsuclub.text, kitsuclub.emojis)}</p>, // emojis that are only in the post aren't in the response yet :(
<p key={"kitsuclub-text"} className="text-left">{...emojify(data.text, data.emojis)}</p>, // emojis that are only in the post aren't in the response yet :(
]);
} catch {
setError(true);
}
}
}, [kitsuclub]);
}, [data]);
return (
<Website
name="KitsuClub"
link={`https://kitsunes.club/@${kitsuclub?.id ?? "taevas"}`}
link={`https://kitsunes.club/@${data?.id ?? "taevas"}`}
elements={elements}
error={error}
/>

View file

@ -1,6 +1,7 @@
import React, {useState, useEffect} from "react";
import Website from "../Website.js";
import ButtonLink from "#parts/ButtonLink.js";
import DataHandler from "#Infos/DataHandler.js";
export type HacktheboxInfo = {
id: string;
@ -13,46 +14,34 @@ export type HacktheboxInfo = {
} | undefined;
export default function Hackthebox() {
const [hackthebox, setHackthebox]: [HacktheboxInfo, React.Dispatch<React.SetStateAction<HacktheboxInfo>>] = useState();
const {data, error, setError} = DataHandler<HacktheboxInfo>("/.netlify/functions/hackthebox", 60 * 60);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const getHackthebox = async () => {
setHackthebox(await fetch("/.netlify/functions/hackthebox").then(async r => r.json()));
};
useEffect(() => {
getHackthebox().catch(() => {
setError(true);
});
}, []);
useEffect(() => {
if (hackthebox) {
if (data) {
try {
setElements([
<div key={"data"} className="flex">
{
hackthebox.type === "user" ?
<img className="m-auto h-16 w-16" alt="machine thumbnail" src={hackthebox.machine_avatar}/> :
<a className="m-auto h-16 w-16" href={`https://www.hackthebox.com/achievement/machine/1063999/${hackthebox.id}`} target="_blank" rel="noreferrer">
<img alt="machine thumbnail" src={hackthebox.machine_avatar}/>
data.type === "user" ?
<img className="m-auto h-16 w-16" alt="machine thumbnail" src={data.machine_avatar}/> :
<a className="m-auto h-16 w-16" href={`https://www.hackthebox.com/achievement/machine/1063999/${data.id}`} target="_blank" rel="noreferrer">
<img alt="machine thumbnail" src={data.machine_avatar}/>
</a>
}
<div className="m-auto pl-4">
<p className="font-bold">{hackthebox.name}</p>
<p>({hackthebox.type})</p>
<p className="font-bold">{data.name}</p>
<p>({data.type})</p>
</div>
</div>,
<p key={"date"} className="mt-2 font-bold">{hackthebox.date}</p>,
<ButtonLink key={"more"} link={`https://app.hackthebox.com/machines/${hackthebox.name}`} text="Machine Link" />,
<p key={"date"} className="mt-2 font-bold">{data.date}</p>,
<ButtonLink key={"more"} link={`https://app.hackthebox.com/machines/${data.name}`} text="Machine Link" />,
]);
} catch {
setError(true);
}
}
}, [hackthebox]);
}, [data]);
return (
<Website

View file

@ -1,6 +1,7 @@
import React, {useState, useEffect} from "react";
import Website from "../Website.js";
import { WKLevelProgression, WKReset } from "@bachmacintosh/wanikani-api-types";
import DataHandler from "#Infos/DataHandler.js";
export type WanikaniInfo = {
progression: {
@ -36,27 +37,16 @@ function Button(item: Item) {
}
export default function Wanikani() {
const [wanikani, setWanikani]: [WanikaniInfo, React.Dispatch<React.SetStateAction<WanikaniInfo>>] = useState();
const {data, error, setError} = DataHandler<WanikaniInfo>("/.netlify/functions/wanikani", 60 * 60);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const getWanikani = async () => {
setWanikani(await fetch("/.netlify/functions/wanikani").then(async r => r.json()));
};
useEffect(() => {
getWanikani().catch(() => {
setError(true);
});
}, []);
useEffect(() => {
if (wanikani) {
if (data) {
try {
const now = new Date();
let level = <></>;
const unlockedLevels = wanikani.progression.data.filter(d => typeof d.data.unlocked_at === "string");
const unlockedLevels = data.progression.data.filter(d => typeof d.data.unlocked_at === "string");
if (unlockedLevels.length) {
const arr = unlockedLevels.sort((a, b) => new Date(b.data.unlocked_at!).getTime() - new Date(a.data.unlocked_at!).getTime());
level = <p className="mb-4"><b>Level {arr[0].data.level}</b> reached!<br/>
@ -64,9 +54,9 @@ export default function Wanikani() {
}
let resets = <></>;
if (wanikani.resets.length) {
if (data.resets.length) {
const allResets: React.JSX.Element[] = [];
for (const dataWrapper of wanikani.resets) {
for (const dataWrapper of data.resets) {
const data = dataWrapper.data;
allResets.push(<p><b>{`${new Date(data.created_at).toISOString().substring(0, 10)}`}</b>{`: Reset my progress from level ${data.original_level} to level ${data.target_level}`}</p>);
}
@ -75,7 +65,7 @@ export default function Wanikani() {
}
const lessons: React.JSX.Element[] = [];
const filteredLessons = wanikani.lessons.filter(lesson => new Date(lesson.available_at) < now);
const filteredLessons = data.lessons.filter(lesson => new Date(lesson.available_at) < now);
for (const lesson of filteredLessons) {
lessons.push(Button(lesson));
}
@ -85,7 +75,7 @@ export default function Wanikani() {
</div> : <p>No lesson available for now!</p>;
const reviews: React.JSX.Element[] = [];
const filteredReviews = wanikani.reviews.filter(review => new Date(review.available_at) < now);
const filteredReviews = data.reviews.filter(review => new Date(review.available_at) < now);
for (const review of filteredReviews) {
reviews.push(Button(review));
}
@ -95,9 +85,9 @@ export default function Wanikani() {
</div> : <p>No review available for now!</p>;
let whenNextToReview = <></>;
if (wanikani.moreThingsToReviewAt) {
if (data.moreThingsToReviewAt) {
const rtf = new Intl.RelativeTimeFormat("en", {style: "long", numeric: "always"});
const timeDifference = new Date(Math.abs(new Date(wanikani.moreThingsToReviewAt).getTime() - now.getTime()));
const timeDifference = new Date(Math.abs(new Date(data.moreThingsToReviewAt).getTime() - now.getTime()));
const howManyHours = (timeDifference.getUTCHours() + 1) + ((24 * (timeDifference.getUTCDate() - 1)) * (timeDifference.getUTCMonth() + 1));
whenNextToReview = <p className="mt-2">{`There will be more stuff to review ${rtf.format(howManyHours, "hour")}!`}</p>;
}
@ -115,7 +105,7 @@ export default function Wanikani() {
setError(true);
}
}
}, [wanikani]);
}, [data]);
return (
<Website

View file

@ -2,6 +2,7 @@ import React, {useState, useEffect} from "react";
import {format} from "timeago.js";
import Website from "../Website.js";
import Link from "#parts/Link.js";
import DataHandler from "#Infos/DataHandler.js";
export type LastfmInfo = {
artist: string;
@ -14,56 +15,34 @@ export type LastfmInfo = {
} | undefined;
export default function Lastfm() {
const [lastfm, setLastfm]: [LastfmInfo, React.Dispatch<React.SetStateAction<LastfmInfo>>] = useState();
const {data, error, setError} = DataHandler<LastfmInfo>("/.netlify/functions/lastfm", 60 * 2);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const getLastfm = async () => {
setLastfm(await fetch("/.netlify/functions/lastfm").then(async r => r.json()));
};
const updateLastFm = () => {
getLastfm().catch(() => {
setError(true);
});
};
useEffect(() => {
updateLastFm();
const timer = setInterval(() => {
updateLastFm();
}, 2 * 60 * 1000);
return () => {
clearInterval(timer);
};
}, []);
useEffect(() => {
if (lastfm) {
if (data) {
try {
const date = new Date(Number(lastfm.date) * 1000);
const dateParagraph = !lastfm.listening ? <p key="date" className="text-left mt-1">
const date = new Date(Number(data.date) * 1000);
const dateParagraph = !data.listening ? <p key="date" className="text-left mt-1">
When: <span className="font-bold">{format(date)}</span>
</p> : <></>;
setElements([
<div key="data" className="flex leading-[18px]">
<img alt="album thumbnail" src={lastfm.image} className="m-auto h-24 w-24"/>
<img alt="album thumbnail" src={data.image} className="m-auto h-24 w-24"/>
<div className="m-auto pl-4 w-fit">
<p className="mb-2">{lastfm.listening ? "I'm currently listening to" : "Last listened to"}</p>
<Link classes="mt-1 px-1 py-2 inline-block w-full font-bold leading-[18px] bg-white text-blue-800" link={lastfm.url} text={lastfm.name}/>
<p className="mb-2">{data.listening ? "I'm currently listening to" : "Last listened to"}</p>
<Link classes="mt-1 px-1 py-2 inline-block w-full font-bold leading-[18px] bg-white text-blue-800" link={data.url} text={data.name}/>
</div>
</div>,
<p key="artist" className="text-left mt-4">Artist: <span className="font-bold">{lastfm.artist}</span></p>,
<p key="album" className="text-left mt-1">Album: <span className="font-bold">{lastfm.album}</span></p>,
<p key="artist" className="text-left mt-4">Artist: <span className="font-bold">{data.artist}</span></p>,
<p key="album" className="text-left mt-1">Album: <span className="font-bold">{data.album}</span></p>,
dateParagraph,
]);
} catch {
setError(true);
}
}
}, [lastfm]);
}, [data]);
return (
<Website

View file

@ -1,6 +1,7 @@
import React, {useState, useEffect} from "react";
import Website from "../Website.js";
import { Ruleset } from "osu-api-v2-js";
import DataHandler from "#Infos/DataHandler.js";
export type OsuInfo = {
country: string;
@ -11,9 +12,8 @@ export type OsuInfo = {
} | undefined;
export default function Osu(args: {ruleset: Ruleset}) {
const [osu, setOsu]: [OsuInfo, React.Dispatch<React.SetStateAction<OsuInfo>>] = useState();
const {data, error, setError} = DataHandler<OsuInfo>(`/.netlify/functions/osu?ruleset=${args.ruleset}`, 60 * 45);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const ruleset = Ruleset[args.ruleset];
let name = ruleset;
@ -23,25 +23,15 @@ export default function Osu(args: {ruleset: Ruleset}) {
name = "catch";
}
const getOsu = async () => {
setOsu(await fetch(`/.netlify/functions/osu?ruleset=${args.ruleset}`).then(async r => r.json()));
};
useEffect(() => {
getOsu().catch(() => {
setError(true);
});
}, []);
useEffect(() => {
if (osu) {
if (data) {
try {
setElements([
<div key={`osu-${ruleset}`} className="flex">
<img className="m-auto w-16 h-16" alt={`${ruleset} mode logo`} src={`/osu_rulesets/${ruleset}.png`}/>
<div className="m-auto">
<p>Global: <strong>#{osu.ranks.global}</strong></p>
<p>{osu.country}: <strong>#{osu.ranks.country}</strong></p>
<p>Global: <strong>#{data.ranks.global}</strong></p>
<p>{data.country}: <strong>#{data.ranks.country}</strong></p>
</div>
</div>,
]);
@ -49,7 +39,7 @@ export default function Osu(args: {ruleset: Ruleset}) {
setError(true);
}
}
}, [osu]);
}, [data]);
return (
<Website

View file

@ -1,6 +1,7 @@
import React, {useState, useEffect} from "react";
import Website from "../Website.js";
import ButtonLink from "#parts/ButtonLink.js";
import DataHandler from "#Infos/DataHandler.js";
export type SpeedruncomInfo = {
place: number;
@ -14,40 +15,29 @@ export type SpeedruncomInfo = {
} | undefined;
export default function Speedruncom() {
const [speedruncom, setSpeedruncom]: [SpeedruncomInfo, React.Dispatch<React.SetStateAction<SpeedruncomInfo>>] = useState();
const {data, error, setError} = DataHandler<SpeedruncomInfo>("/.netlify/functions/speedruncom", 60 * 60);
const [elements, setElements] = useState([] as React.JSX.Element[]);
const [error, setError] = useState(false);
const getSpeedruncom = async () => {
setSpeedruncom(await fetch("/.netlify/functions/speedruncom").then(async r => r.json()));
};
useEffect(() => {
getSpeedruncom().catch(() => {
setError(true);
});
}, []);
useEffect(() => {
if (speedruncom) {
if (data) {
try {
setElements([
<div key={"data"} className="flex pb-2">
<img alt="game thumbnail" src={speedruncom.thumbnail} className="h-32 m-auto" />
<img alt="game thumbnail" src={data.thumbnail} className="h-32 m-auto" />
<div className="m-auto pl-2">
<p className="mb-2">Placed <strong>#{speedruncom.place}</strong> on:</p>
<p className="font-bold">{speedruncom.game}</p>
{speedruncom.details.map((d, i) => <p key={`detail-${i}`}>{d}</p>)}
<p className="mb-2">Placed <strong>#{data.place}</strong> on:</p>
<p className="font-bold">{data.game}</p>
{data.details.map((d, i) => <p key={`detail-${i}`}>{d}</p>)}
</div>
</div>,
<p key={"date"} className="mt-2 font-bold">{speedruncom.date}</p>,
<ButtonLink key={"more"} link={speedruncom.link} text="Run Details" />,
<p key={"date"} className="mt-2 font-bold">{data.date}</p>,
<ButtonLink key={"more"} link={data.link} text="Run Details" />,
]);
} catch {
setError(true);
}
}
}, [speedruncom]);
}, [data]);
return (
<Website