diff --git a/package.json b/package.json index ee11ba00..8dd02062 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/backend/helpers/fetch.ts b/src/backend/helpers/fetch.ts index 9804ff40..b61ae55c 100644 --- a/src/backend/helpers/fetch.ts +++ b/src/backend/helpers/fetch.ts @@ -21,7 +21,22 @@ export function mwFetch(url: string, ops: P[1]): R { } export function proxiedFetch(url: string, ops: P[1]): R { - 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); }); diff --git a/src/backend/helpers/provider.ts b/src/backend/helpers/provider.ts index 348152b3..95f5e374 100644 --- a/src/backend/helpers/provider.ts +++ b/src/backend/helpers/provider.ts @@ -20,8 +20,8 @@ type MWProviderTypeSpecific = } | { type: MWMediaType.SERIES; - episode: number; - season: number; + episode: string; + season: string; }; export type MWProviderContext = MWProviderTypeSpecific & MWProviderBase; diff --git a/src/backend/helpers/scrape.ts b/src/backend/helpers/scrape.ts index 3ad57843..2e9e5e65 100644 --- a/src/backend/helpers/scrape.ts +++ b/src/backend/helpers/scrape.ts @@ -31,8 +31,8 @@ type MWProviderRunContextTypeSpecific = } | { type: MWMediaType.SERIES; - episode: number; - season: number; + episode: string; + season: string; }; export type MWProviderRunContext = MWProviderRunContextBase & diff --git a/src/backend/index.ts b/src/backend/index.ts index 7140fe13..46764b1f 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -2,6 +2,7 @@ import { initializeScraperStore } from "./helpers/register"; // providers import "./providers/gdriveplayer"; +import "./providers/flixhq"; // embeds // -- nothing here yet diff --git a/src/backend/metadata/getmeta.ts b/src/backend/metadata/getmeta.ts index fc25fddf..b8717fcc 100644 --- a/src/backend/metadata/getmeta.ts +++ b/src/backend/metadata/getmeta.ts @@ -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 { 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(url, { baseURL: JW_API_BASE }); + } + return { - meta: formatJWMeta(data), + meta: formatJWMeta(data, seasonData), imdbId, tmdbId, }; diff --git a/src/backend/metadata/justwatch.ts b/src/backend/metadata/justwatch.ts index 31aa78ed..b3ef32f7 100644 --- a/src/backend/metadata/justwatch.ts +++ b/src/backend/metadata/justwatch.ts @@ -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, }; } diff --git a/src/backend/metadata/types.ts b/src/backend/metadata/types.ts index afabb970..66bb9c1a 100644 --- a/src/backend/metadata/types.ts +++ b/src/backend/metadata/types.ts @@ -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; diff --git a/src/backend/providers/flixhq.ts b/src/backend/providers/flixhq.ts index e5b5d1fe..493a1c3b 100644 --- a/src/backend/providers/flixhq.ts +++ b/src/backend/providers/flixhq.ts @@ -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((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( + `/${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("/info", { + baseURL: flixHqBase, + params: { + id: flixId, + }, + }); + + // get stream info from media progress(75); - await timeout(1000); + const watchInfo = await proxiedFetch("/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: [], + }, }; }, }); diff --git a/src/components/buttons/EditButton.tsx b/src/components/buttons/EditButton.tsx index 01988b51..a72eb8f8 100644 --- a/src/components/buttons/EditButton.tsx +++ b/src/components/buttons/EditButton.tsx @@ -22,7 +22,7 @@ export function EditButton(props: EditButtonProps) { > {props.editing ? ( - Stop editing + Stop editing ) : ( )} diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 0a72d463..66a53ed2 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -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 {content}; diff --git a/src/components/video/controls/ShowControl.tsx b/src/components/video/controls/ShowControl.tsx index 5e2467e9..5dafdf45 100644 --- a/src/components/video/controls/ShowControl.tsx +++ b/src/components/video/controls/ShowControl.tsx @@ -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; } diff --git a/src/components/video/controls/VolumeControl.tsx b/src/components/video/controls/VolumeControl.tsx index cfad9441..99ec160f 100644 --- a/src/components/video/controls/VolumeControl.tsx +++ b/src/components/video/controls/VolumeControl.tsx @@ -63,7 +63,7 @@ export function VolumeControl(props: Props) {
diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 563a8643..6ab1374c 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -16,6 +16,11 @@ function App() { + diff --git a/src/views/media/MediaErrorView.tsx b/src/views/media/MediaErrorView.tsx index df5e9943..f9b98280 100644 --- a/src/views/media/MediaErrorView.tsx +++ b/src/views/media/MediaErrorView.tsx @@ -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 (
+ + Failed to load meta +
diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index fb3583a6..5265728f 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -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 (
+ + Loading... +
@@ -51,6 +55,9 @@ function MediaViewScraping(props: MediaViewScrapingProps) { return (
+ + {props.meta.meta.title} +
@@ -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 (
+ + {props.meta.meta.title} + - + {props.selected.type === MWMediaType.SERIES ? ( + console.log("selected stuff", d)} + /> + ) : null}
); } 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(null); const [selected, setSelected] = useState(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(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 ; if (error) return ; @@ -167,5 +207,5 @@ export function MediaView() { ); // show stream once we have a stream - return ; + return ; } diff --git a/src/views/notfound/NotFoundView.tsx b/src/views/notfound/NotFoundView.tsx index 49584bb3..00f4eddc 100644 --- a/src/views/notfound/NotFoundView.tsx +++ b/src/views/notfound/NotFoundView.tsx @@ -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 (
+ + Not found + {props.video ? (
diff --git a/src/views/search/SearchResultsPartial.tsx b/src/views/search/SearchResultsPartial.tsx index d7859612..63250193 100644 --- a/src/views/search/SearchResultsPartial.tsx +++ b/src/views/search/SearchResultsPartial.tsx @@ -13,7 +13,7 @@ export function SearchResultsPartial({ search }: SearchResultsPartialProps) { const [searching, setSearching] = useState(false); const [loading, setLoading] = useState(false); - const debouncedSearch = useDebounce(search, 2000); + const debouncedSearch = useDebounce(search, 500); useEffect(() => { setSearching(search.searchQuery !== ""); setLoading(search.searchQuery !== ""); diff --git a/src/views/search/SearchView.tsx b/src/views/search/SearchView.tsx index ad61b696..f2fc661e 100644 --- a/src/views/search/SearchView.tsx +++ b/src/views/search/SearchView.tsx @@ -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 ( <>
+ + movie-web +
diff --git a/yarn.lock b/yarn.lock index 50e52b88..8378343c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"