episode ids , shorter debounce and flixHQ provider

This commit is contained in:
Jelle van Snik 2023-01-22 19:26:08 +01:00
parent 5a01a68ce4
commit f472f04735
24 changed files with 337 additions and 82 deletions

View File

@ -6,6 +6,7 @@
"dependencies": {
"@formkit/auto-animate": "^1.0.0-beta.5",
"@headlessui/react": "^1.5.0",
"@types/react-helmet": "^6.1.6",
"crypto-js": "^4.1.1",
"fscreen": "^1.2.0",
"fuse.js": "^6.4.6",
@ -19,6 +20,7 @@
"ofetch": "^1.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-i18next": "^12.1.1",
"react-router-dom": "^5.2.0",
"react-stickynode": "^4.1.0",

View File

@ -21,7 +21,22 @@ export function mwFetch<T>(url: string, ops: P<T>[1]): R<T> {
}
export function proxiedFetch<T>(url: string, ops: P<T>[1]): R<T> {
const parsedUrl = new URL(url);
let combinedUrl = ops?.baseURL ?? "";
if (
combinedUrl.length > 0 &&
combinedUrl.endsWith("/") &&
url.startsWith("/")
)
combinedUrl += url.slice(1);
else if (
combinedUrl.length > 0 &&
!combinedUrl.endsWith("/") &&
!url.startsWith("/")
)
combinedUrl += `/${url}`;
else combinedUrl += url;
const parsedUrl = new URL(combinedUrl);
Object.entries(ops?.params ?? {}).forEach(([k, v]) => {
parsedUrl.searchParams.set(k, v);
});

View File

@ -20,8 +20,8 @@ type MWProviderTypeSpecific =
}
| {
type: MWMediaType.SERIES;
episode: number;
season: number;
episode: string;
season: string;
};
export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase;

View File

@ -31,8 +31,8 @@ type MWProviderRunContextTypeSpecific =
}
| {
type: MWMediaType.SERIES;
episode: number;
season: number;
episode: string;
season: string;
};
export type MWProviderRunContext = MWProviderRunContextBase &

View File

@ -2,6 +2,7 @@ import { initializeScraperStore } from "./helpers/register";
// providers
import "./providers/gdriveplayer";
import "./providers/flixhq";
// embeds
// -- nothing here yet

View File

@ -3,6 +3,7 @@ import { makeUrl, mwFetch } from "../helpers/fetch";
import {
formatJWMeta,
JWMediaResult,
JWSeasonMetaResult,
JW_API_BASE,
mediaTypeToJW,
} from "./justwatch";
@ -33,7 +34,8 @@ export interface DetailedMeta {
export async function getMetaFromId(
type: MWMediaType,
id: string
id: string,
seasonId?: string
): Promise<DetailedMeta | null> {
const queryType = mediaTypeToJW(type);
@ -61,8 +63,17 @@ export async function getMetaFromId(
if (!imdbId || !tmdbId) throw new Error("not enough info");
let seasonData: JWSeasonMetaResult | undefined;
if (data.object_type === "show") {
const seasonToScrape = seasonId ?? data.seasons?.[0].id.toString() ?? "";
const url = makeUrl("/content/titles/show_season/{id}/locale/en_US", {
id: seasonToScrape,
});
seasonData = await mwFetch<any>(url, { baseURL: JW_API_BASE });
}
return {
meta: formatJWMeta(data),
meta: formatJWMeta(data, seasonData),
imdbId,
tmdbId,
};

View File

@ -1,10 +1,22 @@
import { MWMediaType } from "./types";
import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types";
export const JW_API_BASE = "https://apis.justwatch.com";
export const JW_IMAGE_BASE = "https://images.justwatch.com";
export type JWContentTypes = "movie" | "show";
export type JWSeasonShort = {
title: string;
id: number;
season_number: number;
};
export type JWEpisodeShort = {
title: string;
id: number;
episode_number: number;
};
export type JWMediaResult = {
title: string;
poster?: string;
@ -12,6 +24,14 @@ export type JWMediaResult = {
original_release_year: number;
jw_entity_id: string;
object_type: JWContentTypes;
seasons?: JWSeasonShort[];
};
export type JWSeasonMetaResult = {
title: string;
id: string;
season_number: number;
episodes: JWEpisodeShort[];
};
export function mediaTypeToJW(type: MWMediaType): JWContentTypes {
@ -26,8 +46,24 @@ export function JWMediaToMediaType(type: string): MWMediaType {
throw new Error("unsupported type");
}
export function formatJWMeta(media: JWMediaResult) {
export function formatJWMeta(
media: JWMediaResult,
season?: JWSeasonMetaResult
): MWMediaMeta {
const type = JWMediaToMediaType(media.object_type);
let seasons: undefined | MWSeasonMeta[];
if (type === MWMediaType.SERIES) {
seasons = media.seasons
?.sort((a, b) => a.season_number - b.season_number)
.map(
(v): MWSeasonMeta => ({
id: v.id.toString(),
number: v.season_number,
title: v.title,
})
);
}
return {
title: media.title,
id: media.id.toString(),
@ -36,5 +72,41 @@ export function formatJWMeta(media: JWMediaResult) {
? `${JW_IMAGE_BASE}${media.poster.replace("{profile}", "s166")}`
: undefined,
type,
seasons: seasons as any,
seasonData: season
? ({
id: season.id.toString(),
number: season.season_number,
title: season.title,
episodes: season.episodes
.sort((a, b) => a.episode_number - b.episode_number)
.map((v) => ({
id: v.id.toString(),
number: v.episode_number,
title: v.title,
})),
} as any)
: (undefined as any),
};
}
export function JWMediaToId(media: MWMediaMeta): string {
return ["JW", mediaTypeToJW(media.type), media.id].join("-");
}
export function decodeJWId(
paramId: string
): { id: string; type: MWMediaType } | null {
const [prefix, type, id] = paramId.split("-", 3);
if (prefix !== "JW") return null;
let mediaType;
try {
mediaType = JWMediaToMediaType(type);
} catch {
return null;
}
return {
type: mediaType,
id,
};
}

View File

@ -4,14 +4,43 @@ export enum MWMediaType {
ANIME = "anime",
}
export type MWMediaMeta = {
export type MWSeasonMeta = {
id: string;
number: number;
title: string;
};
export type MWSeasonWithEpisodeMeta = {
id: string;
number: number;
title: string;
episodes: {
id: string;
number: number;
title: string;
}[];
};
type MWMediaMetaBase = {
title: string;
id: string;
year: string;
poster?: string;
type: MWMediaType;
};
type MWMediaMetaSpecific =
| {
type: MWMediaType.MOVIE | MWMediaType.ANIME;
seasons: undefined;
}
| {
type: MWMediaType.SERIES;
seasons: MWSeasonMeta[];
seasonData: MWSeasonWithEpisodeMeta;
};
export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
export interface MWQuery {
searchQuery: string;
type: MWMediaType;

View File

@ -1,37 +1,63 @@
import { proxiedFetch } from "../helpers/fetch";
import { registerProvider } from "../helpers/register";
import { MWStreamQuality, MWStreamType } from "../helpers/streams";
import { MWMediaType } from "../metadata/types";
const timeout = (time: number) =>
new Promise<void>((resolve) => {
setTimeout(() => resolve(), time);
});
const flixHqBase = "https://api.consumet.org/movies/flixhq";
registerProvider({
id: "testprov",
rank: 42,
id: "flixhq",
displayName: "FlixHQ",
rank: 100,
type: [MWMediaType.MOVIE],
disabled: true,
async scrape({ progress }) {
await timeout(1000);
async scrape({ media, progress }) {
// search for relevant item
const searchResults = await proxiedFetch<any>(
`/${encodeURIComponent(media.meta.title)}`,
{
baseURL: flixHqBase,
}
);
// TODO fuzzy match or normalize title before comparison
const foundItem = searchResults.results.find((v: any) => {
return v.title === media.meta.title && v.releaseDate === media.meta.year;
});
if (!foundItem) throw new Error("No watchable item found");
const flixId = foundItem.id;
// get media info
progress(25);
await timeout(1000);
progress(50);
await timeout(1000);
const mediaInfo = await proxiedFetch<any>("/info", {
baseURL: flixHqBase,
params: {
id: flixId,
},
});
// get stream info from media
progress(75);
await timeout(1000);
const watchInfo = await proxiedFetch<any>("/watch", {
baseURL: flixHqBase,
params: {
episodeId: mediaInfo.episodes[0].id,
mediaId: flixId,
},
});
// get best quality source
const source = watchInfo.sources.reduce((p: any, c: any) =>
c.quality > p.quality ? c : p
);
return {
embeds: [
// {
// type: MWEmbedType.OPENLOAD,
// url: "https://google.com",
// },
// {
// type: MWEmbedType.ANOTHER,
// url: "https://google.com",
// },
],
embeds: [],
stream: {
streamUrl: source.url,
quality: MWStreamQuality.QUNKNOWN,
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4,
captions: [],
},
};
},
});

View File

@ -22,7 +22,7 @@ export function EditButton(props: EditButtonProps) {
>
<span ref={parent}>
{props.editing ? (
<span className="mx-4">Stop editing</span>
<span className="mx-4 whitespace-nowrap">Stop editing</span>
) : (
<Icon icon={Icons.EDIT} />
)}

View File

@ -1,7 +1,7 @@
import { Link } from "react-router-dom";
import { DotList } from "@/components/text/DotList";
import { MWMediaMeta } from "@/backend/metadata/types";
import { mediaTypeToJW } from "@/backend/metadata/justwatch";
import { JWMediaToId } from "@/backend/metadata/justwatch";
import { Icons } from "../Icon";
import { IconPatch } from "../buttons/IconPatch";
@ -107,9 +107,7 @@ export function MediaCard(props: MediaCardProps) {
const canLink = props.linkable && !props.closable;
const link = canLink
? `/media/${encodeURIComponent(
mediaTypeToJW(props.media.type)
)}-${encodeURIComponent(props.media.id)}`
? `/media/${encodeURIComponent(JWMediaToId(props.media))}`
: "#";
if (!props.linkable) return <span>{content}</span>;

View File

@ -1,26 +1,46 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useVideoPlayerState } from "../VideoContext";
interface ShowControlProps {
series?: {
episode: number;
season: number;
episodeId: string;
seasonId: string;
};
title?: string;
onSelect?: (state: { episodeId?: string; seasonId?: string }) => void;
}
export function ShowControl(props: ShowControlProps) {
const { videoState } = useVideoPlayerState();
const lastState = useRef<{
episodeId?: string;
seasonId?: string;
} | null>({
episodeId: props.series?.episodeId,
seasonId: props.series?.seasonId,
});
useEffect(() => {
videoState.setShowData({
current: props.series,
isSeries: !!props.series,
title: props.title,
});
// we only want it to run when props change, not when videoState changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props]);
useEffect(() => {
const currentState = {
episodeId: videoState.seasonData.current?.episodeId,
seasonId: videoState.seasonData.current?.seasonId,
};
if (
currentState.episodeId !== lastState.current?.episodeId ||
currentState.seasonId !== lastState.current?.seasonId
) {
lastState.current = currentState;
props.onSelect?.(currentState);
}
}, [videoState, props]);
return null;
}

View File

@ -63,7 +63,7 @@ export function VolumeControl(props: Props) {
</div>
<div
className={`linear -ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ${
hoveredOnce ? "!w-24 opacity-100" : "w-4 opacity-0"
hoveredOnce || dragging ? "!w-24 opacity-100" : "w-4 opacity-0"
}`}
>
<div

View File

@ -13,11 +13,10 @@ import { getStoredVolume, setStoredVolume } from "./volumeStore";
interface ShowData {
current?: {
episode: number;
season: number;
episodeId: string;
seasonId: string;
};
isSeries: boolean;
title?: string;
}
export interface PlayerControls {

View File

@ -26,10 +26,9 @@ export type PlayerState = {
seasonData: {
isSeries: boolean;
current?: {
episode: number;
season: number;
episodeId: string;
seasonId: string;
};
title?: string;
};
error: null | {
name: string;

View File

@ -15,8 +15,8 @@ export interface ScrapeEventLog {
export type SelectedMediaData =
| {
type: MWMediaType.SERIES;
episode: number;
season: number;
episode: string;
season: string;
}
| {
type: MWMediaType.MOVIE | MWMediaType.ANIME;

View File

@ -42,7 +42,6 @@ if (key) {
// TODO general todos:
// - localize everything
// - add titles to pages
ReactDOM.render(
<React.StrictMode>

View File

@ -16,6 +16,11 @@ function App() {
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
<Route exact path="/media/:media" component={MediaView} />
<Route
exact
path="/media/:media/:season/:episode"
component={MediaView}
/>
<Route exact path="/search/:type/:query?" component={SearchView} />
<Route path="*" component={NotFoundPage} />
</Switch>

View File

@ -4,12 +4,16 @@ import { Link } from "@/components/text/Link";
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
import { useGoBack } from "@/hooks/useGoBack";
import { conf } from "@/setup/config";
import { Helmet } from "react-helmet";
export function MediaFetchErrorView() {
const goBack = useGoBack();
return (
<div className="h-screen flex-1">
<Helmet>
<title>Failed to load meta</title>
</Helmet>
<div className="fixed inset-x-0 top-0 py-6 px-8">
<VideoPlayerHeader onClick={goBack} />
</div>

View File

@ -1,11 +1,12 @@
import { useParams } from "react-router-dom";
import { useHistory, useParams } from "react-router-dom";
import { Helmet } from "react-helmet";
import { useEffect, useRef, useState } from "react";
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
import { MWStream } from "@/backend/helpers/streams";
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
import { DetailedMeta, getMetaFromId } from "@/backend/metadata/getmeta";
import { JWMediaToMediaType } from "@/backend/metadata/justwatch";
import { decodeJWId } from "@/backend/metadata/justwatch";
import { SourceControl } from "@/components/video/controls/SourceControl";
import { Loading } from "@/components/layout/Loading";
import { useLoading } from "@/hooks/useLoading";
@ -23,6 +24,9 @@ import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
function MediaViewLoading(props: { onGoBack(): void }) {
return (
<div className="relative flex h-screen items-center justify-center">
<Helmet>
<title>Loading...</title>
</Helmet>
<div className="absolute inset-x-0 top-0 p-6">
<VideoPlayerHeader onClick={props.onGoBack} />
</div>
@ -51,6 +55,9 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
return (
<div className="relative flex h-screen items-center justify-center">
<Helmet>
<title>{props.meta.meta.title}</title>
</Helmet>
<div className="absolute inset-x-0 top-0 py-6 px-8">
<VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
</div>
@ -85,6 +92,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
interface MediaViewPlayerProps {
meta: DetailedMeta;
stream: MWStream;
selected: SelectedMediaData;
}
export function MediaViewPlayer(props: MediaViewPlayerProps) {
const goBack = useGoBack();
@ -96,8 +104,13 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.stream]);
// TODO show episode title
return (
<div className="h-screen w-screen">
<Helmet>
<title>{props.meta.meta.title}</title>
</Helmet>
<DecoratedVideoPlayer media={props.meta.meta} onGoBack={goBack} autoPlay>
<SourceControl
source={props.stream.streamUrl}
@ -107,44 +120,71 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
startAt={firstStartTime.current}
onProgress={updateProgress}
/>
<ShowControl series={{ episode: 5, season: 2 }} title="hello world" />
{props.selected.type === MWMediaType.SERIES ? (
<ShowControl
series={{
seasonId: props.selected.season,
episodeId: props.selected.episode,
}}
onSelect={(d) => console.log("selected stuff", d)}
/>
) : null}
</DecoratedVideoPlayer>
</div>
);
}
export function MediaView() {
const params = useParams<{ media: string }>();
const params = useParams<{
media: string;
episode?: string;
season?: string;
}>();
const goBack = useGoBack();
const history = useHistory();
const [meta, setMeta] = useState<DetailedMeta | null>(null);
const [selected, setSelected] = useState<SelectedMediaData | null>(null);
const [exec, loading, error] = useLoading(async (mediaParams: string) => {
let type: MWMediaType;
let id = "";
try {
const [t, i] = mediaParams.split("-", 2);
type = JWMediaToMediaType(t);
id = i;
} catch (err) {
return null;
const [exec, loading, error] = useLoading(
async (mediaParams: string, seasonId?: string) => {
const data = decodeJWId(mediaParams);
if (!data) return null;
return getMetaFromId(data.type, data.id, seasonId);
}
return getMetaFromId(type, id);
});
);
const [stream, setStream] = useState<MWStream | null>(null);
useEffect(() => {
exec(params.media).then((v) => {
console.log("I am being ran");
exec(params.media, params.season).then((v) => {
setMeta(v ?? null);
if (v)
setSelected({
type: v.meta.type,
episode: 0 as any,
season: 0 as any,
});
else setSelected(null);
if (v) {
if (v.meta.type !== MWMediaType.SERIES) {
setSelected({
type: v.meta.type,
season: undefined,
episode: undefined,
});
} else {
const season = params.season ?? v.meta.seasonData.id;
const episode = params.episode ?? v.meta.seasonData.episodes[0].id;
setSelected({
type: MWMediaType.SERIES,
season,
episode,
});
if (season !== params.season || episode !== params.episode)
history.replace(
`/media/${encodeURIComponent(params.media)}/${encodeURIComponent(
season
)}/${encodeURIComponent(episode)}`
);
}
} else setSelected(null);
});
}, [exec, params.media]);
// dont rerender when params changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exec, history]);
if (loading) return <MediaViewLoading onGoBack={goBack} />;
if (error) return <MediaFetchErrorView />;
@ -167,5 +207,5 @@ export function MediaView() {
);
// show stream once we have a stream
return <MediaViewPlayer meta={meta} stream={stream} />;
return <MediaViewPlayer meta={meta} stream={stream} selected={selected} />;
}

View File

@ -7,6 +7,7 @@ import { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title";
import { useGoBack } from "@/hooks/useGoBack";
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
import { Helmet } from "react-helmet";
export function NotFoundWrapper(props: {
children?: ReactNode;
@ -16,6 +17,9 @@ export function NotFoundWrapper(props: {
return (
<div className="h-screen flex-1">
<Helmet>
<title>Not found</title>
</Helmet>
{props.video ? (
<div className="fixed inset-x-0 top-0 py-6 px-8">
<VideoPlayerHeader onClick={goBack} />

View File

@ -13,7 +13,7 @@ export function SearchResultsPartial({ search }: SearchResultsPartialProps) {
const [searching, setSearching] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const debouncedSearch = useDebounce<MWQuery>(search, 2000);
const debouncedSearch = useDebounce<MWQuery>(search, 500);
useEffect(() => {
setSearching(search.searchQuery !== "");
setLoading(search.searchQuery !== "");

View File

@ -7,6 +7,7 @@ import { SearchBarInput } from "@/components/SearchBar";
import { Title } from "@/components/text/Title";
import { useSearchQuery } from "@/hooks/useSearchQuery";
import { WideContainer } from "@/components/layout/WideContainer";
import { Helmet } from "react-helmet";
import { SearchResultsPartial } from "./SearchResultsPartial";
export function SearchView() {
@ -22,6 +23,9 @@ export function SearchView() {
return (
<>
<div className="relative z-10 mb-24">
<Helmet>
<title>movie-web</title>
</Helmet>
<Navigation bg={showBg} />
<ThinContainer>
<div className="mt-44 space-y-16 text-center">

View File

@ -321,6 +321,13 @@
dependencies:
"@types/react" "^17"
"@types/react-helmet@^6.1.6":
"integrity" "sha512-ZKcoOdW/Tg+kiUbkFCBtvDw0k3nD4HJ/h/B9yWxN4uDO8OkRksWTO+EL+z/Qu3aHTeTll3Ro0Cc/8UhwBCMG5A=="
"resolved" "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.6.tgz"
"version" "6.1.6"
dependencies:
"@types/react" "*"
"@types/react-router-dom@^5.3.3":
"integrity" "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="
"resolved" "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz"
@ -2881,7 +2888,7 @@
dependencies:
"read" "1"
"prop-types@^15.6.0", "prop-types@^15.6.2", "prop-types@^15.8.1":
"prop-types@^15.6.0", "prop-types@^15.6.2", "prop-types@^15.7.2", "prop-types@^15.8.1":
"integrity" "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="
"resolved" "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"
"version" "15.8.1"
@ -2924,6 +2931,21 @@
"object-assign" "^4.1.1"
"scheduler" "^0.20.2"
"react-fast-compare@^3.1.1":
"integrity" "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
"resolved" "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz"
"version" "3.2.0"
"react-helmet@^6.1.0":
"integrity" "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw=="
"resolved" "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz"
"version" "6.1.0"
dependencies:
"object-assign" "^4.1.1"
"prop-types" "^15.7.2"
"react-fast-compare" "^3.1.1"
"react-side-effect" "^2.1.0"
"react-i18next@^12.1.1":
"integrity" "sha512-mFdieOI0LDy84q3JuZU6Aou1DoWW2fhapcTGeBS8+vWSJuViuoCLQAMYSb0QoHhXS8B0WKUOPpx4cffAP7r/aA=="
"resolved" "https://registry.npmjs.org/react-i18next/-/react-i18next-12.1.1.tgz"
@ -2965,6 +2987,11 @@
"tiny-invariant" "^1.0.2"
"tiny-warning" "^1.0.0"
"react-side-effect@^2.1.0":
"integrity" "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw=="
"resolved" "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz"
"version" "2.1.2"
"react-stickynode@^4.1.0":
"integrity" "sha512-zylWgfad75jLfh/gYIayDcDWIDwO4weZrsZqDpjZ/axhF06zRjdCWFBgUr33Pvv2+htKWqPSFksWTyB6aMQ1ZQ=="
"resolved" "https://registry.npmjs.org/react-stickynode/-/react-stickynode-4.1.0.tgz"
@ -2986,7 +3013,7 @@
"loose-envify" "^1.4.0"
"prop-types" "^15.6.2"
"react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^17.0.2", "react@>= 16.8.0", "react@>=15", "react@>=16.6.0", "react@17.0.2":
"react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^16.3.0 || ^17.0.0 || ^18.0.0", "react@^17.0.2", "react@>= 16.8.0", "react@>=15", "react@>=16.3.0", "react@>=16.6.0", "react@17.0.2":
"integrity" "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA=="
"resolved" "https://registry.npmjs.org/react/-/react-17.0.2.tgz"
"version" "17.0.2"