diff options
| author | Factiven <[email protected]> | 2023-12-24 13:03:54 +0700 |
|---|---|---|
| committer | Factiven <[email protected]> | 2023-12-24 13:03:54 +0700 |
| commit | 50a0f0240d7fef133eb5acc1bea2b1168b08e9db (patch) | |
| tree | 307e09e505580415a58d64b5fc3580e9235869f1 | |
| parent | Update README.md (#104) (diff) | |
| download | moopa-50a0f0240d7fef133eb5acc1bea2b1168b08e9db.tar.xz moopa-50a0f0240d7fef133eb5acc1bea2b1168b08e9db.zip | |
migrate to typescript
124 files changed, 6648 insertions, 2975 deletions
@@ -8,8 +8,11 @@ # testing /coverage -/pages/en/test.js +/pages/en/test.tsx +/pages/en/test-player.tsx /components/devComp +/components/test +/pages/en/w2g.tsx # next.js /.next/ @@ -27,6 +30,7 @@ docker-compose.yml /assets/dummyData.json /backup release-template.md +.vscode # debug npm-debug.log* @@ -22,6 +22,8 @@ </p> +> ⚠️ **DISCLAIMER**: This branch is not stable. Any errors or issues encountered while using this code will not be supported or addressed by me. Use this code at your own risk. I will not provide assistance for any problems that arise from using this code. + <p align="center"> <img src="https://github.com/Ani-Moopa/Moopa/assets/97084324/c17d5d6a-36a2-4d08-957d-ad4683dcdf0d" alt="main"> </p> @@ -52,7 +54,7 @@ </details> -> **Warning:** If you are not familiar with JavaScript or any other programming language related to this project, please learn it first before attempting to work on this project. **I won't be able to help anyone who doesn't know how to do basic stuff.** +> **Warning:** If you are not familiar with JavaScript or any other programming language related to this project, please learn it first before attempting to work on this project. **I won't help anyone who doesn't know how to do basic stuff.** ## Introduction @@ -146,10 +148,6 @@ https://your-website-domain/api/auth/callback/AniListProvider ```bash npx prisma migrate dev npx prisma generate - -### NOTE -# If you get a vercel build error related to prisma that says prisma detected but no initialized just change the following line in package.json line number 8 -"build": "next build" to > "build": "npx prisma migrate deploy && npx prisma generate && next build" ``` 6. Start local server : diff --git a/components/anime/episode.js b/components/anime/episode.js index 3650944..f35df10 100644 --- a/components/anime/episode.js +++ b/components/anime/episode.js @@ -6,29 +6,45 @@ import ThumbnailDetail from "./viewMode/thumbnailDetail"; import ListMode from "./viewMode/listMode"; import { toast } from "sonner"; -function allProvider(response, setMapProviders, setProviderId) { - const getMap = response.find((i) => i?.map === true); - let allProvider = response; +const ITEMS_PER_PAGE = 13; +const DEFAULT_VIEW = 3; - if (getMap) { - allProvider = response.filter((i) => { +const fetchEpisodes = async (info, isDub, refresh = false) => { + const response = await fetch( + `/api/v2/episode/${info.id}?releasing=${ + info.status === "RELEASING" ? "true" : "false" + }${isDub ? "&dub=true" : ""}${refresh ? "&refresh=true" : ""}` + ).then((res) => res.json()); + + const providers = filterProviders(response); + + return providers; +}; + +const filterProviders = (response) => { + const providersWithMap = response.find((i) => i?.map === true); + let providers = response; + + if (providersWithMap) { + providers = response.filter((i) => { if (i?.providerId === "gogoanime" && i?.map !== true) { return null; } return i; }); - setMapProviders(getMap?.episodes); } - if (allProvider.length > 0) { - const defaultProvider = allProvider.find( + return providers; +}; + +const setDefaultProvider = (providers, setProviderId) => { + if (providers.length > 0) { + const defaultProvider = providers.find( (x) => x.providerId === "gogoanime" || x.providerId === "9anime" ); - setProviderId(defaultProvider?.providerId || allProvider[0].providerId); // set to first provider id + setProviderId(defaultProvider?.providerId || providers[0].providerId); } - - return allProvider; -} +}; export default function AnimeEpisode({ info, @@ -48,20 +64,13 @@ export default function AnimeEpisode({ const [isDub, setIsDub] = useState(false); const [providers, setProviders] = useState(null); - const [mapProviders, setMapProviders] = useState(null); useEffect(() => { setLoading(true); const fetchData = async () => { - const response = await fetch( - `/api/v2/episode/${info.id}?releasing=${ - info.status === "RELEASING" ? "true" : "false" - }${isDub ? "&dub=true" : ""}` - ).then((res) => res.json()); - - const providers = allProvider(response, setMapProviders, setProviderId); - - setView(Number(localStorage.getItem("view")) || 3); + const providers = await fetchEpisodes(info, isDub); + setDefaultProvider(providers, setProviderId); + setView(Number(localStorage.getItem("view")) || DEFAULT_VIEW); setArtStorage(JSON.parse(localStorage.getItem("artplayer_settings"))); setProviders(providers); setLoading(false); @@ -71,20 +80,16 @@ export default function AnimeEpisode({ return () => { setCurrentPage(1); setProviders(null); - setMapProviders(null); }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [info.id, isDub]); + }, [info.id, isDub]); // eslint-disable-next-line react-hooks/exhaustive-deps const episodes = - providers - ?.find((provider) => provider.providerId === providerId) - ?.episodes?.slice(0, mapProviders?.length) || []; + providers?.find((provider) => provider.providerId === providerId) + ?.episodes || []; const lastEpisodeIndex = currentPage * itemsPerPage; const firstEpisodeIndex = lastEpisodeIndex - itemsPerPage; - let currentEpisodes = episodes.slice(firstEpisodeIndex, lastEpisodeIndex); + let currentEpisodes = episodes?.slice(firstEpisodeIndex, lastEpisodeIndex); const totalPages = Math.ceil(episodes.length / itemsPerPage); @@ -98,9 +103,10 @@ export default function AnimeEpisode({ useEffect(() => { if ( - !mapProviders || - mapProviders?.every( + !currentEpisodes || + currentEpisodes?.every( (item) => + // item?.img?.includes("null") || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item?.img === null @@ -173,67 +179,13 @@ export default function AnimeEpisode({ setLoading(true); clearTimeout(debounceTimeout); debounceTimeout = setTimeout(async () => { - const res = await fetch( - `/api/v2/episode/${info.id}?releasing=${ - info.status === "RELEASING" ? "true" : "false" - }${isDub ? "&dub=true" : ""}&refresh=true` - ); - if (!res.ok) { - const json = await res.json(); - if (res.status === 429) { - toast.error(json.error); - const resp = await fetch( - `/api/v2/episode/${info.id}?releasing=${ - info.status === "RELEASING" ? "true" : "false" - }${isDub ? "&dub=true" : ""}` - ).then((res) => res.json()); - - if (resp) { - const providers = allProvider( - resp, - setMapProviders, - setProviderId - ); - setProviders(providers); - } - } else { - toast.error("Something went wrong"); - setProviders([]); - } - setLoading(false); - } else { - const remainingRequests = res.headers.get("X-RateLimit-Remaining"); - toast.success("Remaining requests " + remainingRequests); - - const data = await res.json(); - const getMap = data.find((i) => i?.map === true) || data[0]; - let allProvider = data; - - if (getMap) { - allProvider = data.filter((i) => { - if (i?.providerId === "gogoanime" && i?.map !== true) { - return null; - } - return i; - }); - setMapProviders(getMap?.episodes); - } - - if (allProvider.length > 0) { - const defaultProvider = allProvider.find( - (x) => x.providerId === "gogoanime" || x.providerId === "9anime" - ); - setProviderId( - defaultProvider?.providerId || allProvider[0].providerId - ); // set to first provider id - } - - setView(Number(localStorage.getItem("view")) || 3); - setArtStorage(JSON.parse(localStorage.getItem("artplayer_settings"))); - setProviders(allProvider); - setLoading(false); - } - }, 1000); + const providers = await fetchEpisodes(info, isDub, true); + setDefaultProvider(providers, setProviderId); + setView(Number(localStorage.getItem("view")) || DEFAULT_VIEW); + setArtStorage(JSON.parse(localStorage.getItem("artplayer_settings"))); + setProviders(providers); + setLoading(false); + }, 5000); } catch (err) { console.log(err); toast.error("Something went wrong"); @@ -257,7 +209,7 @@ export default function AnimeEpisode({ onClick={() => { handleRefresh(); setProviders(null); - setMapProviders(null); + // setMapProviders(null); }} className="relative flex flex-col items-center w-5 h-5 group" > @@ -376,7 +328,7 @@ export default function AnimeEpisode({ view={view} setView={setView} episode={currentEpisodes} - map={mapProviders} + // map={mapProviders} /> </div> </div> @@ -395,9 +347,9 @@ export default function AnimeEpisode({ {Array.isArray(providers) ? ( providers.length > 0 ? ( currentEpisodes.map((episode, index) => { - const mapData = mapProviders?.find( - (i) => i.number === episode.number - ); + // const mapData = mapProviders?.find( + // (i) => i.number === episode.number + // ); return ( <Fragment key={index}> @@ -406,7 +358,7 @@ export default function AnimeEpisode({ key={index} index={index} info={info} - image={mapData?.img || mapData?.image} + // image={mapData?.img || mapData?.image} providerId={providerId} episode={episode} artStorage={artStorage} @@ -417,9 +369,9 @@ export default function AnimeEpisode({ {view === 2 && ( <ThumbnailDetail key={index} - image={mapData?.img || mapData?.image} - title={mapData?.title} - description={mapData?.description} + // image={mapData?.img || mapData?.image} + // title={mapData?.title} + // description={mapData?.description} index={index} epi={episode} provider={providerId} diff --git a/components/anime/mobile/reused/infoChip.js b/components/anime/mobile/reused/infoChip.tsx index 41def85..80ebf83 100644 --- a/components/anime/mobile/reused/infoChip.js +++ b/components/anime/mobile/reused/infoChip.tsx @@ -1,7 +1,20 @@ -import React from "react"; -import { getFormat } from "../../../../utils/getFormat"; +import React, { CSSProperties, FC } from "react"; +import { getFormat } from "@/utils/getFormat"; -export default function InfoChip({ info, color, className }) { +interface Info { + episodes?: number; + averageScore?: number; + format?: string; + status?: string; +} + +interface InfoChipProps { + info: Info; + color: any; + className: string; +} + +const InfoChip: FC<InfoChipProps> = ({ info, color, className }) => { return ( <div className={`flex-wrap w-full justify-start md:pt-1 gap-4 ${className}`} @@ -40,4 +53,6 @@ export default function InfoChip({ info, color, className }) { )} </div> ); -} +}; + +export default InfoChip; diff --git a/components/anime/mobile/topSection.js b/components/anime/mobile/topSection.tsx index 6780da5..2d28c66 100644 --- a/components/anime/mobile/topSection.js +++ b/components/anime/mobile/topSection.tsx @@ -11,26 +11,36 @@ import { convertSecondsToTime } from "@/utils/getTimes"; import Link from "next/link"; import InfoChip from "./reused/infoChip"; import Description from "./reused/description"; -import { NewNavbar } from "@/components/shared/NavBar"; +import Skeleton from "react-loading-skeleton"; +import { AniListInfoTypes } from "types/info/AnilistInfoTypes"; + +type DetailTopProps = { + info?: AniListInfoTypes | null; + statuses?: any; + handleOpen: () => void; + watchUrl: string | undefined; + progress?: number; + color?: string | null; +}; export default function DetailTop({ info, - statuses, + statuses = undefined, handleOpen, watchUrl, progress, color, -}) { +}: DetailTopProps) { const router = useRouter(); const [readMore, setReadMore] = useState(false); const [showAll, setShowAll] = useState(false); - const isAnime = info.type === "ANIME"; + const isAnime = info?.type === "ANIME"; useEffect(() => { setReadMore(false); - }, [info.id]); + }, [info?.id]); const handleShareClick = async () => { try { @@ -51,78 +61,150 @@ export default function DetailTop({ return ( <div className="gap-6 w-full px-3 pt-4 md:pt-10 flex flex-col items-center justify-center"> - <NewNavbar info={info} /> - {/* MAIN */} <div className="flex flex-col md:flex-row w-full items-center md:items-end gap-5 pt-12"> <div className="shrink-0 w-[180px] h-[250px] rounded overflow-hidden"> - <img - src={info?.coverImage?.extraLarge || info?.coverImage} - alt="poster anime" - width={300} - height={300} - className="w-full h-full object-cover" - /> + {info ? ( + <img + src={ + info?.coverImage?.extraLarge?.toString() ?? + info?.coverImage?.toString() + } + alt="poster anime" + width={300} + height={300} + className="w-full h-full object-cover" + /> + ) : ( + <Skeleton className="h-full" /> + )} </div> <div className="flex flex-col gap-4 items-center md:items-start justify-end w-full"> - <div className="flex flex-col gap-1 text-center md:text-start"> + <div className="flex flex-col gap-1 text-center md:text-start w-full"> <h3 className="font-karla text-lg capitalize leading-none"> {info?.season?.toLowerCase() || getMonth(info?.startDate?.month)}{" "} - {info.seasonYear || info?.startDate?.year} + {info?.seasonYear || info?.startDate?.year} + {!info && <Skeleton height={14} width={140} />} </h3> <h1 className="font-outfit font-extrabold text-2xl md:text-4xl line-clamp-2 text-white"> {info?.title?.romaji || info?.title?.english} + {!info && <Skeleton height={35} width={340} className="" />} </h1> <h2 className="font-karla line-clamp-1 text-sm md:text-lg md:pb-2 font-light text-white/70"> - {info.title?.english} + {info?.title?.english} </h2> - <InfoChip info={info} color={color} className="hidden md:flex" /> - {info?.description && ( - <Description - info={info} - readMore={readMore} - setReadMore={setReadMore} - className="md:block hidden" - /> + {info && ( + <InfoChip info={info} color={color} className="hidden md:flex" /> + )} + {info ? ( + info?.description && ( + <Description + info={info} + readMore={readMore} + setReadMore={setReadMore} + className="md:block hidden" + /> + ) + ) : ( + <div className="w-full md:px-0 md:block hidden"> + <Skeleton className="h-[80px] w-[700px]" /> + </div> )} </div> </div> </div> <div className="hidden md:flex gap-5 items-center justify-start w-full"> - <button - type="button" - onClick={() => router.push(watchUrl)} - className={`${ - !watchUrl ? "opacity-30 pointer-events-none" : "" - } w-[180px] flex-center text-lg font-karla font-semibold gap-2 border-black border-opacity-10 text-black rounded-full py-1 px-4 bg-white hover:opacity-80`} - > - {isAnime ? ( - <PlayIcon className="w-5 h-5" /> - ) : ( - <BookOpenIcon className="w-5 h-5" /> - )} - {progress > 0 ? ( - statuses?.value === "COMPLETED" ? ( - isAnime ? ( - "Rewatch" + {info ? ( + <button + type="button" + onClick={() => router.push(watchUrl ?? "#")} + className={`${ + !watchUrl ? "opacity-30 pointer-events-none" : "" + } w-[180px] flex-center text-lg font-karla font-semibold gap-2 border-black border-opacity-10 text-black rounded-full py-1 px-4 bg-white hover:opacity-80`} + > + {isAnime ? ( + <PlayIcon className="w-5 h-5" /> + ) : ( + <BookOpenIcon className="w-5 h-5" /> + )} + {progress && progress > 0 ? ( + statuses?.value === "COMPLETED" ? ( + isAnime ? ( + "Rewatch" + ) : ( + "Reread" + ) + ) : !watchUrl && info?.nextAiringEpisode ? ( + <span> + {convertSecondsToTime(info.nextAiringEpisode.timeUntilAiring)}{" "} + </span> ) : ( - "Reread" + "Continue" ) - ) : !watchUrl && info?.nextAiringEpisode ? ( - <span> - {convertSecondsToTime(info.nextAiringEpisode.timeUntilAiring)}{" "} - </span> + ) : isAnime ? ( + "Watch Now" ) : ( - "Continue" - ) - ) : isAnime ? ( - "Watch Now" + "Read Now" + )} + </button> + ) : ( + <div className="h-10 w-[180px] bg-secondary flex-center text-lg font-karla font-semibold gap-2 border-black border-opacity-10 text-black rounded-full" /> + )} + <div className="flex gap-2"> + {info ? ( + <button + type="button" + className="flex-center group relative w-10 h-10 bg-secondary rounded-full" + onClick={() => handleOpen()} + > + <span className="absolute pointer-events-none z-40 opacity-0 -translate-y-8 group-hover:-translate-y-10 group-hover:opacity-100 font-karla shadow-tersier shadow-md whitespace-nowrap bg-secondary px-2 py-1 rounded transition-all duration-200 ease-out"> + Add to List + </span> + <PlusIcon className="w-5 h-5" /> + </button> ) : ( - "Read Now" + <div className="w-10 h-10 bg-secondary rounded-full" /> )} - </button> - <div className="flex gap-2"> + {info ? ( + <button + type="button" + className="flex-center group relative w-10 h-10 bg-secondary rounded-full" + onClick={handleShareClick} + > + <span className="absolute pointer-events-none z-40 opacity-0 -translate-y-8 group-hover:-translate-y-10 group-hover:opacity-100 font-karla shadow-tersier shadow-md whitespace-nowrap bg-secondary px-2 py-1 rounded transition-all duration-200 ease-out"> + Share {isAnime ? "Anime" : "Manga"} + </span> + <ShareIcon className="w-5 h-5" /> + </button> + ) : ( + <div className="w-10 h-10 bg-secondary rounded-full" /> + )} + {info ? ( + <a + target="_blank" + rel="noopener noreferrer" + href={`https://anilist.co/${info.type.toLowerCase()}/${info.id}`} + className="flex-center group relative w-10 h-10 bg-secondary rounded-full" + > + <span className="absolute pointer-events-none z-40 opacity-0 -translate-y-8 group-hover:-translate-y-10 group-hover:opacity-100 font-karla shadow-tersier shadow-md whitespace-nowrap bg-secondary px-2 py-1 rounded transition-all duration-200 ease-out"> + See on AniList + </span> + <Image + src="/svg/anilist-icon.svg" + alt="anilist_icon" + width={20} + height={20} + /> + </a> + ) : ( + <div className="w-10 h-10 bg-secondary rounded-full" /> + )} + </div> + </div> + + <div className="md:hidden flex gap-2 items-center justify-center w-[90%]"> + {info ? ( <button type="button" className="flex-center group relative w-10 h-10 bg-secondary rounded-full" @@ -133,6 +215,46 @@ export default function DetailTop({ </span> <PlusIcon className="w-5 h-5" /> </button> + ) : ( + <div className="w-10 h-10 bg-secondary rounded-full" /> + )} + {info ? ( + <button + type="button" + onClick={() => router.push(watchUrl ?? "#")} + className={`${ + !watchUrl ? "opacity-30 pointer-events-none" : "" + } flex items-center text-lg font-karla font-semibold gap-1 border-black border-opacity-10 text-black rounded-full py-2 px-4 bg-white`} + > + {isAnime ? ( + <PlayIcon className="w-5 h-5" /> + ) : ( + <BookOpenIcon className="w-5 h-5" /> + )} + {progress && progress > 0 ? ( + statuses?.value === "COMPLETED" ? ( + isAnime ? ( + "Rewatch" + ) : ( + "Reread" + ) + ) : !watchUrl && info?.nextAiringEpisode ? ( + <span> + {convertSecondsToTime(info.nextAiringEpisode.timeUntilAiring)}{" "} + </span> + ) : ( + "Continue" + ) + ) : isAnime ? ( + "Watch Now" + ) : ( + "Read Now" + )} + </button> + ) : ( + <div className="h-10 w-32 bg-secondary flex-center text-lg font-karla font-semibold gap-2 border-black border-opacity-10 text-black rounded-full" /> + )} + {info ? ( <button type="button" className="flex-center group relative w-10 h-10 bg-secondary rounded-full" @@ -143,81 +265,12 @@ export default function DetailTop({ </span> <ShareIcon className="w-5 h-5" /> </button> - <a - target="_blank" - rel="noopener noreferrer" - href={`https://anilist.co/${info.type.toLowerCase()}/${info.id}`} - className="flex-center group relative w-10 h-10 bg-secondary rounded-full" - > - <span className="absolute pointer-events-none z-40 opacity-0 -translate-y-8 group-hover:-translate-y-10 group-hover:opacity-100 font-karla shadow-tersier shadow-md whitespace-nowrap bg-secondary px-2 py-1 rounded transition-all duration-200 ease-out"> - See on AniList - </span> - <Image - src="/svg/anilist-icon.svg" - alt="anilist_icon" - width={20} - height={20} - /> - </a> - </div> + ) : ( + <div className="w-10 h-10 bg-secondary rounded-full" /> + )} </div> - <div className="md:hidden flex gap-2 items-center justify-center w-[90%]"> - <button - type="button" - className="flex-center group relative w-10 h-10 bg-secondary rounded-full" - onClick={() => handleOpen()} - > - <span className="absolute pointer-events-none z-40 opacity-0 -translate-y-8 group-hover:-translate-y-10 group-hover:opacity-100 font-karla shadow-tersier shadow-md whitespace-nowrap bg-secondary px-2 py-1 rounded transition-all duration-200 ease-out"> - Add to List - </span> - <PlusIcon className="w-5 h-5" /> - </button> - <button - type="button" - onClick={() => router.push(watchUrl)} - className={`${ - !watchUrl ? "opacity-30 pointer-events-none" : "" - } flex items-center text-lg font-karla font-semibold gap-1 border-black border-opacity-10 text-black rounded-full py-2 px-4 bg-white`} - > - {isAnime ? ( - <PlayIcon className="w-5 h-5" /> - ) : ( - <BookOpenIcon className="w-5 h-5" /> - )} - {progress > 0 ? ( - statuses?.value === "COMPLETED" ? ( - isAnime ? ( - "Rewatch" - ) : ( - "Reread" - ) - ) : !watchUrl && info?.nextAiringEpisode ? ( - <span> - {convertSecondsToTime(info.nextAiringEpisode.timeUntilAiring)}{" "} - </span> - ) : ( - "Continue" - ) - ) : isAnime ? ( - "Watch Now" - ) : ( - "Read Now" - )} - </button> - <button - type="button" - className="flex-center group relative w-10 h-10 bg-secondary rounded-full" - onClick={handleShareClick} - > - <span className="absolute pointer-events-none z-40 opacity-0 -translate-y-8 group-hover:-translate-y-10 group-hover:opacity-100 font-karla shadow-tersier shadow-md whitespace-nowrap bg-secondary px-2 py-1 rounded transition-all duration-200 ease-out"> - Share {isAnime ? "Anime" : "Manga"} - </span> - <ShareIcon className="w-5 h-5" /> - </button> - </div> - - {info.nextAiringEpisode?.timeUntilAiring && ( + {info && info.nextAiringEpisode?.timeUntilAiring && ( <p className="md:hidden"> Episode {info.nextAiringEpisode.episode} in{" "} <span className="font-bold"> @@ -226,7 +279,7 @@ export default function DetailTop({ </p> )} - {info?.description && ( + {info && info?.description && ( <Description info={info} readMore={readMore} @@ -235,13 +288,15 @@ export default function DetailTop({ /> )} - <InfoChip - info={info} - color={color} - className={`${readMore ? "flex" : "hidden"} md:hidden`} - /> + {info && ( + <InfoChip + info={info} + color={color} + className={`${readMore ? "flex" : "hidden"} md:hidden`} + /> + )} - {info?.relations?.edges?.length > 0 && ( + {info && info?.relations?.edges?.length > 0 && ( <div className="w-screen md:w-full"> <div className="flex justify-between items-center p-3 md:p-0"> {info?.relations?.edges?.length > 0 && ( @@ -288,7 +343,7 @@ export default function DetailTop({ <div className="w-[90px] bg-image rounded-l-md shrink-0"> <Image src={rel.coverImage.extraLarge} - alt={rel.id} + alt={rel.id.toString()} height={500} width={500} className="object-cover h-full w-full shrink-0 rounded-l-md" @@ -314,7 +369,7 @@ export default function DetailTop({ ); } -function getMonth(month) { +function getMonth(month: number | undefined) { if (!month) return ""; const formattedMonth = new Date(0, month).toLocaleString("default", { month: "long", diff --git a/components/anime/viewMode/thumbnailDetail.js b/components/anime/viewMode/thumbnailDetail.js index d8cbfcc..f955fec 100644 --- a/components/anime/viewMode/thumbnailDetail.js +++ b/components/anime/viewMode/thumbnailDetail.js @@ -1,3 +1,4 @@ +import { parseImageProxy } from "@/utils/imageUtils"; import Image from "next/image"; import Link from "next/link"; @@ -5,7 +6,7 @@ export default function ThumbnailDetail({ index, epi, info, - image, + // image, title, description, provider, @@ -18,10 +19,10 @@ export default function ThumbnailDetail({ let prog = (time / duration) * 100; if (prog > 90) prog = 100; - const parsedImage = image - ? image?.includes("null") + const parsedImage = epi?.img + ? epi?.img?.includes("null") ? info.coverImage?.extraLarge - : image + : epi?.img : info.coverImage?.extraLarge || null; return ( @@ -36,7 +37,12 @@ export default function ThumbnailDetail({ <div className="relative"> {parsedImage && ( <Image - src={parsedImage || ""} + src={ + parseImageProxy( + parsedImage, + provider === "animepahe" ? "https://animepahe.ru" : undefined + ) || "" + } alt={`Episode ${epi?.number} Thumbnail`} width={520} height={236} @@ -74,11 +80,11 @@ export default function ThumbnailDetail({ className={`w-[70%] h-full select-none p-4 flex flex-col justify-center gap-3`} > <h1 className="font-karla font-bold text-base lg:text-lg xl:text-xl italic line-clamp-1"> - {title || `Episode ${epi?.number || 0}`} + {epi?.title || `Episode ${epi?.number || 0}`} </h1> - {description && ( + {epi?.description && ( <p className="line-clamp-2 text-xs lg:text-md xl:text-lg italic font-outfit font-extralight"> - {description} + {epi?.description} </p> )} </div> diff --git a/components/anime/viewMode/thumbnailOnly.js b/components/anime/viewMode/thumbnailOnly.js index c7fe674..06a92f5 100644 --- a/components/anime/viewMode/thumbnailOnly.js +++ b/components/anime/viewMode/thumbnailOnly.js @@ -1,9 +1,10 @@ import Image from "next/image"; import Link from "next/link"; +import { parseImageProxy } from "../../../utils/imageUtils"; export default function ThumbnailOnly({ info, - image, + // image, providerId, episode, artStorage, @@ -15,10 +16,10 @@ export default function ThumbnailOnly({ let prog = (time / duration) * 100; if (prog > 90) prog = 100; - const parsedImage = image - ? image?.includes("null") + const parsedImage = episode?.img + ? episode?.img?.includes("null") ? info.coverImage?.extraLarge - : image + : episode?.img : info.coverImage?.extraLarge || null; return ( <Link @@ -45,7 +46,12 @@ export default function ThumbnailOnly({ {/* <div className="absolute inset-0 bg-black z-30 opacity-20" /> */} {parsedImage && ( <Image - src={parsedImage || ""} + src={ + parseImageProxy( + parsedImage, + providerId === "animepahe" ? "https://animepahe.ru" : undefined + ) || "" + } alt={`Episode ${episode?.number} Thumbnail`} width={500} height={500} diff --git a/components/anime/viewSelector.js b/components/anime/viewSelector.js index baa13b2..c2ca327 100644 --- a/components/anime/viewSelector.js +++ b/components/anime/viewSelector.js @@ -4,13 +4,14 @@ export default function ViewSelector({ view, setView, episode, map }) { <div className={ episode?.length > 0 - ? map?.every( + ? episode?.every( (item) => item?.img === null || + item?.img?.includes("null") || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item.title === null - ) || !map + ) || !episode ? "pointer-events-none" : "cursor-pointer" : "pointer-events-none" @@ -32,13 +33,14 @@ export default function ViewSelector({ view, setView, episode, map }) { height="20" className={`${ episode?.length > 0 - ? map?.every( + ? episode?.every( (item) => item?.img === null || + item?.img?.includes("null") || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item.title === null - ) || !map + ) || !episode ? "fill-[#1c1c22]" : view === 1 ? "fill-action" @@ -52,13 +54,14 @@ export default function ViewSelector({ view, setView, episode, map }) { <div className={ episode?.length > 0 - ? map?.every( + ? episode?.every( (item) => item?.img === null || + item?.img?.includes("null") || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item.title === null - ) || !map + ) || !episode ? "pointer-events-none" : "cursor-pointer" : "pointer-events-none" @@ -75,13 +78,14 @@ export default function ViewSelector({ view, setView, episode, map }) { fill="none" className={`${ episode?.length > 0 - ? map?.every( + ? episode?.every( (item) => item?.img === null || + item?.img?.includes("null") || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item.title === null - ) || !map + ) || !episode ? "fill-[#1c1c22]" : view === 2 ? "fill-action" diff --git a/components/disqus.js b/components/disqus.tsx index b814851..dca03e2 100644 --- a/components/disqus.js +++ b/components/disqus.tsx @@ -1,6 +1,15 @@ import { DiscussionEmbed } from "disqus-react"; -const DisqusComments = ({ post }) => { +type DisqusCommentsProps = { + post: { + name: string; + url: string; + title: string; + episode: number; + }; +}; + +const DisqusComments = ({ post }: DisqusCommentsProps) => { const disqusShortname = post.name || "your_disqus_shortname"; const disqusConfig = { url: post.url, diff --git a/components/home/content.js b/components/home/content.tsx index d2498f6..b193381 100644 --- a/components/home/content.js +++ b/components/home/content.tsx @@ -15,6 +15,63 @@ import HistoryOptions from "./content/historyOptions"; import { toast } from "sonner"; import { truncateImgUrl } from "@/utils/imageUtils"; +type ContentProps = { + ids: string; + section: string; + data?: any; + userData?: UserDataTypes[]; + og?: any; + userName?: string; + setRemoved?: any; + type?: string; +}; + +type UserDataTypes = { + id: string; + aniId?: string; + title?: string; + aniTitle?: string; + image?: string; + episode?: number; + timeWatched?: number; + duration?: number; + provider?: string; + nextId?: string; + nextNumber?: number; + dub?: boolean; + createdDate: string; + userProfileId: string; + watchId: string; +}; + +interface SlicedDataTypes { + id: string | number; + slug?: string; + nextAiringEpisode?: any; + currentEpisode?: number; + idMal: number; + status: string; + title: Title; + bannerImage: string; + coverImage: CoverImage | string; + image?: string; + episodeNumber?: number; + description: string; +} + +interface Title { + romaji: string; + english: string; + native: string; +} + +interface CoverImage { + extraLarge: string; + large: string; + medium: string; + color?: string; +} + export default function Content({ ids, section, @@ -24,12 +81,12 @@ export default function Content({ userName, setRemoved, type = "anime", -}) { - const router = useRouter(); - - const ref = useRef(); +}: ContentProps) { + const ref = useRef<HTMLElement>(null!); const { events } = useDraggable(ref); + const router = useRouter(); + const [clicked, setClicked] = useState(false); useEffect(() => { @@ -45,19 +102,27 @@ export default function Content({ const [scrollRight, setScrollRight] = useState(true); const slideLeft = () => { - ref.current.classList.add("scroll-smooth"); - var slider = document.getElementById(ids); - slider.scrollLeft = slider.scrollLeft - 500; - ref.current.classList.remove("scroll-smooth"); + if (ref.current) { + ref.current.classList.add("scroll-smooth"); + var slider = document.getElementById(ids); + if (slider?.scrollLeft) { + slider.scrollLeft = slider.scrollLeft - 500; + } + ref.current.classList.remove("scroll-smooth"); + } }; const slideRight = () => { - ref.current.classList.add("scroll-smooth"); - var slider = document.getElementById(ids); - slider.scrollLeft = slider.scrollLeft + 500; - ref.current.classList.remove("scroll-smooth"); + if (ref.current) { + ref.current.classList.add("scroll-smooth"); + var slider = document.getElementById(ids); + if (slider?.scrollLeft) { + slider.scrollLeft = slider.scrollLeft + 500; + } + ref.current.classList.remove("scroll-smooth"); + } }; - const handleScroll = (e) => { + const handleScroll = (e: any) => { const scrollLeft = e.target.scrollLeft > 31; const scrollRight = e.target.scrollLeft < e.target.scrollWidth - e.target.clientWidth; @@ -65,10 +130,12 @@ export default function Content({ setScrollRight(scrollRight); }; - function handleAlert(e) { + function handleAlert(e: string) { if (localStorage.getItem("clicked")) { const existingDataString = localStorage.getItem("clicked"); - const existingData = JSON.parse(existingDataString); + const existingData = existingDataString + ? JSON.parse(existingDataString) + : {}; existingData[e] = true; @@ -87,8 +154,8 @@ export default function Content({ } const array = data; - let filteredData = array?.filter((item) => item !== null); - const slicedData = + let filteredData = array?.filter((item: any) => item !== null); + const slicedData: SlicedDataTypes[] = filteredData?.length > 15 ? filteredData?.slice(0, 15) : filteredData; const goToPage = () => { @@ -112,7 +179,7 @@ export default function Content({ } }; - const removeItem = async (id, aniId) => { + const removeItem = async (id: string, aniId: string) => { if (userName) { // remove from database const res = await fetch(`/api/user/update/episode`, { @@ -131,7 +198,7 @@ export default function Content({ if (id) { // remove from local storage const artplayerSettings = - JSON.parse(localStorage.getItem("artplayer_settings")) || {}; + JSON.parse(localStorage.getItem("artplayer_settings") || "{}") || {}; if (artplayerSettings[id]) { delete artplayerSettings[id]; localStorage.setItem( @@ -142,9 +209,9 @@ export default function Content({ } if (aniId) { const currentData = - JSON.parse(localStorage.getItem("artplayer_settings")) || {}; + JSON.parse(localStorage.getItem("artplayer_settings") || "{}") || {}; - const updatedData = {}; + const updatedData: { [key: string]: any } = {}; for (const key in currentData) { const item = currentData[key]; @@ -166,7 +233,7 @@ export default function Content({ if (id) { // remove from local storage const artplayerSettings = - JSON.parse(localStorage.getItem("artplayer_settings")) || {}; + JSON.parse(localStorage.getItem("artplayer_settings") || "{}") || {}; if (artplayerSettings[id]) { delete artplayerSettings[id]; localStorage.setItem( @@ -178,10 +245,10 @@ export default function Content({ } if (aniId) { const currentData = - JSON.parse(localStorage.getItem("artplayer_settings")) || {}; + JSON.parse(localStorage.getItem("artplayer_settings") || "{}") || {}; // Create a new object to store the updated data - const updatedData = {}; + const updatedData: { [key: string]: any } = {}; // Iterate through the current data and copy items with different aniId to the updated object for (const key in currentData) { @@ -223,11 +290,22 @@ export default function Content({ className="flex h-full w-full select-none overflow-x-scroll overflow-y-hidden scrollbar-hide lg:gap-8 gap-4 lg:p-10 py-8 px-5 z-30" onScroll={handleScroll} {...events} - ref={ref} + ref={ref as React.RefObject<HTMLDivElement>} > {ids !== "recentlyWatched" ? slicedData?.map((anime) => { - const progress = og?.find((i) => i.mediaId === anime.id); + const progress = og?.find((i: any) => i.mediaId === anime.id); + + let image; + if (typeof anime.coverImage === "string") { + image = truncateImgUrl(anime.coverImage); + } else if (anime.coverImage) { + image = anime.coverImage.extraLarge || anime.coverImage.large; + } + + if (!image && anime.image) { + image = anime.image; + } return ( <div @@ -238,6 +316,14 @@ export default function Content({ href={ ids === "listManga" ? `/en/manga/${anime.id}` + : ids === "recentAdded" + ? anime?.slug + ? `/en/anime/watch/${ + anime.id + }/gogoanime?id=${encodeURIComponent( + anime?.slug + )}&num=${anime.currentEpisode}` + : `/en/${type}/${anime.id}` : `/en/${type}/${anime.id}` } className="hover:scale-105 hover:shadow-lg duration-300 ease-out group relative" @@ -255,7 +341,7 @@ export default function Content({ )} {checkProgress(progress) && ( <div - onClick={() => handleAlert(anime.id)} + onClick={() => handleAlert(String(anime.id))} className="group-hover:visible invisible absolute top-0 bg-black bg-opacity-20 w-full h-full z-20 text-center" > <h1 className="text-[12px] lg:text-sm pt-28 lg:pt-44 font-bold opacity-100"> @@ -282,31 +368,20 @@ export default function Content({ {ids === "recentAdded" && ( <div className="absolute bg-gradient-to-b from-black/30 to-transparent from-5% to-30% top-0 z-30 w-full h-full rounded" /> )} - <Image - draggable={false} - src={ - anime.image || - anime.coverImage?.extraLarge || - anime.coverImage?.large || - truncateImgUrl(anime?.coverImage) || - "https://cdn.discordapp.com/attachments/986579286397964290/1058415946945003611/gray_pfp.png" - } - alt={ - anime.title.romaji || - anime.title.english || - "coverImage" - } - width={500} - height={300} - placeholder="blur" - blurDataURL={ - anime.image || - anime.coverImage?.extraLarge || - anime.coverImage?.large || - "https://cdn.discordapp.com/attachments/986579286397964290/1058415946945003611/gray_pfp.png" - } - className="z-20 h-[190px] w-[135px] lg:h-[265px] lg:w-[185px] object-cover rounded-md brightness-90" - /> + {image && ( + <Image + draggable={false} + src={image} + alt={ + anime.title.romaji || + anime.title.english || + "coverImage" + } + width={500} + height={300} + className="z-20 h-[190px] w-[135px] lg:h-[265px] lg:w-[185px] object-cover rounded-md brightness-90" + /> + )} </div> {ids === "recentAdded" && ( <Fragment> @@ -356,7 +431,7 @@ export default function Content({ .map((i) => { const time = i.timeWatched; const duration = i.duration; - let prog = (time / duration) * 100; + let prog = time && duration ? (time / duration) * 100 : 0; if (prog > 90) prog = 100; return ( @@ -378,9 +453,11 @@ export default function Content({ router.push( `/en/anime/watch/${i.aniId}/${ i.provider - }?id=${encodeURIComponent(i?.nextId)}&num=${ - i?.nextNumber - }${i?.dub ? `&dub=${i?.dub}` : ""}` + }?id=${encodeURIComponent( + i?.nextId || "" + )}&num=${i?.nextNumber}${ + i?.dub ? `&dub=${i?.dub}` : "" + }` ); }} > @@ -404,11 +481,11 @@ export default function Content({ <PlayIcon className="w-5 h-5 shrink-0" /> <h1 className="font-semibold font-karla line-clamp-1" - title={i?.title || i.anititle} + title={i?.title || i?.aniTitle} > {i?.title === i.aniTitle ? `Episode ${i.episode}` - : i?.title || i.anititle} + : i?.title || i?.aniTitle} </h1> </div> <span @@ -456,7 +533,8 @@ export default function Content({ </div> ); })} - {userData?.filter((i) => i.aniId !== null)?.length >= 10 && + {userData && + userData?.filter((i) => i.aniId !== null)?.length >= 10 && section !== "Recommendations" && ( <div key={section} @@ -498,7 +576,7 @@ export default function Content({ ); } -function convertSecondsToTime(sec) { +function convertSecondsToTime(sec: number) { let days = Math.floor(sec / (3600 * 24)); let hours = Math.floor((sec % (3600 * 24)) / 3600); let minutes = Math.floor((sec % 3600) / 60); @@ -516,7 +594,7 @@ function convertSecondsToTime(sec) { return time.trim(); } -function checkProgress(entry) { +function checkProgress(entry: { progress: any; media: any }) { const { progress, media } = entry; const { episodes, nextAiringEpisode } = media; diff --git a/components/home/recommendation.js b/components/home/recommendation.js index 842932c..b643456 100644 --- a/components/home/recommendation.js +++ b/components/home/recommendation.js @@ -1,13 +1,22 @@ import Image from "next/image"; // import data from "../../assets/dummyData.json"; -import { BookOpenIcon, PlayIcon } from "@heroicons/react/24/solid"; +import { + BookOpenIcon as BookOpenSolid, + PlayIcon, +} from "@heroicons/react/24/solid"; import { useDraggable } from "react-use-draggable-scroll"; import { useRef } from "react"; import Link from "next/link"; +import { + BookOpenIcon as BookOpenOutline, + PlayCircleIcon, +} from "@heroicons/react/24/outline"; export default function UserRecommendation({ data }) { - const ref = useRef(null); - const { events } = useDraggable(ref); + const mobileRef = useRef(null); + const desktopRef = useRef(null); + const { events: mobileEvent } = useDraggable(mobileRef); + const { events: desktopEvent } = useDraggable(desktopRef); const uniqueRecommendationIds = new Set(); @@ -25,10 +34,13 @@ export default function UserRecommendation({ data }) { }); return ( - <div className="flex flex-col bg-tersier relative rounded overflow-hidden"> - <div className="flex lg:gap-5 z-50"> + <div className="flex flex-col lg:bg-tersier relative rounded overflow-hidden"> + <div className="hidden lg:flex lg:gap-5 z-50"> <div className="flex flex-col items-start justify-center gap-3 lg:gap-7 lg:w-[50%] pl-5 lg:px-10"> - <h2 className="font-bold text-3xl text-white"> + <h2 + className="font-inter font-bold text-3xl text-white line-clamp-2" + title={data[0].title.userPreferred} + > {data[0].title.userPreferred} </h2> <p @@ -37,53 +49,128 @@ export default function UserRecommendation({ data }) { }} className="font-roboto font-light line-clamp-3 lg:line-clamp-3" /> - <button - type="button" + <Link + href={`/en/${data[0].type.toLowerCase()}/${data[0].id}`} className="border border-white/70 py-1 px-2 lg:py-2 lg:px-4 rounded-full flex items-center gap-2 text-white font-bold" > {data[0].type === "ANIME" ? ( <PlayIcon className="w-5 h-5 text-white" /> ) : ( - <BookOpenIcon className="w-5 h-5 text-white" /> + <BookOpenSolid className="w-5 h-5 text-white" /> )} {data[0].type === "ANIME" ? "Watch" : "Read"} Now - </button> + </Link> </div> <div id="recommendation-list" className="flex gap-5 overflow-x-scroll scrollbar-none px-5 py-7 lg:py-10" - ref={ref} - {...events} + ref={desktopRef} + {...desktopEvent} > {filteredData.slice(0, 9).map((i) => ( <Link - key={i.id} + key={`desktop-${i.id}`} href={`/en/${i.type.toLowerCase()}/${i.id}`} - className="relative snap-start shrink-0 group hover:bg-white/20 p-1 rounded" + className="relative flex-center snap-start shrink-0 group rounded" > + <span className="h-[190px] w-[135px] lg:h-[265px] lg:w-[185px] rounded absolute bg-gradient-to-b from-black/50 from-5% to-30% to-transparent z-40" /> + <span className="h-[190px] w-[135px] lg:h-[265px] lg:w-[185px] rounded absolute group-hover:bg-gradient-to-t from-black/90 to-transparent z-40 opacity-0 group-hover:opacity-100 transition-all duration-200 ease" /> + <span + title={i.title.userPreferred} + className="absolute bottom-5 text-center line-clamp-2 font-karla font-semibold opacity-0 group-hover:opacity-100 w-[70%] z-50 transition-all duration-200 ease" + > + {i.title.userPreferred} + </span> + <div className="absolute top-0 right-0 z-40 font-karla font-bold"> + {i.type === "ANIME" ? ( + <span className="flex items-center px-2 py-1 gap-1 text-sm text-white"> + <PlayCircleIcon className="w-5 h-5" /> + </span> + ) : ( + <span className="flex items-center px-2 py-1 gap-1 text-sm text-white"> + <BookOpenOutline className="w-5 h-5" /> + </span> + )} + </div> <Image src={i.coverImage.extraLarge} alt={i.title.userPreferred} width={190} height={256} - className="h-[190px] w-[135px] lg:h-[265px] lg:w-[185px] rounded-md object-cover overflow-hidden transition-all duration-150 ease-in-out" + className="h-[190px] w-[135px] lg:h-[265px] lg:w-[185px] brightness-[90%] rounded-md object-cover overflow-hidden transition-all duration-150 ease-in-out" /> - <span className="absolute rounded pointer-events-none w-[240px] h-[50%] transition-all duration-150 ease-in transform translate-x-[75%] group-hover:translate-x-[80%] top-0 left-0 bg-secondary opacity-0 group-hover:opacity-100 flex flex-col z-50"> + {/* <span className="absolute rounded pointer-events-none w-[240px] h-[50%] transition-all duration-150 ease-in transform group-hover:translate-x-[80%] top-0 left-0 bg-secondary opacity-0 group-hover:opacity-100 flex flex-col z-50"> <div className="">{i.title.userPreferred}</div> <div>a</div> - </span> + </span> */} </Link> ))} </div> </div> - <div className="absolute top-0 left-0 z-40 bg-gradient-to-r from-transparent from-30% to-80% to-tersier w-[80%] lg:w-[60%] h-full" /> + <div className="flex lg:hidden"> + <div + id="recommendation-list" + className="flex gap-5 overflow-x-scroll scrollbar-none px-5 py-5 lg:py-10" + ref={mobileRef} + {...mobileEvent} + > + {filteredData.slice(0, 9).map((i) => ( + <div key={`mobile-${i.id}`} className="flex flex-col gap-2"> + <Link + title={i.title.userPreferred} + href={`/en/${i.type.toLowerCase()}/${i.id}`} + className="relative flex-center snap-start shrink-0 group rounded scale-100 hover:scale-105 duration-300 ease-out" + > + <span className="h-[190px] w-[135px] lg:h-[265px] lg:w-[185px] rounded absolute bg-gradient-to-b from-black/50 from-5% to-30% to-transparent z-40" /> + <div className="absolute top-0 right-0 z-40 font-karla font-bold"> + {i.type === "ANIME" ? ( + <span className="flex items-center px-2 py-1 gap-1 text-sm text-white"> + <PlayCircleIcon className="w-5 h-5" /> + </span> + ) : ( + <span className="flex items-center px-2 py-1 gap-1 text-sm text-white"> + <BookOpenOutline className="w-5 h-5" /> + </span> + )} + </div> + <Image + src={i.coverImage.extraLarge} + alt={i.title.userPreferred} + width={190} + height={256} + className="h-[190px] w-[135px] lg:h-[265px] lg:w-[185px] shrink-0 brightness-[90%] rounded-md object-cover overflow-hidden transition-all duration-150 ease-in-out" + /> + </Link> + <Link + href={ + i.type === "MANGA" + ? `/en/manga/${i.id}` + : `/en/${i.type.toLowerCase()}/${i.id}` + } + className="w-[135px] lg:w-[185px] line-clamp-2" + title={i.title.romaji} + > + <h1 className="font-karla font-semibold xl:text-base text-[15px]"> + {i.status === "RELEASING" ? ( + <span className="dots bg-green-500" /> + ) : i.status === "NOT_YET_RELEASED" ? ( + <span className="dots bg-red-500" /> + ) : null} + {i.title.userPreferred} + </h1> + </Link> + </div> + ))} + </div> + </div> + <div className="hidden lg:block absolute top-0 left-0 z-40 bg-gradient-to-r from-transparent from-30% to-80% to-tersier w-[80%] lg:w-[60%] h-full" /> {data[0]?.bannerImage && ( <Image src={data[0]?.bannerImage} alt={data[0].title.userPreferred} width={500} height={500} - className="absolute top-0 left-0 z-30 w-[60%] h-full object-cover opacity-30" + className="hidden lg:block absolute top-0 left-0 z-30 w-[60%] h-full object-cover opacity-30" /> )} </div> diff --git a/components/home/schedule.js b/components/home/schedule.js index bb35d08..19260c2 100644 --- a/components/home/schedule.js +++ b/components/home/schedule.js @@ -4,7 +4,7 @@ import { convertUnixToTime } from "../../utils/getTimes"; import { PlayIcon } from "@heroicons/react/20/solid"; import { BackwardIcon, ForwardIcon } from "@heroicons/react/24/solid"; import Link from "next/link"; -import { useCountdown } from "../../utils/useCountdownSeconds"; +import { useCountdown } from "../../lib/hooks/useCountdownSeconds"; export default function Schedule({ data, scheduleData, anime, update }) { let now = new Date(); @@ -13,7 +13,7 @@ export default function Schedule({ data, scheduleData, anime, update }) { "Schedule"; currentDay = currentDay.replace("Schedule", ""); - const [day, hours, minutes, seconds] = useCountdown( + const { day, hours, minutes, seconds } = useCountdown( anime[0]?.airingSchedule.nodes[0]?.airingAt * 1000 || Date.now(), update ); diff --git a/components/listEditor.js b/components/listEditor.tsx index 7d30835..2e180a1 100644 --- a/components/listEditor.js +++ b/components/listEditor.tsx @@ -1,26 +1,37 @@ -import { useState } from "react"; +import { useState, FormEvent } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; import { toast } from "sonner"; +import { AniListInfoTypes } from "@/types/info/AnilistInfoTypes"; -const ListEditor = ({ +interface ListEditorProps { + animeId: number; + session: any; // replace 'any' with the appropriate type + stats?: string; + prg?: number; + max?: number; + info?: AniListInfoTypes; // replace 'any' with the appropriate type + close: () => void; +} + +const ListEditor: React.FC<ListEditorProps> = ({ animeId, session, - stats, - prg, + stats = "CURRENT", + prg = 0, max, - info = null, + info = undefined, close, }) => { - const [status, setStatus] = useState(stats ?? "CURRENT"); - const [progress, setProgress] = useState(prg ?? 0); - const isAnime = info?.type === "ANIME"; + const [status, setStatus] = useState<string>(stats ?? "CURRENT"); + const [progress, setProgress] = useState<number>(prg ?? 0); + const isAnime: boolean = info?.type === "ANIME"; const router = useRouter(); - const handleSubmit = async (e) => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - console.log("Submitting", status?.name, progress); + // console.log("Submitting", status?.name, progress); try { const response = await fetch("https://graphql.anilist.co/", { method: "POST", @@ -109,7 +120,7 @@ const ListEditor = ({ <select name="status" id="status" - value={status?.value} + value={status || "CURRENT"} onChange={(e) => setStatus(e.target.value)} className="rounded-sm px-2 py-1 bg-[#363642] w-[50%] sm:w-[150px] text-sm sm:text-base" > @@ -137,7 +148,7 @@ const ListEditor = ({ id="progress" value={progress} max={max} - onChange={(e) => setProgress(e.target.value)} + onChange={(e) => setProgress(Number(e.target.value))} className="rounded-sm px-2 py-1 bg-[#363642] w-[50%] sm:w-[150px] text-sm sm:text-base" min="0" /> diff --git a/components/manga/ChaptersComponent.js b/components/manga/ChaptersComponent.js new file mode 100644 index 0000000..d031c3b --- /dev/null +++ b/components/manga/ChaptersComponent.js @@ -0,0 +1,89 @@ +import { useEffect } from "react"; +import ChapterSelector from "./chapters"; +import axios from "axios"; +import pls from "@/utils/request"; + +export default function ChaptersComponent({ + info, + mangaId, + aniId, + setWatch, + chapter, + setChapter, + loading, + setLoading, + notFound, + setNotFound, +}) { + useEffect(() => { + setLoading(true); + }, [aniId]); + + useEffect(() => { + async function fetchData() { + try { + setLoading(true); + // console.log(mangaId); + + if (mangaId) { + const Chapters = await pls.get( + `https://api.anify.tv/chapters/${mangaId}` + ); + // console.log("clean this balls"); + + if (!Chapters) { + setLoading(false); + setNotFound(true); + } else { + setChapter(Chapters); + setLoading(false); + } + } + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + } + fetchData(); + }, [mangaId]); + + return ( + <div> + {!loading ? ( + notFound ? ( + <div className="h-[20vh] lg:w-full flex-center flex-col gap-5"> + <p className="text-center font-karla font-bold lg:text-lg"> + Oops!<br></br> It looks like this manga is not available. + </p> + </div> + ) : info && chapter && chapter.length > 0 ? ( + <ChapterSelector + chaptersData={chapter} + mangaId={mangaId} + data={info} + setWatch={setWatch} + /> + ) : ( + <div className="flex justify-center"> + <div className="lds-ellipsis"> + <div></div> + <div></div> + <div></div> + <div></div> + </div> + </div> + ) + ) : ( + <div className="flex justify-center"> + <div className="lds-ellipsis"> + <div></div> + <div></div> + <div></div> + <div></div> + </div> + </div> + )} + </div> + ); +} diff --git a/components/manga/chapters.js b/components/manga/chapters.js index 2150686..4e7e42e 100644 --- a/components/manga/chapters.js +++ b/components/manga/chapters.js @@ -89,7 +89,7 @@ const ChapterSelector = ({ chaptersData, data, setWatch, mangaId }) => { } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chapters]); + }, [chapters, mangaId]); return ( <div className="flex flex-col gap-2 px-3"> diff --git a/components/manga/leftBar.js b/components/manga/leftBar.js index 5a98115..5485cd2 100644 --- a/components/manga/leftBar.js +++ b/components/manga/leftBar.js @@ -93,7 +93,7 @@ export function LeftBar({ onClick={() => setSeekPage(index)} > <Image - src={`https://api.consumet.org/utils/image-proxy?url=${encodeURIComponent( + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( x.url )}${ x?.headers?.Referer diff --git a/components/manga/mobile/bottomBar.js b/components/manga/mobile/bottomBar.js index 5b28de4..1cde8ed 100644 --- a/components/manga/mobile/bottomBar.js +++ b/components/manga/mobile/bottomBar.js @@ -108,7 +108,7 @@ export default function BottomBar({ onClick={() => setSeekPage(x.index)} > <Image - src={`https://api.consumet.org/utils/image-proxy?url=${encodeURIComponent( + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( x.url )}${ x?.headers?.Referer diff --git a/components/manga/panels/firstPanel.js b/components/manga/panels/firstPanel.js index 596fa58..8470fd0 100644 --- a/components/manga/panels/firstPanel.js +++ b/components/manga/panels/firstPanel.js @@ -141,7 +141,7 @@ export default function FirstPanel({ ref={(el) => (imageRefs.current[index] = el)} > <Image - src={`https://api.consumet.org/utils/image-proxy?url=${encodeURIComponent( + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( i.url )}${ i?.headers?.Referer diff --git a/components/manga/panels/secondPanel.js b/components/manga/panels/secondPanel.js index fa158b2..23a9da0 100644 --- a/components/manga/panels/secondPanel.js +++ b/components/manga/panels/secondPanel.js @@ -136,7 +136,7 @@ export default function SecondPanel({ width={500} height={500} className="w-1/2 h-screen object-contain" - src={`https://api.consumet.org/utils/image-proxy?url=${encodeURIComponent( + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( image[image.length - index - 2]?.url )}${ image[image.length - index - 2]?.headers?.Referer @@ -157,7 +157,7 @@ export default function SecondPanel({ width={500} height={500} className="w-1/2 h-screen object-contain" - src={`https://api.consumet.org/utils/image-proxy?url=${encodeURIComponent( + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( image[image.length - index - 1]?.url )}${ image[image.length - index - 1]?.headers?.Referer diff --git a/components/manga/panels/thirdPanel.js b/components/manga/panels/thirdPanel.js index f13b49d..77bb132 100644 --- a/components/manga/panels/thirdPanel.js +++ b/components/manga/panels/thirdPanel.js @@ -127,7 +127,7 @@ export default function ThirdPanel({ height={500} className="w-full h-screen object-contain" onClick={() => setMobileVisible(!mobileVisible)} - src={`https://api.consumet.org/utils/image-proxy?url=${encodeURIComponent( + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( image[image.length - index - 1]?.url )}${ image[image.length - index - 1]?.headers?.Referer diff --git a/components/modal.js b/components/modal.tsx index 5d6d0cc..6865560 100644 --- a/components/modal.js +++ b/components/modal.tsx @@ -1,4 +1,10 @@ -export default function Modal({ open, onClose, children }) { +type ModalProps = { + open: boolean; + onClose: () => void; + children: React.ReactNode; +}; + +export default function Modal({ open, onClose, children }: ModalProps) { return ( <div onClick={onClose} diff --git a/components/search/searchByImage.js b/components/search/searchByImage.tsx index f61418f..2041871 100644 --- a/components/search/searchByImage.js +++ b/components/search/searchByImage.tsx @@ -3,15 +3,22 @@ import { useRouter } from "next/router"; import React, { useEffect } from "react"; import { toast } from "sonner"; +type SearchByImageProps = { + searchPalette?: boolean; + setIsOpen?: (isOpen: boolean) => void; + setData?: any; // Replace 'any' with the actual data type + setMedia?: (media: any) => void; // Replace 'any' with the actual media type +}; + export default function SearchByImage({ searchPalette = false, setIsOpen, - setData, - setMedia, -}) { + setData = () => {}, + setMedia = () => {}, +}: SearchByImageProps) { const router = useRouter(); - async function findImage(formData) { + async function findImage(formData: FormData) { const response = new Promise((resolve, reject) => { fetch("https://api.trace.moe/search?anilistInfo", { method: "POST", @@ -32,14 +39,16 @@ export default function SearchByImage({ }); response - .then((data) => { - if (data?.result?.length > 0) { + .then((data: any) => { + if (data && data?.result?.length > 0) { const id = data.result[0].anilist.id; - const datas = data.result.filter((i) => i.anilist.isAdult === false); + const datas = data.result.filter( + (i: any) => i.anilist.isAdult === false + ); if (setData) setData(datas); if (searchPalette) router.push(`/en/anime/${id}`); if (setIsOpen) setIsOpen(false); - if (setMedia) setMedia(); + if (setMedia) setMedia({}); } }) .catch((error) => { @@ -47,7 +56,7 @@ export default function SearchByImage({ }); } - const handleImageSelect = async (e) => { + const handleImageSelect = async (e: any) => { const selectedImage = e.target.files[0]; if (selectedImage) { @@ -64,7 +73,7 @@ export default function SearchByImage({ useEffect(() => { // Add a global event listener for the paste event - const handlePaste = async (e) => { + const handlePaste = async (e: any) => { // e.preventDefault(); const items = e.clipboardData.items; @@ -117,3 +126,35 @@ export default function SearchByImage({ </div> ); } + +export interface TraceMoeDataTypes { + frameCount: number; + error: string; + result: TraceMoeResultTypes[]; +} + +export interface TraceMoeResultTypes { + anilist: Anilist; + filename: string; + episode: any; + from: number; + to: number; + similarity: number; + video: string; + image: string; + hovered?: boolean; +} + +interface Anilist { + id: number; + idMal: number; + title: Title; + synonyms: string[]; + isAdult: boolean; +} + +interface Title { + native: string; + romaji: string; + english: any; +} diff --git a/components/search/selection.js b/components/search/selection.ts index 767361d..767361d 100644 --- a/components/search/selection.js +++ b/components/search/selection.ts diff --git a/components/searchPalette.js b/components/searchPalette.tsx index b450423..b253f59 100644 --- a/components/searchPalette.js +++ b/components/searchPalette.tsx @@ -1,39 +1,65 @@ import { Fragment, useEffect, useRef, useState } from "react"; import { Combobox, Dialog, Menu, Transition } from "@headlessui/react"; -import useDebounce from "../lib/hooks/useDebounce"; +import useDebounce from "@/lib/hooks/useDebounce"; import Image from "next/image"; import { useRouter } from "next/router"; -import { useSearch } from "../lib/context/isOpenState"; +import { useSearch } from "@/lib/context/isOpenState"; import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; import { BookOpenIcon, PlayIcon } from "@heroicons/react/20/solid"; -import { useAniList } from "../lib/anilist/useAnilist"; -import { getFormat } from "../utils/getFormat"; +import { useAniList } from "@/lib/anilist/useAnilist"; +import { getFormat } from "@/utils/getFormat"; import SearchByImage from "./search/searchByImage"; +type SearchType = "ANIME" | "MANGA"; + +export interface DataTypes { + id: number; + title: Title; + coverImage: CoverImage; + type: string; + format: string; + bannerImage?: string; + isLicensed: boolean; + genres: string[]; + startDate: StartDate; +} + +interface Title { + userPreferred: string; +} + +interface CoverImage { + medium: string; +} + +interface StartDate { + year: number; +} + export default function SearchPalette() { const { isOpen, setIsOpen } = useSearch(); const { quickSearch } = useAniList(); - const [query, setQuery] = useState(""); - const [data, setData] = useState(null); + const [query, setQuery] = useState<string>(""); + const [data, setData] = useState<DataTypes[] | null>(null); const debounceSearch = useDebounce(query, 500); - const [loading, setLoading] = useState(false); - const [type, setType] = useState("ANIME"); + const [loading, setLoading] = useState<boolean>(false); + const [type, setType] = useState<SearchType>("ANIME"); - const [nextPage, setNextPage] = useState(false); + const [nextPage, setNextPage] = useState<boolean>(false); - let focusInput = useRef(null); + let focusInput = useRef<HTMLInputElement>(null); const router = useRouter(); function closeModal() { setIsOpen(false); } - function handleChange(event) { + function handleChange(event: string): void { router.push(`/en/${type.toLowerCase()}/${event}`); } - async function advance() { + async function advance(): Promise<void> { setLoading(true); const res = await quickSearch({ search: debounceSearch, @@ -50,11 +76,11 @@ export default function SearchPalette() { }, [debounceSearch, type]); useEffect(() => { - const handleKeyDown = (e) => { + const handleKeyDown = (e: any) => { if (e.code === "KeyS" && e.ctrlKey) { // do your stuff e.preventDefault(); - setIsOpen((prev) => !prev); + setIsOpen((prev: boolean) => !prev); setData(null); setQuery(""); } @@ -103,7 +129,7 @@ export default function SearchPalette() { <Combobox as="div" className="max-w-2xl mx-auto rounded-lg shadow-2xl relative flex flex-col" - onChange={(e) => { + onChange={(e: any) => { handleChange(e); setData(null); setIsOpen(false); @@ -202,7 +228,7 @@ export default function SearchPalette() { > {!loading ? ( <Fragment> - {data?.length > 0 + {data && data?.length > 0 ? data?.map((i) => ( <Combobox.Option key={i.id} diff --git a/components/shared/MobileNav.js b/components/shared/MobileNav.tsx index d0f29c2..7d6dfd6 100644 --- a/components/shared/MobileNav.js +++ b/components/shared/MobileNav.tsx @@ -5,8 +5,12 @@ import Image from "next/image"; import Link from "next/link"; import { useState } from "react"; -export default function MobileNav({ hideProfile = false }) { - const { data: sessions } = useSession(); +type MobileNavProps = { + hideProfile?: boolean; +}; + +export default function MobileNav({ hideProfile = false }: MobileNavProps) { + const { data: sessions }: { data: any } = useSession(); const [isVisible, setIsVisible] = useState(false); const handleShowClick = () => { @@ -48,11 +52,11 @@ export default function MobileNav({ hideProfile = false }) { > {isVisible && sessions && !hideProfile && ( <Link - href={`/en/profile/${sessions?.user.name}`} + href={`/en/profile/${sessions?.user?.name}`} className="fixed lg:hidden bottom-[100px] w-[60px] h-[60px] flex items-center justify-center right-[20px] rounded-full z-50 bg-[#17171f]" > <Image - src={sessions?.user.image.large} + src={sessions?.user?.image?.large} alt="user avatar" width={60} height={60} @@ -99,7 +103,7 @@ export default function MobileNav({ hideProfile = false }) { </button> {sessions ? ( <button - onClick={() => signOut("AniListProvider")} + onClick={() => signOut({ redirect: true })} className="group flex gap-[1.5px] flex-col items-center " > <div> diff --git a/components/shared/NavBar.js b/components/shared/NavBar.tsx index 8cfdfc1..6e8812e 100644 --- a/components/shared/NavBar.js +++ b/components/shared/NavBar.tsx @@ -7,14 +7,31 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; +import { AniListInfoTypes } from "types/info/AnilistInfoTypes"; -const getScrollPosition = (el = window) => ({ - x: el.pageXOffset !== undefined ? el.pageXOffset : el.scrollLeft, - y: el.pageYOffset !== undefined ? el.pageYOffset : el.scrollTop, -}); +const getScrollPosition = (el: Window | Element = window) => { + if (el instanceof Window) { + return { x: el.pageXOffset, y: el.pageYOffset }; + } else { + return { x: el.scrollLeft, y: el.scrollTop }; + } +}; -export function NewNavbar({ - info, +type NavbarProps = { + info?: AniListInfoTypes | null; + scrollP?: number; + toTop?: boolean; + withNav?: boolean; + paddingY?: string; + home?: boolean; + back?: boolean; + manga?: boolean; + shrink?: boolean; + bgHover?: boolean; +}; + +export function Navbar({ + info = null, scrollP = 200, toTop = false, withNav = false, @@ -23,10 +40,13 @@ export function NewNavbar({ back = false, manga = false, shrink = false, -}) { - const { data: session } = useSession(); + bgHover = false, +}: NavbarProps) { + const { data: session }: { data: any } = useSession(); const router = useRouter(); - const [scrollPosition, setScrollPosition] = useState(); + const [scrollPosition, setScrollPosition] = useState< + { x: number; y: number } | undefined + >(); const { setIsOpen } = useSearch(); const year = new Date().getFullYear(); @@ -48,8 +68,10 @@ export function NewNavbar({ return ( <> <nav - className={`${home ? "" : "fixed"} z-[200] top-0 px-5 w-full ${ - scrollPosition?.y >= scrollP + className={`${home ? "" : "fixed"} ${ + bgHover ? "hover:bg-tersier" : "" + } z-[200] top-0 px-5 w-full ${ + scrollPosition?.y ?? 0 >= scrollP ? home ? "" : `bg-tersier shadow-tersier shadow-sm ${ @@ -86,7 +108,7 @@ export function NewNavbar({ <span className={`font-inter font-semibold w-[50%] line-clamp-1 select-none ${ - scrollPosition?.y >= scrollP + 80 + scrollPosition?.y ?? 0 >= scrollP + 80 ? "opacity-100" : "opacity-0" } transition-all duration-200 ease-linear`} @@ -160,7 +182,7 @@ export function NewNavbar({ {session && ( <li className="text-center"> <Link - href={`/en/profile/${session?.user.name}`} + href={`/en/profile/${session?.user?.name}`} className="hover:text-action/80 transition-all duration-150 ease-linear" > My List @@ -202,28 +224,28 @@ export function NewNavbar({ <button type="button" onClick={() => - router.push(`/en/profile/${session?.user.name}`) + router.push(`/en/profile/${session?.user?.name}`) } - className="rounded-full bg-white/30 overflow-hidden" + className="rounded-full w-7 h-7 bg-white/30 overflow-hidden" > <Image - src={session?.user.image.large} + src={session?.user?.image?.large} alt="avatar" width={50} height={50} - className="w-full h-full object-cover" + className="w-7 h-7 object-cover" /> </button> <div className="hidden absolute z-50 w-28 text-center -bottom-20 text-white shadow-2xl opacity-0 bg-secondary p-1 py-2 rounded-md font-karla font-light invisible group-hover:visible group-hover:opacity-100 duration-300 transition-all md:grid place-items-center gap-1"> <Link - href={`/en/profile/${session?.user.name}`} + href={`/en/profile/${session?.user?.name}`} className="hover:text-action" > Profile </Link> <button type="button" - onClick={() => signOut("AniListProvider")} + onClick={() => signOut({ redirect: true })} className="hover:text-action" > Log out @@ -254,7 +276,7 @@ export function NewNavbar({ }); }} className={`${ - scrollPosition?.y >= 180 + scrollPosition?.y ?? 0 >= 180 ? "-translate-x-6 opacity-100" : "translate-x-[100%] opacity-0" } transform transition-all duration-300 ease-in-out fixed bottom-24 lg:bottom-14 right-0 z-[500]`} diff --git a/components/shared/bugReport.js b/components/shared/bugReport.tsx index f6bd9f1..5c1e3f4 100644 --- a/components/shared/bugReport.js +++ b/components/shared/bugReport.tsx @@ -10,7 +10,12 @@ const severityOptions = [ { id: 4, name: "Critical" }, ]; -const BugReportForm = ({ isOpen, setIsOpen }) => { +interface BugReportFormProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +} + +const BugReportForm: React.FC<BugReportFormProps> = ({ isOpen, setIsOpen }) => { const [bugDescription, setBugDescription] = useState(""); const [severity, setSeverity] = useState(severityOptions[0]); @@ -20,7 +25,7 @@ const BugReportForm = ({ isOpen, setIsOpen }) => { setSeverity(severityOptions[0]); } - const handleSubmit = async (e) => { + const handleSubmit = async (e: any) => { e.preventDefault(); const bugReport = { @@ -44,7 +49,7 @@ const BugReportForm = ({ isOpen, setIsOpen }) => { const json = await res.json(); toast.success(json.message); closeModal(); - } catch (err) { + } catch (err: any) { console.log(err); toast.error("Something went wrong: " + err.message); } @@ -94,7 +99,7 @@ const BugReportForm = ({ isOpen, setIsOpen }) => { <textarea id="bugDescription" name="bugDescription" - rows="4" + rows={4} className={`w-full bg-image text-txt rounded-md border border-txt focus:ring-action focus:border-action transition duration-300 focus:outline-none py-2 px-3`} placeholder="Describe the bug you encountered..." value={bugDescription} diff --git a/components/shared/changelogs.tsx b/components/shared/changelogs.tsx new file mode 100644 index 0000000..a7b0436 --- /dev/null +++ b/components/shared/changelogs.tsx @@ -0,0 +1,265 @@ +import { Dialog, Transition } from "@headlessui/react"; +import Link from "next/link"; +import { Fragment, useEffect, useRef, useState } from "react"; + +const web = { + version: "v4.3.1", +}; + +const logs = [ + { + version: "v4.3.1", + pre: true, + notes: null, + highlights: true, + changes: [ + "Fix: Auto Next Episode forcing to play sub even if dub is selected", + "Fix: Episode metadata not showing after switching to dub", + "Fix: Profile picture weirdly cropped", + "Fix: Weird padding on the navbar in profile page", + ], + }, + { + version: "v4.3.0", + pre: true, + notes: null, + highlights: false, + changes: [ + "Added changelogs section", + "Added recommendations based on user lists", + "New Player!", + "And other minor bug fixes!", + ], + }, +]; + +export default function ChangeLogs() { + let [isOpen, setIsOpen] = useState(false); + let completeButtonRef = useRef(null); + + function closeModal() { + localStorage.setItem("version", web.version); + setIsOpen(false); + } + + function getVersion() { + let version = localStorage.getItem("version"); + if (version !== web.version) { + setIsOpen(true); + } + } + + useEffect(() => { + getVersion(); + }, []); + + return ( + <> + <Transition appear show={isOpen} as={Fragment}> + <Dialog + as="div" + className="relative z-50" + onClose={closeModal} + initialFocus={completeButtonRef} + > + <Transition.Child + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div className="fixed inset-0 bg-black/25" /> + </Transition.Child> + + <div className="fixed inset-0 overflow-y-auto"> + <div className="flex min-h-full items-center justify-center p-4 text-center"> + <Transition.Child + as={Fragment} + enter="ease-out duration-300" + enterFrom="opacity-0 scale-95" + enterTo="opacity-100 scale-100" + leave="ease-in duration-200" + leaveFrom="opacity-100 scale-100" + leaveTo="opacity-0 scale-95" + > + <Dialog.Panel className="w-full max-w-lg transform overflow-hidden rounded bg-secondary p-6 text-left align-middle shadow-xl transition-all"> + <Dialog.Title + as="h3" + className="text-lg font-medium leading-6 text-gray-100" + > + <div className="flex justify-between items-center gap-2"> + <p className="text-xl">Changelogs</p> + <div className="flex gap-2 items-center"> + {/* Github Icon */} + <Link + href="/github" + className="w-5 h-5 hover:opacity-75" + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="#fff" + viewBox="0 0 20 20" + > + <g> + <g + fill="none" + fillRule="evenodd" + stroke="none" + strokeWidth="1" + > + <g + fill="#fff" + transform="translate(-140 -7559)" + > + <g transform="translate(56 160)"> + <path d="M94 7399c5.523 0 10 4.59 10 10.253 0 4.529-2.862 8.371-6.833 9.728-.507.101-.687-.219-.687-.492 0-.338.012-1.442.012-2.814 0-.956-.32-1.58-.679-1.898 2.227-.254 4.567-1.121 4.567-5.059 0-1.12-.388-2.034-1.03-2.752.104-.259.447-1.302-.098-2.714 0 0-.838-.275-2.747 1.051a9.396 9.396 0 00-2.505-.345 9.375 9.375 0 00-2.503.345c-1.911-1.326-2.751-1.051-2.751-1.051-.543 1.412-.2 2.455-.097 2.714-.639.718-1.03 1.632-1.03 2.752 0 3.928 2.335 4.808 4.556 5.067-.286.256-.545.708-.635 1.371-.57.262-2.018.715-2.91-.852 0 0-.529-.985-1.533-1.057 0 0-.975-.013-.068.623 0 0 .655.315 1.11 1.5 0 0 .587 1.83 3.369 1.21.005.857.014 1.665.014 1.909 0 .271-.184.588-.683.493-3.974-1.355-6.839-5.199-6.839-9.729 0-5.663 4.478-10.253 10-10.253"></path> + </g> + </g> + </g> + </g> + </svg> + </Link> + {/* Discord Icon */} + <Link + href="/discord" + className="w-6 h-6 hover:opacity-75" + > + <svg + xmlns="http://www.w3.org/2000/svg" + preserveAspectRatio="xMidYMid" + viewBox="0 -28.5 256 256" + > + <path + fill="#fff" + d="M216.856 16.597A208.502 208.502 0 00164.042 0c-2.275 4.113-4.933 9.645-6.766 14.046-19.692-2.961-39.203-2.961-58.533 0-1.832-4.4-4.55-9.933-6.846-14.046a207.809 207.809 0 00-52.855 16.638C5.618 67.147-3.443 116.4 1.087 164.956c22.169 16.555 43.653 26.612 64.775 33.193A161.094 161.094 0 0079.735 175.3a136.413 136.413 0 01-21.846-10.632 108.636 108.636 0 005.356-4.237c42.122 19.702 87.89 19.702 129.51 0a131.66 131.66 0 005.355 4.237 136.07 136.07 0 01-21.886 10.653c4.006 8.02 8.638 15.67 13.873 22.848 21.142-6.58 42.646-16.637 64.815-33.213 5.316-56.288-9.08-105.09-38.056-148.36zM85.474 135.095c-12.645 0-23.015-11.805-23.015-26.18s10.149-26.2 23.015-26.2c12.867 0 23.236 11.804 23.015 26.2.02 14.375-10.148 26.18-23.015 26.18zm85.051 0c-12.645 0-23.014-11.805-23.014-26.18s10.148-26.2 23.014-26.2c12.867 0 23.236 11.804 23.015 26.2 0 14.375-10.148 26.18-23.015 26.18z" + ></path> + </svg> + </Link> + </div> + </div> + </Dialog.Title> + <div className="mt-4"> + <p className="text-sm text-gray-400"> + Hi! Welcome to the new changelogs section. Here you can + see a lists of the latest changes and updates to the site. + </p> + <p className="inline-block text-sm italic my-2 text-gray-400"> + *This update is still in it's pre-release state, please + expect to see some bugs. If you find any, please report + them. + </p> + </div> + + {logs.map((x) => ( + <ChangelogsVersions + notes={x.notes} + version={x.version} + pre={x.pre} + key={x.version} + > + {x.changes.map((i, index) => ( + <p key={index}>- {i}</p> + ))} + </ChangelogsVersions> + ))} + + {/* <div className="my-2 flex items-center justify-evenly"> + <div className="w-full h-[1px] bg-gradient-to-r from-white/5 to-white/40" /> + <p className="relative flex flex-1 whitespace-nowrap font-bold mx-2 font-inter"> + v4.3.0 + <span className="flex text-xs font-light font-roboto ml-1 italic"> + pre + </span> + </p> + <div className="w-full h-[1px] bg-gradient-to-l from-white/5 to-white/40" /> + </div> + + <div className="flex flex-col gap-2 text-sm text-gray-200"> + <div> + <p className="inline-block italic mb-2 text-gray-400"> + *This update is still in it's pre-release state, please + expect to see some bugs. If you find any, please report + them. + </p> + + <p>- Added changelogs section</p> + <p>- Added recommendations based on user lists</p> + <p>- New Player!</p> + <p>- And other minor bug fixes!</p> + </div> + </div> */} + + <div className="mt-2 text-gray-400 text-sm"> + <p> + see more changelogs{" "} + <Link href="/changelogs" className="text-blue-500"> + here + </Link> + </p> + </div> + + <div className="flex items-center gap-2 mt-4"> + <div className="flex-1" /> + <button + type="button" + className="inline-flex justify-center rounded-md border border-transparent bg-action/10 px-4 py-2 text-sm font-medium text-action/90 hover:bg-action/20 focus:outline-none" + onClick={closeModal} + ref={completeButtonRef} + > + Got it, thanks! + </button> + </div> + </Dialog.Panel> + </Transition.Child> + </div> + </div> + </Dialog> + </Transition> + </> + ); +} + +type ChangelogsVersionsProps = { + version?: string; + pre: boolean; + notes?: string | null; + highlights?: boolean; + children: React.ReactNode; +}; + +export function ChangelogsVersions({ + version, + pre, + notes, + highlights, + children, +}: ChangelogsVersionsProps) { + return ( + <> + <div className="my-2 flex items-center justify-evenly"> + <div className="w-full h-[1px] bg-gradient-to-r from-white/5 to-white/40" /> + <p className="relative flex flex-1 whitespace-nowrap font-bold mx-2 font-inter"> + {version} + {pre && ( + <span className="flex text-xs font-light font-roboto ml-1 italic"> + pre + </span> + )} + </p> + <div className="w-full h-[1px] bg-gradient-to-l from-white/5 to-white/40" /> + </div> + + <div className="flex flex-col gap-2 text-sm py-2 text-gray-200"> + <div> + {notes && ( + <p className="inline-block italic mb-2 text-gray-400">*{notes}</p> + )} + {children} + </div> + </div> + </> + ); +} diff --git a/components/shared/footer.js b/components/shared/footer.tsx index a29a3d3..2a513a3 100644 --- a/components/shared/footer.js +++ b/components/shared/footer.tsx @@ -76,10 +76,7 @@ function Footer() { </p> <div className="flex items-center gap-5"> {/* Github Icon */} - <Link - href="https://github.com/Ani-Moopa/Moopa" - className="w-5 h-5 hover:opacity-75" - > + <Link href="/github" className="w-5 h-5 hover:opacity-75"> <svg xmlns="http://www.w3.org/2000/svg" fill="#fff" @@ -102,10 +99,7 @@ function Footer() { </svg> </Link> {/* Discord Icon */} - <Link - href="https://discord.gg/v5fjSdKwr2" - className="w-6 h-6 hover:opacity-75" - > + <Link href="/discord" className="w-6 h-6 hover:opacity-75"> <svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" diff --git a/components/shared/hamburgerMenu.js b/components/shared/hamburgerMenu.js deleted file mode 100644 index 7e4bdf1..0000000 --- a/components/shared/hamburgerMenu.js +++ /dev/null @@ -1,192 +0,0 @@ -import { signIn, signOut, useSession } from "next-auth/react"; -import Image from "next/image"; -import Link from "next/link"; -import React, { useState } from "react"; - -export default function HamburgerMenu() { - const { data: session } = useSession(); - const [isVisible, setIsVisible] = useState(false); - const [fade, setFade] = useState(false); - - const handleShowClick = () => { - setIsVisible(true); - setFade(true); - }; - - const handleHideClick = () => { - setIsVisible(false); - setFade(false); - }; - - return ( - <React.Fragment> - {/* Mobile Hamburger */} - {!isVisible && ( - <button - onClick={handleShowClick} - className="fixed bottom-[30px] right-[20px] z-[100] flex h-[51px] w-[50px] cursor-pointer items-center justify-center rounded-[8px] bg-[#17171f] shadow-lg lg:hidden" - id="bars" - > - <svg - xmlns="http://www.w3.org/2000/svg" - className="h-[42px] w-[61.5px] text-[#8BA0B2] fill-orange-500" - viewBox="0 0 20 20" - fill="currentColor" - > - <path - fillRule="evenodd" - d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" - clipRule="evenodd" - /> - </svg> - </button> - )} - <div className={`z-50`}> - {isVisible && ( - <div className="fixed bottom-[30px] right-[20px] z-50 flex h-[51px] w-[300px] items-center justify-center gap-8 rounded-[8px] text-[11px] bg-[#17171f] shadow-lg lg:hidden"> - <div className="grid grid-cols-4 place-items-center gap-6"> - <button className="group flex flex-col items-center"> - <Link href={`/en/`} className=""> - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - strokeWidth={1.5} - stroke="currentColor" - className="w-6 h-6 group-hover:stroke-action" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" - /> - </svg> - </Link> - <Link - href={`/en/`} - className="font-karla font-bold text-[#8BA0B2] group-hover:text-action" - > - home - </Link> - </button> - <button className="group flex flex-col items-center"> - <Link href={`/en/about`}> - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - strokeWidth={1.5} - stroke="currentColor" - className="w-6 h-6 group-hover:stroke-action" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" - /> - </svg> - </Link> - <Link - href={`/en/about`} - className="font-karla font-bold text-[#8BA0B2] group-hover:text-action" - > - about - </Link> - </button> - <button className="group flex gap-[1.5px] flex-col items-center "> - <div> - <Link href={`/en/search/anime`}> - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - strokeWidth={1.5} - stroke="currentColor" - className="w-6 h-6 group-hover:stroke-action" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" - /> - </svg> - </Link> - </div> - <Link - href={`/en/search/anime`} - className="font-karla font-bold text-[#8BA0B2] group-hover:text-action" - > - search - </Link> - </button> - {session ? ( - <button - onClick={() => signOut("AniListProvider")} - className="group flex gap-[1.5px] flex-col items-center " - > - <div> - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 96 960 960" - className="group-hover:fill-action w-6 h-6 fill-txt" - > - <path d="M186.666 936q-27 0-46.833-19.833T120 869.334V282.666q0-27 19.833-46.833T186.666 216H474v66.666H186.666v586.668H474V936H186.666zm470.668-176.667l-47-48 102-102H370v-66.666h341.001l-102-102 46.999-48 184 184-182.666 182.666z"></path> - </svg> - </div> - <h1 className="font-karla font-bold text-[#8BA0B2] group-hover:text-action"> - logout - </h1> - </button> - ) : ( - <button - onClick={() => signIn("AniListProvider")} - className="group flex gap-[1.5px] flex-col items-center " - > - <div> - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 96 960 960" - className="group-hover:fill-action w-6 h-6 fill-txt mr-2" - > - <path d="M486 936v-66.666h287.334V282.666H486V216h287.334q27 0 46.833 19.833T840 282.666v586.668q0 27-19.833 46.833T773.334 936H486zm-78.666-176.667l-47-48 102-102H120v-66.666h341l-102-102 47-48 184 184-182.666 182.666z"></path> - </svg> - </div> - <h1 className="font-karla font-bold text-[#8BA0B2] group-hover:text-action"> - login - </h1> - </button> - )} - </div> - <button onClick={handleHideClick}> - <svg - width="20" - height="21" - className="fill-orange-500" - viewBox="0 0 20 21" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - <rect - x="2.44043" - y="0.941467" - width="23.5842" - height="3.45134" - rx="1.72567" - transform="rotate(45 2.44043 0.941467)" - /> - <rect - x="19.1172" - y="3.38196" - width="23.5842" - height="3.45134" - rx="1.72567" - transform="rotate(135 19.1172 3.38196)" - /> - </svg> - </button> - </div> - )} - </div> - </React.Fragment> - ); -} diff --git a/components/shared/loading.js b/components/shared/loading.js deleted file mode 100644 index 4620645..0000000 --- a/components/shared/loading.js +++ /dev/null @@ -1,20 +0,0 @@ -import Image from "next/image"; - -export default function Loading() { - return ( - <> - <div className="flex flex-col gap-5 items-center justify-center w-full z-[800]"> - {/* <Image - src="/wait-animation.gif" - width="0" - height="0" - className="w-[30%] h-[30%]" - /> */} - <div className="flex flex-col items-center font-karla gap-2"> - <p>Please Wait...</p> - <div className="loader"></div> - </div> - </div> - </> - ); -} diff --git a/components/shared/loading.tsx b/components/shared/loading.tsx new file mode 100644 index 0000000..902b6f9 --- /dev/null +++ b/components/shared/loading.tsx @@ -0,0 +1,16 @@ +export default function Loading() { + return ( + <div className="flex-center flex-col font-karla z-40 gap-2"> + {/* <div className="flex flex-col gap-5 items-center justify-center w-full z-50"> */} + {/* <Image + src="/wait-animation.gif" + width="0" + height="0" + className="w-[30%] h-[30%]" + /> */} + <p>Please Wait...</p> + <div className="loader"></div> + {/* </div> */} + </div> + ); +} diff --git a/components/watch/new-player/components/bufferingIndicator.tsx b/components/watch/new-player/components/bufferingIndicator.tsx new file mode 100644 index 0000000..4793d55 --- /dev/null +++ b/components/watch/new-player/components/bufferingIndicator.tsx @@ -0,0 +1,15 @@ +import { Spinner } from "@vidstack/react"; + +export default function BufferingIndicator() { + return ( + <div className="pointer-events-none absolute inset-0 z-50 flex h-full w-full items-center justify-center"> + <Spinner.Root + className="text-white opacity-0 transition-opacity duration-200 ease-linear media-buffering:animate-spin media-buffering:opacity-100" + size={84} + > + <Spinner.Track className="opacity-25" width={8} /> + <Spinner.TrackFill className="opacity-75" width={8} /> + </Spinner.Root> + </div> + ); +} diff --git a/components/watch/new-player/components/buttons.tsx b/components/watch/new-player/components/buttons.tsx new file mode 100644 index 0000000..18c2b42 --- /dev/null +++ b/components/watch/new-player/components/buttons.tsx @@ -0,0 +1,277 @@ +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import { + CaptionButton, + FullscreenButton, + isTrackCaptionKind, + MuteButton, + PIPButton, + PlayButton, + Tooltip, + useMediaState, + type TooltipPlacement, + useMediaRemote, + useMediaStore, +} from "@vidstack/react"; +import { + ClosedCaptionsIcon, + ClosedCaptionsOnIcon, + FullscreenExitIcon, + FullscreenIcon, + MuteIcon, + PauseIcon, + PictureInPictureExitIcon, + PictureInPictureIcon, + PlayIcon, + ReplayIcon, + TheatreModeExitIcon, + TheatreModeIcon, + VolumeHighIcon, + VolumeLowIcon, +} from "@vidstack/react/icons"; +import { useRouter } from "next/router"; +import { Navigation } from "../player"; + +export interface MediaButtonProps { + tooltipPlacement: TooltipPlacement; + navigation?: Navigation; + host?: boolean; +} + +export const buttonClass = + "group ring-media-focus relative inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-md outline-none ring-inset hover:bg-white/20 data-[focus]:ring-4"; + +export const tooltipClass = + "animate-out fade-out slide-out-to-bottom-2 data-[visible]:animate-in data-[visible]:fade-in data-[visible]:slide-in-from-bottom-4 z-10 rounded-sm bg-black/90 px-2 py-0.5 text-sm font-medium text-white parent-data-[open]:hidden"; + +export function Play({ tooltipPlacement }: MediaButtonProps) { + const isPaused = useMediaState("paused"), + ended = useMediaState("ended"), + tooltipText = isPaused ? "Play" : "Pause", + Icon = ended ? ReplayIcon : isPaused ? PlayIcon : PauseIcon; + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <PlayButton className={buttonClass}> + <Icon className="w-8 h-8" /> + </PlayButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {tooltipText} + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function MobilePlayButton({ tooltipPlacement, host }: MediaButtonProps) { + const isPaused = useMediaState("paused"), + ended = useMediaState("ended"), + Icon = ended ? ReplayIcon : isPaused ? PlayIcon : PauseIcon; + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <PlayButton + className={`${ + host ? "" : "pointer-events-none" + } group ring-media-focus relative inline-flex h-16 w-16 media-paused:cursor-pointer cursor-default items-center justify-center rounded-full outline-none`} + > + <Icon className="w-10 h-10" /> + </PlayButton> + </Tooltip.Trigger> + {/* <Tooltip.Content + className="animate-out fade-out slide-out-to-bottom-2 data-[visible]:animate-in data-[visible]:fade-in data-[visible]:slide-in-from-bottom-4 z-10 rounded-sm bg-black/90 px-2 py-0.5 text-sm font-medium text-white parent-data-[open]:hidden" + placement={tooltipPlacement} + > + {tooltipText} + </Tooltip.Content> */} + </Tooltip.Root> + ); +} + +export function Mute({ tooltipPlacement }: MediaButtonProps) { + const volume = useMediaState("volume"), + isMuted = useMediaState("muted"); + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <MuteButton className={buttonClass}> + {isMuted || volume == 0 ? ( + <MuteIcon className="w-8 h-8" /> + ) : volume < 0.5 ? ( + <VolumeLowIcon className="w-8 h-8" /> + ) : ( + <VolumeHighIcon className="w-8 h-8" /> + )} + </MuteButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {isMuted ? "Unmute" : "Mute"} + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function Caption({ tooltipPlacement }: MediaButtonProps) { + const track = useMediaState("textTrack"), + isOn = track && isTrackCaptionKind(track); + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <CaptionButton className={buttonClass}> + {isOn ? ( + <ClosedCaptionsOnIcon className="w-8 h-8" /> + ) : ( + <ClosedCaptionsIcon className="w-8 h-8" /> + )} + </CaptionButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {isOn ? "Closed-Captions On" : "Closed-Captions Off"} + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function TheaterButton({ tooltipPlacement }: MediaButtonProps) { + const playerState = useMediaState("currentTime"), + isPlaying = useMediaState("playing"); + + const { setPlayerState, setTheaterMode, theaterMode } = useWatchProvider(); + + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <button + type="button" + className={buttonClass} + onClick={() => { + setPlayerState((prev: any) => ({ + ...prev, + currentTime: playerState, + isPlaying: isPlaying, + })); + setTheaterMode((prev: any) => !prev); + }} + > + {!theaterMode ? ( + <TheatreModeIcon className="w-8 h-8" /> + ) : ( + <TheatreModeExitIcon className="w-8 h-8" /> + )} + </button> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + Theatre Mode + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function PIP({ tooltipPlacement }: MediaButtonProps) { + const isActive = useMediaState("pictureInPicture"); + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <PIPButton className={buttonClass}> + {isActive ? ( + <PictureInPictureExitIcon className="w-8 h-8" /> + ) : ( + <PictureInPictureIcon className="w-8 h-8" /> + )} + </PIPButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {isActive ? "Exit PIP" : "Enter PIP"} + </Tooltip.Content> + </Tooltip.Root> + ); +} + +export function PlayNextButton({ + tooltipPlacement, + navigation, +}: MediaButtonProps) { + // const remote = useMediaRemote(); + const router = useRouter(); + const { dataMedia, track } = useWatchProvider(); + return ( + <button + title="next-button" + type="button" + onClick={() => { + if (navigation?.next) { + router.push( + `/en/anime/watch/${dataMedia.id}/${track.provider}?id=${ + navigation?.next?.id + }&num=${navigation?.next?.number}${ + track?.isDub ? `&dub=${track?.isDub}` : "" + }` + ); + } + }} + className="next-button hidden" + > + Next Episode + </button> + ); +} + +export function SkipOpButton({ tooltipPlacement }: MediaButtonProps) { + const remote = useMediaRemote(); + const { track } = useWatchProvider(); + const op = track?.skip?.find((item: any) => item.text === "Opening"); + + return ( + <button + type="button" + onClick={() => { + remote.seek(op?.endTime); + }} + className="op-button hidden hover:bg-white/80 bg-white px-4 py-2 text-primary font-karla font-semibold rounded-md" + > + Skip Opening + </button> + ); +} + +export function SkipEdButton({ tooltipPlacement }: MediaButtonProps) { + const remote = useMediaRemote(); + const { duration } = useMediaStore(); + const { track } = useWatchProvider(); + const ed = track?.skip?.find((item: any) => item.text === "Ending"); + + const endTime = + Math.round(duration) === ed?.endTime ? ed?.endTime - 1 : ed?.endTime; + + // console.log(endTime); + + return ( + <button + title="ed-button" + type="button" + onClick={() => remote.seek(endTime)} + className="ed-button hidden cursor-pointer hover:bg-white/80 bg-white px-4 py-2 text-primary font-karla font-semibold rounded-md" + > + Skip Ending + </button> + ); +} + +export function Fullscreen({ tooltipPlacement }: MediaButtonProps) { + const isActive = useMediaState("fullscreen"); + return ( + <Tooltip.Root> + <Tooltip.Trigger asChild> + <FullscreenButton className={buttonClass}> + {isActive ? ( + <FullscreenExitIcon className="w-8 h-8" /> + ) : ( + <FullscreenIcon className="w-8 h-8" /> + )} + </FullscreenButton> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + {isActive ? "Exit Fullscreen" : "Enter Fullscreen"} + </Tooltip.Content> + </Tooltip.Root> + ); +} diff --git a/components/watch/new-player/components/chapter-title.tsx b/components/watch/new-player/components/chapter-title.tsx new file mode 100644 index 0000000..779f826 --- /dev/null +++ b/components/watch/new-player/components/chapter-title.tsx @@ -0,0 +1,11 @@ +import { ChapterTitle, type ChapterTitleProps } from "@vidstack/react"; +import { ChevronLeftIcon, ChevronRightIcon } from "@vidstack/react/icons"; + +export function ChapterTitleComponent() { + return ( + <span className="inline-block flex-1 overflow-hidden text-ellipsis whitespace-nowrap px-2 text-sm font-medium text-white"> + <span className="mr-1 text-txt">•</span> + <ChapterTitle className="ml-1" /> + </span> + ); +} diff --git a/components/watch/new-player/components/layouts/captions.module.css b/components/watch/new-player/components/layouts/captions.module.css new file mode 100644 index 0000000..338b96e --- /dev/null +++ b/components/watch/new-player/components/layouts/captions.module.css @@ -0,0 +1,80 @@ +.captions { + @apply font-roboto font-medium; + /* Recommended settings in the WebVTT spec (https://www.w3.org/TR/webvtt1). */ + /* --cue-color: var(--media-cue-color, white); */ + /* --cue-color: white; */ + /* z-index: 20; */ + /* --cue-bg-color: var(--media-cue-bg, rgba(0, 0, 0, 0.7)); */ + + /* bg color white */ + --cue-bg-color: rgba(255, 255, 255, 0.9); + --cue-font-size: calc(var(--overlay-height) / 100 * 5); + --cue-line-height: calc(var(--cue-font-size) * 1.2); + --cue-padding-x: 0.5em; + --cue-padding-y: 0.1em; + + /* remove background blur */ + + /* --cue-text-shadow: 0 0 5px black; */ + + font-size: var(--cue-font-size); + word-spacing: normal; + text-shadow: 0px 2px 8px rgba(0, 0, 0, 1); + /* contain: layout style; */ +} + +.captions[data-dir="rtl"] :global([data-part="cue-display"]) { + direction: rtl; +} + +.captions[aria-hidden="true"] { + display: none; +} + +/************************************************************************************************* + * Cue Display + *************************************************************************************************/ + +/* +* Most of the cue styles are set automatically by our [media-captions](https://github.com/vidstack/media-captions) +* library via CSS variables. They are inferred from the VTT, SRT, or SSA file cue settings. You're +* free to ignore them and style the captions as desired, but we don't recommend it unless the +* captions file contains no cue settings. Otherwise, you might be breaking accessibility. +*/ +.captions :global([data-part="cue-display"]) { + position: absolute; + direction: ltr; + overflow: visible; + contain: content; + top: var(--cue-top); + left: var(--cue-left); + right: var(--cue-right); + bottom: var(--cue-bottom); + width: var(--cue-width, auto); + height: var(--cue-height, auto); + transform: var(--cue-transform); + text-align: var(--cue-text-align); + writing-mode: var(--cue-writing-mode, unset); + white-space: pre-line; + unicode-bidi: plaintext; + min-width: min-content; + min-height: min-content; +} + +.captions :global([data-part="cue"]) { + display: inline-block; + contain: content; + /* border-radius: 2px; */ + /* backdrop-filter: unset; */ + padding: var(--cue-padding-y) var(--cue-padding-x); + line-height: var(--cue-line-height); + /* background-color: var(--cue-bg-color); */ + color: var(--cue-color); + white-space: pre-wrap; + outline: var(--cue-outline); + text-shadow: var(--cue-text-shadow); +} + +.captions :global([data-part="cue-display"][data-vertical] [data-part="cue"]) { + padding: var(--cue-padding-x) var(--cue-padding-y); +} diff --git a/components/watch/new-player/components/layouts/video-layout.module.css b/components/watch/new-player/components/layouts/video-layout.module.css new file mode 100644 index 0000000..14540f6 --- /dev/null +++ b/components/watch/new-player/components/layouts/video-layout.module.css @@ -0,0 +1,13 @@ +.controls { + /* + * These CSS variables are supported out of the box to easily apply offsets to all popups. + * You can also offset via props on `Tooltip.Content`, `Menu.Content`, and slider previews. + */ + --media-tooltip-y-offset: 30px; + --media-menu-y-offset: 30px; +} + +.controls :global(.volume-slider) { + --media-slider-preview-offset: 30px; + margin-left: 1.5px; +} diff --git a/components/watch/new-player/components/layouts/video-layout.tsx b/components/watch/new-player/components/layouts/video-layout.tsx new file mode 100644 index 0000000..fa1f6c3 --- /dev/null +++ b/components/watch/new-player/components/layouts/video-layout.tsx @@ -0,0 +1,173 @@ +import captionStyles from "./captions.module.css"; +import styles from "./video-layout.module.css"; + +import { + Captions, + Controls, + Gesture, + Spinner, + useMediaState, +} from "@vidstack/react"; + +import * as Buttons from "../buttons"; +import * as Menus from "../menus"; +import * as Sliders from "../sliders"; +import { TimeGroup } from "../time-group"; +import { Title } from "../title"; +import { ChapterTitleComponent } from "../chapter-title"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import { Navigation } from "../../player"; +import BufferingIndicator from "../bufferingIndicator"; +import { useEffect, useState } from "react"; + +export interface VideoLayoutProps { + thumbnails?: string; + navigation?: Navigation; + host?: boolean; +} + +function isMobileDevice() { + if (typeof window !== "undefined") { + return ( + typeof window.orientation !== "undefined" || + navigator.userAgent.indexOf("IEMobile") !== -1 + ); + } + return false; +} + +export function VideoLayout({ + thumbnails, + navigation, + host = true, +}: VideoLayoutProps) { + const [isMobile, setIsMobile] = useState(false); + + const { track } = useWatchProvider(); + const isFullscreen = useMediaState("fullscreen"); + + useEffect(() => { + setIsMobile(isMobileDevice()); + }, []); + + return ( + <> + <Gestures host={host} /> + <Captions + className={`${captionStyles.captions} media-preview:opacity-0 media-controls:bottom-[85px] media-captions:opacity-100 absolute inset-0 bottom-2 z-10 select-none break-words opacity-0 transition-[opacity,bottom] duration-300`} + /> + <Controls.Root + className={`${styles.controls} media-paused:bg-black/10 duration-200 media-controls:opacity-100 absolute inset-0 z-10 flex h-full w-full flex-col bg-gradient-to-t from-black/30 via-transparent to-black/30 opacity-0 transition-opacity`} + > + <Controls.Group className="flex justify-between items-center w-full px-2 pt-2"> + <Title navigation={navigation} /> + <div className="flex-1" /> + {/* <Menus.Episodes placement="left start" /> */} + </Controls.Group> + <div className="flex-1" /> + + {/* {isPaused && ( */} + <Controls.Group + className={`media-paused:opacity-100 media-paused:scale-100 backdrop-blur-sm scale-[160%] opacity-0 duration-200 ease-out flex shadow bg-white/10 rounded-full absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2`} + > + <Buttons.MobilePlayButton tooltipPlacement="top center" host={host} /> + </Controls.Group> + {/* )} */} + + <div className="pointer-events-none absolute inset-0 z-50 flex h-full w-full items-center justify-center"> + <Spinner.Root + className="text-white opacity-0 transition-opacity duration-200 ease-linear media-buffering:animate-spin media-buffering:opacity-100" + size={84} + > + <Spinner.Track className="opacity-25" width={8} /> + <Spinner.TrackFill className="opacity-75" width={8} /> + </Spinner.Root> + </div> + {/* </Controls.Group> */} + + <Controls.Group className="flex px-4"> + <div className="flex-1" /> + {host && ( + <> + <Buttons.SkipOpButton tooltipPlacement="top end" /> + <Buttons.SkipEdButton tooltipPlacement="top end" /> + <Buttons.PlayNextButton + navigation={navigation} + tooltipPlacement="top end" + /> + </> + )} + </Controls.Group> + + <Controls.Group className="flex w-full items-center px-2"> + <Sliders.Time thumbnails={thumbnails} host={host} /> + </Controls.Group> + <Controls.Group className="-mt-0.5 flex w-full items-center px-2 pb-2"> + <Buttons.Play tooltipPlacement="top start" /> + <Buttons.Mute tooltipPlacement="top" /> + <Sliders.Volume /> + <TimeGroup /> + <ChapterTitleComponent /> + <div className="flex-1" /> + {track?.subtitles && <Buttons.Caption tooltipPlacement="top" />} + <Menus.Settings placement="top end" tooltipPlacement="top" /> + {!isMobile && !isFullscreen && ( + <Buttons.TheaterButton tooltipPlacement="top" /> + )} + <Buttons.PIP tooltipPlacement="top" /> + <Buttons.Fullscreen tooltipPlacement="top end" /> + </Controls.Group> + </Controls.Root> + </> + ); +} + +function Gestures({ host }: { host?: boolean }) { + const isMobile = isMobileDevice(); + return ( + <> + {isMobile ? ( + <> + {host && ( + <Gesture + className="absolute inset-0 z-10" + event="dblpointerup" + action="toggle:paused" + /> + )} + <Gesture + className="absolute inset-0" + event="pointerup" + action="toggle:controls" + /> + </> + ) : ( + <> + {host && ( + <Gesture + className="absolute inset-0" + event="pointerup" + action="toggle:paused" + /> + )} + <Gesture + className="absolute inset-0 z-10" + event="dblpointerup" + action="toggle:fullscreen" + /> + </> + )} + + <Gesture + className="absolute top-0 left-0 w-1/5 h-full z-20" + event="dblpointerup" + action="seek:-10" + /> + <Gesture + className="absolute top-0 right-0 w-1/5 h-full z-20" + event="dblpointerup" + action="seek:10" + /> + </> + ); +} diff --git a/components/watch/new-player/components/menus.tsx b/components/watch/new-player/components/menus.tsx new file mode 100644 index 0000000..de2b302 --- /dev/null +++ b/components/watch/new-player/components/menus.tsx @@ -0,0 +1,387 @@ +// @ts-nocheck + +import type { ReactElement } from "react"; + +// import EpiDataDummy from "@/components/test/episodeDummy.json"; + +import { + Menu, + Tooltip, + useCaptionOptions, + type MenuPlacement, + type TooltipPlacement, + useVideoQualityOptions, + useMediaState, + usePlaybackRateOptions, +} from "@vidstack/react"; +import { + ChevronLeftIcon, + ChevronRightIcon, + ClosedCaptionsIcon, + SettingsMenuIcon, + RadioButtonIcon, + RadioButtonSelectedIcon, + SettingsIcon, + // EpisodesIcon, + SettingsSwitchIcon, + // PlaybackSpeedCircleIcon, + OdometerIcon, +} from "@vidstack/react/icons"; + +import { buttonClass, tooltipClass } from "./buttons"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import React from "react"; + +export interface SettingsProps { + placement: MenuPlacement; + tooltipPlacement: TooltipPlacement; +} + +export const menuClass = + "fixed bottom-0 animate-out fade-out slide-out-to-bottom-2 data-[open]:animate-in data-[open]:fade-in data-[open]:slide-in-from-bottom-4 flex h-[var(--menu-height)] max-h-[200px] lg:max-h-[400px] min-w-[260px] flex-col overflow-y-auto overscroll-y-contain rounded-md border border-white/10 bg-black/95 p-2.5 font-sans text-[15px] font-medium outline-none backdrop-blur-sm transition-[height] duration-300 will-change-[height] data-[resizing]:overflow-hidden"; + +export const submenuClass = + "hidden w-full flex-col items-start justify-center outline-none data-[keyboard]:mt-[3px] data-[open]:inline-block"; + +export const contentMenuClass = + "flex cust-scroll h-[var(--menu-height)] max-h-[180px] lg:max-h-[400px] min-w-[260px] flex-col overflow-y-auto overscroll-y-contain rounded-md border border-white/10 bg-secondary p-2 font-sans text-[15px] font-medium outline-none backdrop-blur-sm transition-[height] duration-300 will-change-[height] data-[resizing]:overflow-hidden"; + +export function Settings({ placement, tooltipPlacement }: SettingsProps) { + const { track } = useWatchProvider(); + const isSubtitleAvailable = track?.epiData?.subtitles?.length > 0; + + return ( + <Menu.Root className="parent"> + <Tooltip.Root> + <Tooltip.Trigger asChild> + <Menu.Button className={buttonClass}> + <SettingsIcon className="h-8 w-8 transform transition-transform duration-200 ease-out group-data-[open]:rotate-90" /> + </Menu.Button> + </Tooltip.Trigger> + <Tooltip.Content className={tooltipClass} placement={tooltipPlacement}> + Settings + </Tooltip.Content> + </Tooltip.Root> + {/* <Menu.Content className={menuClass} placement={placement}> + {isSubtitleAvailable && <CaptionSubmenu />} + <QualitySubmenu /> + </Menu.Content> */} + <Menu.Content className={contentMenuClass} placement={placement}> + <AutoPlay /> + <AutoNext /> + <SpeedSubmenu /> + {isSubtitleAvailable && <CaptionSubmenu />} + <QualitySubmenu /> + </Menu.Content> + </Menu.Root> + ); +} + +// export function Episodes({ placement }: { placement: MenuPlacement }) { +// return ( +// <Menu.Root className="parent"> +// <Tooltip.Root> +// <Tooltip.Trigger asChild> +// <Menu.Button className={buttonClass}> +// <EpisodesIcon className="w-10 h-10" /> +// </Menu.Button> +// </Tooltip.Trigger> +// </Tooltip.Root> +// <Menu.Content +// className={`bg-secondary/95 border border-white/10 max-h-[240px] overflow-y-scroll cust-scroll rounded overflow-hidden z-30 -translate-y-5 -translate-x-2`} +// placement={placement} +// > +// <EpisodeSubmenu /> +// </Menu.Content> +// </Menu.Root> +// ); +// } + +function SpeedSubmenu() { + const options = usePlaybackRateOptions(), + hint = + options.selectedValue === "1" ? "Normal" : options.selectedValue + "x"; + return ( + <Menu.Root> + <SubmenuButton + label="Playback Rate" + hint={hint} + icon={OdometerIcon} + disabled={options.disabled} + > + Speed ({hint}) + </SubmenuButton> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={options.selectedValue} + > + {options.map(({ label, value, select }) => ( + <Radio value={value} onSelect={select} key={value}> + {label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +function CaptionSubmenu() { + const options = useCaptionOptions(), + hint = options.selectedTrack?.label ?? "Off"; + return ( + <Menu.Root> + <SubmenuButton + label="Captions" + hint={hint} + disabled={options.disabled} + icon={ClosedCaptionsIcon} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={options.selectedValue} + > + {options.map(({ label, value, select }) => ( + <Radio value={value} onSelect={select} key={value}> + {label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +// function EpisodeSubmenu() { +// return ( +// // <div className="h-full w-[320px]"> +// <div className="flex flex-col h-full w-[360px] font-karla"> +// {/* {EpiDataDummy.map((epi, index) => ( */} +// <div +// key={index} +// className={`flex gap-1 hover:bg-secondary px-3 py-2 ${ +// index === 0 +// ? "pt-4" +// // : index === EpiDataDummy.length - 1 +// ? "pb-4" +// : "" +// }`} +// > +// <Image +// src={epi.img} +// alt="thumbnail" +// width={100} +// height={100} +// className="object-cover w-[120px] h-[64px] rounded-md" +// /> +// <div className="flex flex-col pl-2"> +// <h1 className="font-semibold">{epi.title}</h1> +// <p className="line-clamp-2 text-sm font-light"> +// {epi?.description} +// </p> +// </div> +// </div> +// ))} +// </div> +// // </div> +// ); +// } + +function AutoPlay() { + const [options, setOptions] = React.useState([ + { + label: "On", + value: "on", + selected: false, + }, + { + label: "Off", + value: "off", + selected: true, + }, + ]); + + const { autoplay, setAutoPlay } = useWatchProvider(); + + // console.log({ autoplay }); + + return ( + <Menu.Root> + <SubmenuButton + label="Autoplay Video" + hint={ + autoplay + ? options.find((option) => option.value === autoplay)?.value + : options.find((option) => option.selected)?.value + } + icon={SettingsSwitchIcon} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={ + autoplay + ? options.find((option) => option.value === autoplay)?.value + : options.find((option) => option.selected)?.value + } + onChange={(value) => { + setOptions((options) => + options.map((option) => + option.value === value + ? { ...option, selected: true } + : { ...option, selected: false } + ) + ); + setAutoPlay(value); + localStorage.setItem("autoplay", value); + }} + > + {options.map((option) => ( + <Radio key={option.value} value={option.value}> + {option.label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +function AutoNext() { + const [options, setOptions] = React.useState([ + { + label: "On", + value: "on", + selected: false, + }, + { + label: "Off", + value: "off", + selected: true, + }, + ]); + + const { autoNext, setAutoNext } = useWatchProvider(); + + return ( + <Menu.Root> + <SubmenuButton + label="Autoplay Next" + hint={ + autoNext + ? options.find((option) => option.value === autoNext)?.value + : options.find((option) => option.selected)?.value + } + icon={SettingsSwitchIcon} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={ + autoNext + ? options.find((option) => option.value === autoNext)?.value + : options.find((option) => option.selected)?.value + } + onChange={(value) => { + setOptions((options) => + options.map((option) => + option.value === value + ? { ...option, selected: true } + : { ...option, selected: false } + ) + ); + setAutoNext(value); + localStorage.setItem("autoNext", value); + }} + > + {options.map((option) => ( + <Radio key={option.value} value={option.value}> + {option.label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +function QualitySubmenu() { + const options = useVideoQualityOptions({ sort: "descending" }), + autoQuality = useMediaState("autoQuality"), + currentQualityText = options.selectedQuality?.height + "p" ?? "", + hint = !autoQuality ? currentQualityText : `Auto (${currentQualityText})`; + + // console.log({ options }); + + return ( + <Menu.Root> + <SubmenuButton + label="Quality" + hint={hint} + disabled={options.disabled} + icon={SettingsMenuIcon} + /> + <Menu.Content className={submenuClass}> + <Menu.RadioGroup + className="w-full flex flex-col" + value={options.selectedValue} + > + {options.map(({ label, value, bitrateText, select }) => ( + <Radio value={value} onSelect={select} key={value}> + {label} + </Radio> + ))} + </Menu.RadioGroup> + </Menu.Content> + </Menu.Root> + ); +} + +export interface RadioProps extends Menu.RadioProps {} + +function Radio({ children, ...props }: RadioProps) { + return ( + <Menu.Radio + className="ring-media-focus group relative flex w-full cursor-pointer select-none items-center justify-start rounded-sm p-2.5 outline-none data-[hocus]:bg-white/10 data-[focus]:ring-[3px]" + {...props} + > + <RadioButtonIcon className="h-4 w-4 text-white group-data-[checked]:hidden" /> + <RadioButtonSelectedIcon + className="text-media-brand hidden h-4 w-4 group-data-[checked]:block" + type="radio-button-selected" + /> + <span className="ml-2">{children}</span> + </Menu.Radio> + ); +} + +export interface SubmenuButtonProps { + label: string; + hint: string; + disabled?: boolean; + icon: ReactElement; +} + +function SubmenuButton({ + label, + hint, + icon: Icon, + disabled, +}: SubmenuButtonProps) { + return ( + <Menu.Button + className="ring-media-focus data-[open]:bg-secondary parent left-0 z-10 flex w-full cursor-pointer select-none items-center justify-start rounded-sm p-2.5 outline-none ring-inset data-[open]:sticky data-[open]:-top-2.5 data-[hocus]:bg-white/10 data-[focus]:ring-[3px]" + disabled={disabled} + > + <ChevronLeftIcon className="parent-data-[open]:block -ml-0.5 mr-1.5 hidden h-[18px] w-[18px]" /> + <div className="contents parent-data-[open]:hidden"> + <Icon className="w-5 h-5" /> + </div> + <span className="ml-1.5 parent-data-[open]:ml-0">{label}</span> + <span className="ml-auto text-sm text-white/50">{hint}</span> + <ChevronRightIcon className="parent-data-[open]:hidden ml-0.5 h-[18px] w-[18px] text-sm text-white/50" /> + </Menu.Button> + ); +} diff --git a/components/watch/new-player/components/sliders.tsx b/components/watch/new-player/components/sliders.tsx new file mode 100644 index 0000000..f31e28a --- /dev/null +++ b/components/watch/new-player/components/sliders.tsx @@ -0,0 +1,73 @@ +import { TimeSlider, VolumeSlider } from "@vidstack/react"; + +export function Volume() { + return ( + <VolumeSlider.Root className="volume-slider group relative mx-[7.5px] inline-flex h-10 w-full max-w-[80px] cursor-pointer touch-none select-none items-center outline-none aria-hidden:hidden"> + <VolumeSlider.Track className="relative ring-media-focus z-0 h-[5px] w-full rounded-sm bg-white/30 group-data-[focus]:ring-[3px]"> + <VolumeSlider.TrackFill className="bg-white absolute h-full w-[var(--slider-fill)] rounded-sm will-change-[width]" /> + </VolumeSlider.Track> + + <VolumeSlider.Preview + className="flex flex-col items-center opacity-0 transition-opacity duration-200 data-[visible]:opacity-100" + noClamp + > + <VolumeSlider.Value className="rounded-sm bg-black px-2 py-px text-[13px] font-medium" /> + </VolumeSlider.Preview> + <VolumeSlider.Thumb className="absolute left-[var(--slider-fill)] top-1/2 z-20 h-[15px] w-[15px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-[#cacaca] bg-white opacity-0 ring-white/40 transition-opacity group-data-[active]:opacity-100 group-data-[dragging]:ring-4 will-change-[left]" /> + </VolumeSlider.Root> + ); +} + +export interface TimeSliderProps { + thumbnails?: string; + host?: boolean; +} + +export function Time({ thumbnails, host }: TimeSliderProps) { + return ( + <TimeSlider.Root + className={`${ + host ? "" : "pointer-events-none" + } time-slider group relative mx-[7.5px] inline-flex h-10 w-full cursor-pointer touch-none select-none items-center outline-none`} + > + <TimeSlider.Chapters className="relative flex h-full w-full items-center rounded-[1px]"> + {(cues, forwardRef) => + cues.map((cue) => ( + <div + className="last-child:mr-0 group/slider relative mr-0.5 flex h-full w-full items-center rounded-[1px]" + style={{ contain: "layout style" }} + key={cue.startTime} + ref={forwardRef} + > + <TimeSlider.Track className="relative ring-media-focus z-0 h-[5px] group-hover/slider:h-[10px] transition-all duration-100 w-full rounded-sm bg-white/30 group-data-[focus]:ring-[3px]"> + <TimeSlider.TrackFill className="bg-white absolute h-full w-[var(--chapter-fill)] rounded-sm will-change-[width]" /> + <TimeSlider.Progress className="absolute z-10 h-full w-[var(--chapter-progress)] rounded-sm bg-white/50 will-change-[width]" /> + </TimeSlider.Track> + </div> + )) + } + </TimeSlider.Chapters> + {/* <TimeSlider.Track className="relative ring-media-focus z-0 h-[5px] w-full rounded-sm bg-white/30 group-data-[focus]:ring-[3px]"> + <TimeSlider.TrackFill className="bg-white absolute h-full w-[var(--slider-fill)] rounded-sm will-change-[width]" /> + <TimeSlider.Progress className="absolute z-10 h-full w-[var(--slider-progress)] rounded-sm bg-white/40 will-change-[width]" /> + </TimeSlider.Track> */} + + <TimeSlider.Thumb className="absolute left-[var(--slider-fill)] top-1/2 z-20 h-[15px] w-[15px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-[#cacaca] bg-white opacity-0 ring-white/40 transition-opacity group-data-[active]:opacity-100 group-data-[dragging]:ring-4 will-change-[left]" /> + + <TimeSlider.Preview className="flex flex-col items-center opacity-0 transition-opacity duration-200 data-[visible]:opacity-100 pointer-events-none"> + {thumbnails ? ( + <TimeSlider.Thumbnail.Root + src={thumbnails} + className="block h-[var(--thumbnail-height)] max-h-[160px] min-h-[80px] w-[var(--thumbnail-width)] min-w-[120px] max-w-[180px] overflow-hidden border border-white bg-black" + > + <TimeSlider.Thumbnail.Img /> + </TimeSlider.Thumbnail.Root> + ) : null} + + <TimeSlider.ChapterTitle className="mt-2 text-sm" /> + + <TimeSlider.Value className="text-[13px]" /> + </TimeSlider.Preview> + </TimeSlider.Root> + ); +} diff --git a/components/watch/new-player/components/time-group.tsx b/components/watch/new-player/components/time-group.tsx new file mode 100644 index 0000000..45fc795 --- /dev/null +++ b/components/watch/new-player/components/time-group.tsx @@ -0,0 +1,11 @@ +import { Time } from "@vidstack/react"; + +export function TimeGroup() { + return ( + <div className="ml-1.5 flex items-center text-sm font-medium"> + <Time className="time" type="current" /> + <div className="mx-1 text-white/80">/</div> + <Time className="time" type="duration" /> + </div> + ); +} diff --git a/components/watch/new-player/components/title.tsx b/components/watch/new-player/components/title.tsx new file mode 100644 index 0000000..6233061 --- /dev/null +++ b/components/watch/new-player/components/title.tsx @@ -0,0 +1,35 @@ +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import { useMediaRemote } from "@vidstack/react"; +import { ChevronLeftIcon } from "@vidstack/react/icons"; +import { Navigation } from "../player"; + +type TitleProps = { + navigation?: Navigation; +}; + +export function Title({ navigation }: TitleProps) { + const { dataMedia } = useWatchProvider(); + const remote = useMediaRemote(); + + return ( + <div className="media-fullscreen:flex hidden text-start flex-1 text-sm font-medium text-white"> + {/* <p className="pt-4 h-full"> + </p> */} + <button + type="button" + className="flex items-center gap-2 text-sm font-karla w-full" + onClick={() => remote.toggleFullscreen()} + > + <ChevronLeftIcon className="font-extrabold w-7 h-7" /> + <span className="max-w-[75%] text-base xl:text-2xl font-semibold whitespace-nowrap overflow-hidden text-ellipsis"> + {dataMedia?.title?.romaji} + </span> + <span className="text-base xl:text-2xl font-normal">/</span> + <span className="text-base xl:text-2xl font-normal"> + Episode {navigation?.playing.number} + </span> + {/* <span className="absolute top-5 left-[1s0%] w-[24%] h-[1px] bg-white" /> */} + </button> + </div> + ); +} diff --git a/components/watch/new-player/player.module.css b/components/watch/new-player/player.module.css new file mode 100644 index 0000000..f2f5b39 --- /dev/null +++ b/components/watch/new-player/player.module.css @@ -0,0 +1,50 @@ +.player { + --media-brand: #f5f5f5; + --media-focus-ring-color: #4e9cf6; + --media-focus-ring: 0 0 0 3px var(--media-focus-ring-color); + + --media-tooltip-y-offset: 30px; + --media-menu-y-offset: 30px; + + background-color: black; + border-radius: var(--media-border-radius); + color: #f5f5f5; + contain: layout; + font-family: sans-serif; + overflow: hidden; +} + +.player[data-focus]:not([data-playing]) { + box-shadow: var(--media-focus-ring); +} + +.player video { + height: 100%; + object-fit: contain; + display: block; +} + +.player video, +.poster { + border-radius: var(--media-border-radius); +} + +.poster { + display: block; + position: absolute; + top: 0; + left: 0; + opacity: 0; + width: 100%; + height: 100%; +} + +.poster[data-visible] { + opacity: 1; +} + +.poster img { + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/components/watch/new-player/player.tsx b/components/watch/new-player/player.tsx new file mode 100644 index 0000000..b98ff79 --- /dev/null +++ b/components/watch/new-player/player.tsx @@ -0,0 +1,471 @@ +import "@vidstack/react/player/styles/base.css"; + +import { useEffect, useRef, useState } from "react"; + +import style from "./player.module.css"; + +import { + MediaPlayer, + MediaProvider, + useMediaStore, + useMediaRemote, + type MediaPlayerInstance, + Track, + MediaTimeUpdateEventDetail, + MediaTimeUpdateEvent, +} from "@vidstack/react"; +import { VideoLayout } from "./components/layouts/video-layout"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; +import { useRouter } from "next/router"; +import { Subtitle } from "types/episodes/TrackData"; +import useWatchStorage from "@/lib/hooks/useWatchStorage"; +import { Sessions } from "types/episodes/Sessions"; +import { useAniList } from "@/lib/anilist/useAnilist"; + +export interface Navigation { + prev: Prev; + playing: Playing; + next: Next; +} + +export interface Prev { + id: string; + title: string; + img: string; + number: number; + description: string; +} + +export interface Playing { + id: string; + title: string; + description: string; + img: string; + number: number; +} + +export interface Next { + id: string; + title: string; + description: string; + img: string; + number: number; +} + +type VidStackProps = { + id: string; + navigation: Navigation; + userData: UserData; + sessions: Sessions; +}; + +export type UserData = { + id?: string; + userProfileId?: string; + aniId: string; + watchId: string; + title: string; + aniTitle: string; + image: string; + episode: number; + duration: number; + timeWatched: number; + provider: string; + nextId: string; + nextNumber: number; + dub: boolean; + createdAt: string; +}; + +type SkipData = { + startTime: number; + endTime: number; + text: string; +}; + +export default function VidStack({ + id, + navigation, + userData, + sessions, +}: VidStackProps) { + let player = useRef<MediaPlayerInstance>(null); + + const { + aspectRatio, + setAspectRatio, + track, + playerState, + dataMedia, + autoNext, + } = useWatchProvider(); + + const { qualities, duration } = useMediaStore(player); + + const [getSettings, updateSettings] = useWatchStorage(); + const { marked, setMarked } = useWatchProvider(); + + const { markProgress } = useAniList(sessions); + + const remote = useMediaRemote(player); + + const { defaultQuality = null } = track ?? {}; + + const [chapters, setChapters] = useState<string>(""); + + const router = useRouter(); + + useEffect(() => { + if (qualities.length > 0) { + const sourceQuality = qualities.reduce( + (max, obj) => (obj.height > max.height ? obj : max), + qualities[0] + ); + const aspectRatio = calculateAspectRatio( + sourceQuality.width, + sourceQuality.height + ); + + setAspectRatio(aspectRatio); + } + }, [qualities]); + + const [isPlaying, setIsPlaying] = useState(false); + let interval: any; + + useEffect(() => { + const plyr = player.current; + + function handlePlay() { + // console.log("Player is playing"); + setIsPlaying(true); + } + + function handlePause() { + // console.log("Player is paused"); + setIsPlaying(false); + } + + function handleEnd() { + // console.log("Player ended"); + setIsPlaying(false); + } + + plyr?.addEventListener("play", handlePlay); + plyr?.addEventListener("pause", handlePause); + plyr?.addEventListener("ended", handleEnd); + + return () => { + plyr?.removeEventListener("play", handlePlay); + plyr?.removeEventListener("pause", handlePause); + plyr?.removeEventListener("ended", handleEnd); + }; + }, [id, duration]); + + useEffect(() => { + if (isPlaying) { + interval = setInterval(async () => { + const currentTime = player.current?.currentTime + ? Math.round(player.current?.currentTime) + : 0; + + const parsedImage = navigation?.playing?.img?.includes("null") + ? dataMedia?.coverImage?.extraLarge + : navigation?.playing?.img; + + if (sessions?.user?.name) { + // console.log("updating user data"); + await fetch("/api/user/update/episode", { + method: "PUT", + body: JSON.stringify({ + name: sessions?.user?.name, + id: String(dataMedia?.id), + watchId: navigation?.playing?.id, + title: + navigation.playing?.title || + dataMedia.title?.romaji || + dataMedia.title?.english, + aniTitle: dataMedia.title?.romaji || dataMedia.title?.english, + image: parsedImage, + number: Number(navigation.playing?.number), + duration: duration, + timeWatched: currentTime, + provider: track?.provider, + nextId: navigation?.next?.id, + nextNumber: Number(navigation?.next?.number), + dub: track?.isDub ? true : false, + }), + }); + } + + updateSettings(navigation?.playing?.id, { + aniId: String(dataMedia.id), + watchId: navigation?.playing?.id, + title: + navigation.playing?.title || + dataMedia.title?.romaji || + dataMedia.title?.english, + aniTitle: dataMedia.title?.romaji || dataMedia.title?.english, + image: parsedImage, + episode: Number(navigation.playing?.number), + duration: duration, + timeWatched: currentTime, // update timeWatched with currentTime + provider: track?.provider, + nextId: navigation?.next?.id, + nextNumber: navigation?.next?.number, + dub: track?.isDub ? true : false, + createdAt: new Date().toISOString(), + }); + // console.log("update"); + }, 5000); + } else { + clearInterval(interval); + } + + return () => { + clearInterval(interval); + }; + }, [isPlaying, sessions?.user?.name, track?.isDub, duration]); + + useEffect(() => { + const autoplay = localStorage.getItem("autoplay") || "off"; + + return player.current!.subscribe(({ canPlay }) => { + // console.log("can play?", "->", canPlay); + if (canPlay) { + if (autoplay === "on") { + if (playerState?.currentTime === 0) { + remote.play(); + } else { + if (playerState?.isPlaying) { + remote.play(); + } else { + remote.pause(); + } + } + } else { + if (playerState?.isPlaying) { + remote.play(); + } else { + remote.pause(); + } + } + remote.seek(playerState?.currentTime); + } + }); + }, [playerState?.currentTime, playerState?.isPlaying]); + + useEffect(() => { + const chapter = track?.skip, + videoDuration = Math.round(duration); + + let vtt = "WEBVTT\n\n"; + + let lastEndTime = 0; + + if (chapter && chapter?.length > 0) { + chapter.forEach((item: SkipData) => { + let startMinutes = Math.floor(item.startTime / 60); + let startSeconds = item.startTime % 60; + let endMinutes = Math.floor(item.endTime / 60); + let endSeconds = item.endTime % 60; + + let start = `${startMinutes.toString().padStart(2, "0")}:${startSeconds + .toString() + .padStart(2, "0")}`; + let end = `${endMinutes.toString().padStart(2, "0")}:${endSeconds + .toString() + .padStart(2, "0")}`; + + vtt += `${start} --> ${end}\n${item.text}\n\n`; + if (item.endTime > lastEndTime) { + lastEndTime = item.endTime; + } + }); + + if (lastEndTime < videoDuration) { + let startMinutes = Math.floor(lastEndTime / 60); + let startSeconds = lastEndTime % 60; + let endMinutes = Math.floor(videoDuration / 60); + let endSeconds = videoDuration % 60; + + let start = `${startMinutes.toString().padStart(2, "0")}:${startSeconds + .toString() + .padStart(2, "0")}`; + let end = `${endMinutes.toString().padStart(2, "0")}:${endSeconds + .toString() + .padStart(2, "0")}`; + + vtt += `${start} --> ${end}\n\n\n`; + } + + const vttBlob = new Blob([vtt], { type: "text/vtt" }); + const vttUrl = URL.createObjectURL(vttBlob); + + setChapters(vttUrl); + } + return () => { + setChapters(""); + }; + }, [track?.skip, duration]); + + useEffect(() => { + return () => { + if (player.current) { + player.current.destroy(); + } + }; + }, []); + + function onEnded() { + if (!navigation?.next?.id) return; + if (autoNext === "on") { + const nextButton = document.querySelector(".next-button"); + + let timeoutId: ReturnType<typeof setTimeout>; + + const stopTimeout = () => { + clearTimeout(timeoutId); + nextButton?.classList.remove("progress"); + }; + + nextButton?.classList.remove("hidden"); + nextButton?.classList.add("progress"); + + timeoutId = setTimeout(() => { + console.log("time is up!"); + if (navigation?.next) { + router.push( + `/en/anime/watch/${dataMedia.id}/${track.provider}?id=${ + navigation?.next?.id + }&num=${navigation?.next?.number}${ + track?.isDub ? `&dub=${track?.isDub}` : "" + }` + ); + } + }, 7000); + + nextButton?.addEventListener("mouseover", stopTimeout); + } + } + + function onLoadedMetadata() { + const seek: any = getSettings(navigation?.playing?.id); + if (playerState?.currentTime !== 0) return; + const seekTime = seek?.timeWatched; + const percentage = duration !== 0 ? seekTime / Math.round(duration) : 0; + const percentagedb = + duration !== 0 ? userData?.timeWatched / Math.round(duration) : 0; + + if (percentage >= 0.9 || percentagedb >= 0.9) { + remote.seek(0); + console.log("Video started from the beginning"); + } else if (userData?.timeWatched) { + remote.seek(userData?.timeWatched); + } else { + remote.seek(seekTime); + } + } + + let mark = 0; + function onTimeUpdate(detail: MediaTimeUpdateEventDetail) { + if (sessions) { + let currentTime = detail.currentTime; + const percentage = currentTime / duration; + + if (percentage >= 0.9) { + // use >= instead of > + if (mark < 1 && marked < 1) { + mark = 1; + setMarked(1); + console.log("marking progress"); + markProgress(dataMedia.id, navigation.playing.number); + } + } + } + + const opButton = document.querySelector(".op-button"); + const edButton = document.querySelector(".ed-button"); + + const op: SkipData = track?.skip.find( + (item: SkipData) => item.text === "Opening" + ), + ed = track?.skip.find((item: SkipData) => item.text === "Ending"); + + if ( + op && + detail.currentTime > op.startTime && + detail.currentTime < op.endTime + ) { + opButton?.classList.remove("hidden"); + } else { + opButton?.classList.add("hidden"); + } + + if ( + ed && + detail.currentTime > ed.startTime && + detail.currentTime < ed.endTime + ) { + edButton?.classList.remove("hidden"); + } else { + edButton?.classList.add("hidden"); + } + } + + function onSeeked(currentTime: number) { + const nextButton = document.querySelector(".next-button"); + // console.log({ currentTime, duration }); + if (currentTime !== duration) { + nextButton?.classList.add("hidden"); + } + } + + return ( + <MediaPlayer + key={id} + className={`${style.player} player`} + title={ + navigation?.playing?.title || + `Episode ${navigation?.playing?.number}` || + "Loading..." + } + load="idle" + crossorigin="anonymous" + src={{ + src: defaultQuality?.url, + type: "application/vnd.apple.mpegurl", + }} + onTimeUpdate={onTimeUpdate} + playsinline + aspectRatio={aspectRatio} + onEnd={onEnded} + onSeeked={onSeeked} + onLoadedMetadata={onLoadedMetadata} + ref={player} + > + <MediaProvider> + {track && + track?.subtitles && + track?.subtitles?.map((track: Subtitle) => ( + <Track {...track} key={track.src} /> + ))} + {chapters?.length > 0 && ( + <Track key={chapters} src={chapters} kind="chapters" default={true} /> + )} + </MediaProvider> + <VideoLayout thumbnails={track?.thumbnails} navigation={navigation} /> + </MediaPlayer> + ); +} + +export function calculateAspectRatio(width: number, height: number) { + if (width === 0 && height === 0) { + return "16/9"; + } + + const gcd = (a: number, b: number): any => (b === 0 ? a : gcd(b, a % b)); + const divisor = gcd(width, height); + const aspectRatio = `${width / divisor}/${height / divisor}`; + return aspectRatio; +} diff --git a/components/watch/new-player/tracks.tsx b/components/watch/new-player/tracks.tsx new file mode 100644 index 0000000..abc1fb5 --- /dev/null +++ b/components/watch/new-player/tracks.tsx @@ -0,0 +1,184 @@ +export const textTracks = [ + // Subtitles + // { + // src: "https://media-files.vidstack.io/sprite-fight/subs/english.vtt", + // label: "English", + // language: "en-US", + // kind: "subtitles", + // default: true, + // }, + // { + // src: "https://media-files.vidstack.io/sprite-fight/subs/spanish.vtt", + // label: "Spanish", + // language: "es-ES", + // kind: "subtitles", + // }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ara-3.vtt", + label: "Arabic", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/chi-4.vtt", + label: "Chinese - Chinese Simplified", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/chi-5.vtt", + label: "Chinese - Chinese Traditional", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/hrv-6.vtt", + label: "Croatian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/cze-7.vtt", + label: "Czech", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/dan-8.vtt", + label: "Danish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/dut-9.vtt", + label: "Dutch", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/32/6d/326d5416033fe39a1540b11908f191fe/326d5416033fe39a1540b11908f191fe.vtt", + label: "English", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/fin-10.vtt", + label: "Finnish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/fre-11.vtt", + label: "French", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ger-12.vtt", + label: "German", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/gre-13.vtt", + label: "Greek", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/heb-14.vtt", + label: "Hebrew", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/hun-15.vtt", + label: "Hungarian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ind-16.vtt", + label: "Indonesian", + kind: "subtitles", + default: true, + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ita-17.vtt", + label: "Italian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/jpn-18.vtt", + label: "Japanese", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/kor-19.vtt", + label: "Korean", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/may-20.vtt", + label: "Malay", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/nob-21.vtt", + label: "Norwegian Bokmål - Norwegian Bokmal", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/pol-22.vtt", + label: "Polish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/por-23.vtt", + label: "Portuguese", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/por-24.vtt", + label: "Portuguese - Brazilian Portuguese", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/rum-25.vtt", + label: "Romanian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/rus-26.vtt", + label: "Russian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/spa-27.vtt", + label: "Spanish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/spa-28.vtt", + label: "Spanish - European Spanish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/swe-29.vtt", + label: "Swedish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/tha-30.vtt", + label: "Thai", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/tur-31.vtt", + label: "Turkish", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/ukr-32.vtt", + label: "Ukrainian", + kind: "subtitles", + }, + { + src: "https://ccb.megaresources.co/3c/0f/3c0fbf6aafc60b02d3be402099638403/vie-33.vtt", + label: "Vietnamese", + kind: "subtitles", + }, + // // Chapters + // { + // src: "https://media-files.vidstack.io/sprite-fight/chapters.vtt", + // kind: "chapters", + // language: "en-US", + // default: true, + // }, +] as const; diff --git a/components/watch/player/artplayer.js b/components/watch/player/artplayer.js deleted file mode 100644 index 666c103..0000000 --- a/components/watch/player/artplayer.js +++ /dev/null @@ -1,387 +0,0 @@ -import { useEffect, useRef } from "react"; -import Artplayer from "artplayer"; -import Hls from "hls.js"; -import { useWatchProvider } from "@/lib/context/watchPageProvider"; -import artplayerPluginHlsQuality from "artplayer-plugin-hls-quality"; - -export default function NewPlayer({ - playerRef, - option, - getInstance, - provider, - track, - defSub, - defSize, - subtitles, - subSize, - res, - quality, - ...rest -}) { - const artRef = useRef(null); - const { setTheaterMode, setPlayerState, setAutoPlay } = useWatchProvider(); - - function playM3u8(video, url, art) { - if (Hls.isSupported()) { - if (art.hls) art.hls.destroy(); - const hls = new Hls(); - hls.loadSource(url); - hls.attachMedia(video); - art.hls = hls; - art.on("destroy", () => hls.destroy()); - } else if (video.canPlayType("application/vnd.apple.mpegurl")) { - video.src = url; - } else { - art.notice.show = "Unsupported playback format: m3u8"; - } - } - - useEffect(() => { - Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.15, 1.2, 1.5, 1.7, 2]; - - const art = new Artplayer({ - ...option, - container: artRef.current, - type: "m3u8", - customType: { - m3u8: playM3u8, - }, - ...(subtitles?.length > 0 && { - subtitle: { - url: `${defSub}`, - // type: "vtt", - encoding: "utf-8", - default: true, - name: "English", - escape: false, - style: { - color: "#FFFF", - fontSize: `${defSize?.size}`, - fontFamily: localStorage.getItem("font") - ? localStorage.getItem("font") - : "Arial", - textShadow: localStorage.getItem("subShadow") - ? JSON.parse(localStorage.getItem("subShadow")).value - : "0px 0px 10px #000000", - }, - }, - }), - - plugins: [ - artplayerPluginHlsQuality({ - // Show quality in setting - setting: true, - - // Get the resolution text from level - getResolution: (level) => level.height + "P", - - // I18n - title: "Quality", - auto: "Auto", - }), - ], - - settings: [ - // provider === "gogoanime" && - { - html: "Autoplay Next", - icon: '<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"><path fill="currentColor" d="M4.05 16.975q-.5.35-1.025.05t-.525-.9v-8.25q0-.6.525-.888t1.025.038l6.2 4.15q.45.3.45.825t-.45.825l-6.2 4.15Zm10 0q-.5.35-1.025.05t-.525-.9v-8.25q0-.6.525-.888t1.025.038l6.2 4.15q.45.3.45.825t-.45.825l-6.2 4.15Z"></path></svg>', - tooltip: "ON/OFF", - switch: localStorage.getItem("autoplay") === "true" ? true : false, - onSwitch: function (item) { - // setPlayNext(!item.switch); - localStorage.setItem("autoplay", !item.switch); - return !item.switch; - }, - }, - { - html: "Autoplay Video", - icon: '<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"><path fill="currentColor" d="M4.05 16.975q-.5.35-1.025.05t-.525-.9v-8.25q0-.6.525-.888t1.025.038l6.2 4.15q.45.3.45.825t-.45.825l-6.2 4.15Zm10 0q-.5.35-1.025.05t-.525-.9v-8.25q0-.6.525-.888t1.025.038l6.2 4.15q.45.3.45.825t-.45.825l-6.2 4.15Z"></path></svg>', - // icon: '<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"><path fill="currentColor" d="M5.59 7.41L7 6l6 6l-6 6l-1.41-1.41L10.17 12L5.59 7.41m6 0L13 6l6 6l-6 6l-1.41-1.41L16.17 12l-4.58-4.59Z"></path></svg>', - tooltip: "ON/OFF", - switch: - localStorage.getItem("autoplay_video") === "true" ? true : false, - onSwitch: function (item) { - setAutoPlay(!item.switch); - localStorage.setItem("autoplay_video", !item.switch); - return !item.switch; - }, - }, - { - html: "Alternative Quality", - width: 250, - tooltip: `${res}`, - selector: quality?.alt, - icon: '<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 512 512"><path fill="currentColor" d="M381.25 112a48 48 0 0 0-90.5 0H48v32h242.75a48 48 0 0 0 90.5 0H464v-32ZM176 208a48.09 48.09 0 0 0-45.25 32H48v32h82.75a48 48 0 0 0 90.5 0H464v-32H221.25A48.09 48.09 0 0 0 176 208Zm160 128a48.09 48.09 0 0 0-45.25 32H48v32h242.75a48 48 0 0 0 90.5 0H464v-32h-82.75A48.09 48.09 0 0 0 336 336Z"></path></svg>', - onSelect: function (item) { - art.switchQuality(item.url, item.html); - localStorage.setItem("quality", item.html); - return item.html; - }, - }, - { - html: "Server", - width: 250, - tooltip: `${quality?.server[0].html}`, - icon: '<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 32 32"><path fill="currentColor" d="m24.6 24.4l2.6 2.6l-2.6 2.6L26 31l4-4l-4-4zm-2.2 0L19.8 27l2.6 2.6L21 31l-4-4l4-4z"></path><circle cx="11" cy="8" r="1" fill="currentColor"></circle><circle cx="11" cy="16" r="1" fill="currentColor"></circle><circle cx="11" cy="24" r="1" fill="currentColor"></circle><path fill="currentColor" d="M24 3H8c-1.1 0-2 .9-2 2v22c0 1.1.9 2 2 2h7v-2H8v-6h18V5c0-1.1-.9-2-2-2zm0 16H8v-6h16v6zm0-8H8V5h16v6z"></path></svg>', - selector: quality?.server, - onSelect: function (item) { - art.switchQuality(item.url, item.html); - localStorage.setItem("quality", item.html); - return item.html; - }, - }, - subtitles?.length > 0 && { - html: "Subtitles", - icon: '<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"><path fill="currentColor" d="M4 20q-.825 0-1.413-.588T2 18V6q0-.825.588-1.413T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.588 1.413T20 20H4Zm2-4h8v-2H6v2Zm10 0h2v-2h-2v2ZM6 12h2v-2H6v2Zm4 0h8v-2h-8v2Z"></path></svg>', - width: 300, - tooltip: "Settings", - selector: [ - { - html: "Display", - icon: '<svg xmlns="http://www.w3.org/2000/svg" width="35" height="26" viewBox="0 -960 960 960"><path d="M480.169-341.796q65.754 0 111.894-46.31 46.141-46.309 46.141-112.063t-46.31-111.894q-46.309-46.141-112.063-46.141t-111.894 46.31q-46.141 46.309-46.141 112.063t46.31 111.894q46.309 46.141 112.063 46.141zm-.371-48.307q-45.875 0-77.785-32.112-31.91-32.112-31.91-77.987 0-45.875 32.112-77.785 32.112-31.91 77.987-31.91 45.875 0 77.785 32.112 31.91 32.112 31.91 77.987 0 45.875-32.112 77.785-32.112 31.91-77.987 31.91zm.226 170.102q-130.921 0-239.6-69.821-108.679-69.82-167.556-186.476-2.687-4.574-3.892-10.811Q67.77-493.347 67.77-500t1.205-12.891q1.205-6.237 3.892-10.811Q131.745-640.358 240.4-710.178q108.655-69.821 239.576-69.821t239.6 69.821q108.679 69.82 167.556 186.476 2.687 4.574 3.892 10.811 1.205 6.238 1.205 12.891t-1.205 12.891q-1.205 6.237-3.892 10.811Q828.255-359.642 719.6-289.822q-108.655 69.821-239.576 69.821zM480-500zm-.112 229.744q117.163 0 215.048-62.347Q792.821-394.949 844.308-500q-51.487-105.051-149.26-167.397-97.772-62.347-214.936-62.347-117.163 0-215.048 62.347Q167.179-605.051 115.282-500q51.897 105.051 149.67 167.397 97.772 62.347 214.936 62.347z"></path></svg>', - tooltip: "Show", - switch: true, - onSwitch: function (item) { - item.tooltip = item.switch ? "Hide" : "Show"; - art.subtitle.show = !item.switch; - return !item.switch; - }, - }, - { - html: "Font Size", - icon: '<svg xmlns="http://www.w3.org/2000/svg" width="35" height="26" viewBox="0 -960 960 960"><path d="M619.861-177.694q-15.655 0-26.475-10.918-10.821-10.918-10.821-26.516v-492.309H415.128q-15.598 0-26.516-10.959-10.918-10.959-10.918-26.615 0-15.655 10.918-26.475 10.918-10.82 26.516-10.82h409.744q15.598 0 26.516 10.958 10.918 10.959 10.918 26.615 0 15.656-10.918 26.476-10.918 10.82-26.516 10.82H657.435v492.309q0 15.598-10.959 26.516-10.959 10.918-26.615 10.918zm-360 0q-15.655 0-26.475-10.918-10.821-10.918-10.821-26.516v-292.309h-87.437q-15.598 0-26.516-10.959-10.918-10.959-10.918-26.615 0-15.655 10.918-26.475 10.918-10.82 26.516-10.82h249.744q15.598 0 26.516 10.958 10.918 10.959 10.918 26.615 0 15.656-10.918 26.476-10.918 10.82-26.516 10.82h-87.437v292.309q0 15.598-10.959 26.516-10.959 10.918-26.615 10.918z"></path></svg>', - selector: subSize, - onSelect: function (item) { - if (item.html === "Small") { - art.subtitle.style({ fontSize: "16px" }); - localStorage.setItem( - "subSize", - JSON.stringify({ - size: "16px", - html: "Small", - }) - ); - } else if (item.html === "Medium") { - art.subtitle.style({ fontSize: "36px" }); - localStorage.setItem( - "subSize", - JSON.stringify({ - size: "36px", - html: "Medium", - }) - ); - } else if (item.html === "Large") { - art.subtitle.style({ fontSize: "56px" }); - localStorage.setItem( - "subSize", - JSON.stringify({ - size: "56px", - html: "Large", - }) - ); - } - }, - }, - { - html: "Language", - icon: '<svg xmlns="http://www.w3.org/2000/svg" width="35" height="26" viewBox="0 -960 960 960"><path d="M528.282-110.771q-21.744 0-31.308-14.013t-2.205-34.295l135.952-359.307q5.304-14.793 20.292-25.126 14.988-10.334 31.152-10.334 15.398 0 30.85 10.388 15.451 10.387 20.932 25.125l137.128 357.485q8.025 20.949-1.83 35.513-9.855 14.564-33.24 14.564-10.366 0-19.392-6.616-9.025-6.615-12.72-16.242l-30.997-91.808H594.769l-33.381 91.869q-3.645 9.181-13.148 15.989-9.504 6.808-19.958 6.808zm87.871-179.281h131.64l-64.615-180.717h-2.41l-64.615 180.717zM302.104-608.384q14.406 25.624 31.074 48.184 16.669 22.559 37.643 47.021 41.333-44.128 68.628-90.461t46.038-97.897H111.499q-15.674 0-26.278-10.615-10.603-10.616-10.603-26.308t10.615-26.307q10.616-10.616 26.308-10.616h221.537v-36.923q0-15.692 10.615-26.307 10.616-10.616 26.308-10.616t26.307 10.616q10.616 10.615 10.616 26.307v36.923h221.537q15.692 0 26.307 10.616 10.616 10.615 10.616 26.307 0 15.692-10.616 26.308-10.615 10.615-26.307 10.615h-69.088q-19.912 64.153-53.237 125.74-33.325 61.588-82.341 116.412l89.384 90.974-27.692 75.179-115.486-112.922-158.948 158.947q-10.615 10.616-25.667 10.616-15.051 0-25.666-11.026-11.026-10.615-11.026-25.666 0-15.052 11.026-26.077l161.614-161.358q-24.666-28.308-45.551-57.307-20.884-29-37.756-60.103-10.641-19.871-1.346-34.717t33.038-14.846q9.088 0 18.429 5.73 9.34 5.731 13.956 13.577z"></path></svg>', - tooltip: "English", - selector: [...subtitles], - onSelect: function (item) { - art.subtitle.switch(item.url, { - name: item.html, - }); - return item.html; - }, - }, - { - html: "Font Family", - tooltip: localStorage.getItem("font") - ? localStorage.getItem("font") - : "Arial", - selector: [ - { html: "Arial" }, - { html: "Comic Sans MS" }, - { html: "Verdana" }, - { html: "Tahoma" }, - { html: "Trebuchet MS" }, - { html: "Times New Roman" }, - { html: "Georgia" }, - { html: "Impact " }, - { html: "Andalé Mono" }, - { html: "Palatino" }, - { html: "Baskerville" }, - { html: "Garamond" }, - { html: "Courier New" }, - { html: "Brush Script MT" }, - ], - onSelect: function (item) { - art.subtitle.style({ fontFamily: item.html }); - localStorage.setItem("font", item.html); - return item.html; - }, - }, - { - html: "Font Shadow", - tooltip: localStorage.getItem("subShadow") - ? JSON.parse(localStorage.getItem("subShadow")).shadow - : "Default", - selector: [ - { html: "None", value: "none" }, - { - html: "Uniform", - value: - "2px 2px 0px #000, -2px -2px 0px #000, 2px -2px 0px #000, -2px 2px 0px #000", - }, - { html: "Raised", value: "-1px 2px 3px rgba(0, 0, 0, 1)" }, - { html: "Depressed", value: "-2px -3px 3px rgba(0, 0, 0, 1)" }, - { html: "Glow", value: "0 0 10px rgba(0, 0, 0, 0.8)" }, - { - html: "Block", - value: - "-3px 3px 4px rgba(0, 0, 0, 1),2px 2px 4px rgba(0, 0, 0, 1),1px -1px 3px rgba(0, 0, 0, 1),-3px -2px 4px rgba(0, 0, 0, 1)", - }, - ], - onSelect: function (item) { - art.subtitle.style({ textShadow: item.value }); - localStorage.setItem( - "subShadow", - JSON.stringify({ shadow: item.html, value: item.value }) - ); - return item.html; - }, - }, - ], - }, - ].filter(Boolean), - controls: [ - { - name: "theater-button", - index: 11, - position: "right", - tooltip: "Theater (t)", - html: '<i class="theater"><svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20"><path fill="currentColor" d="M19 3H1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm-1 12H2V5h16v10z"></path></svg></i>', - click: function (...args) { - setPlayerState((prev) => ({ - ...prev, - currentTime: art.currentTime, - isPlaying: art.playing, - })); - setTheaterMode((prev) => !prev); - }, - }, - { - index: 10, - name: "fast-rewind", - position: "left", - html: '<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20"><path fill="currentColor" d="M17.959 4.571L10.756 9.52s-.279.201-.279.481s.279.479.279.479l7.203 4.951c.572.38 1.041.099 1.041-.626V5.196c0-.727-.469-1.008-1.041-.625zm-9.076 0L1.68 9.52s-.279.201-.279.481s.279.479.279.479l7.203 4.951c.572.381 1.041.1 1.041-.625v-9.61c0-.727-.469-1.008-1.041-.625z"></path></svg>', - tooltip: "Backward 5s", - click: function () { - art.backward = 5; - }, - }, - { - index: 11, - name: "fast-forward", - position: "left", - html: '<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20"><path fill="currentColor" d="M9.244 9.52L2.041 4.571C1.469 4.188 1 4.469 1 5.196v9.609c0 .725.469 1.006 1.041.625l7.203-4.951s.279-.199.279-.478c0-.28-.279-.481-.279-.481zm9.356.481c0 .279-.279.478-.279.478l-7.203 4.951c-.572.381-1.041.1-1.041-.625V5.196c0-.727.469-1.008 1.041-.625L18.32 9.52s.28.201.28.481z"></path></svg>', - tooltip: "Forward 5s", - click: function () { - art.forward = 5; - }, - }, - ], - }); - - if ("mediaSession" in navigator) { - art.on("video:timeupdate", () => { - const session = navigator.mediaSession; - if (!session) return; - session.setPositionState({ - duration: art.duration, - playbackRate: art.playbackRate, - position: art.currentTime, - }); - }); - - navigator.mediaSession.setActionHandler("play", () => { - art.play(); - }); - - navigator.mediaSession.setActionHandler("pause", () => { - art.pause(); - }); - - navigator.mediaSession.setActionHandler("previoustrack", () => { - if (track?.prev) { - router.push( - `/en/anime/watch/${id}/${provider}?id=${encodeURIComponent( - track?.prev?.id - )}&num=${track?.prev?.number}` - ); - } - }); - - navigator.mediaSession.setActionHandler("nexttrack", () => { - if (track?.next) { - router.push( - `/en/anime/watch/${id}/${provider}?id=${encodeURIComponent( - track?.next?.id - )}&num=${track?.next?.number}` - ); - } - }); - } - - playerRef.current = art; - - art.events.proxy(document, "keydown", (event) => { - // Check if the focus is on an input field or textarea - const isInputFocused = - document.activeElement.tagName === "INPUT" || - document.activeElement.tagName === "TEXTAREA"; - - if (!isInputFocused) { - if (event.key === "f" || event.key === "F") { - art.fullscreen = !art.fullscreen; - } - - if (event.key === "t" || event.key === "T") { - setPlayerState((prev) => ({ - ...prev, - currentTime: art.currentTime, - isPlaying: art.playing, - })); - setTheaterMode((prev) => !prev); - } - } - }); - - art.events.proxy(document, "keypress", (event) => { - // Check if the focus is on an input field or textarea - const isInputFocused = - document.activeElement.tagName === "INPUT" || - document.activeElement.tagName === "TEXTAREA"; - - if (!isInputFocused && event.code === "Space") { - event.preventDefault(); - art.playing ? art.pause() : art.play(); - } - }); - - if (getInstance && typeof getInstance === "function") { - getInstance(art); - } - - return () => { - if (art && art.destroy) { - art.destroy(false); - } - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return <div ref={artRef} {...rest}></div>; -} diff --git a/components/watch/player/component/controls/quality.js b/components/watch/player/component/controls/quality.js deleted file mode 100644 index 08dbd0e..0000000 --- a/components/watch/player/component/controls/quality.js +++ /dev/null @@ -1,15 +0,0 @@ -import artplayerPluginHlsQuality from "artplayer-plugin-hls-quality"; - -export const QualityPlugins = [ - artplayerPluginHlsQuality({ - // Show quality in setting - setting: true, - - // Get the resolution text from level - getResolution: (level) => level.height + "P", - - // I18n - title: "Quality", - auto: "Auto", - }), -]; diff --git a/components/watch/player/component/overlay.js b/components/watch/player/component/overlay.js deleted file mode 100644 index 1d5ac27..0000000 --- a/components/watch/player/component/overlay.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @type {import("artplayer/types/icons".Icons)} - */ -export const icons = { - screenshot: - '<svg xmlns="http://www.w3.org/2000/svg" width="16px" height="16px" viewBox="0 0 20 20"><path fill="currentColor" d="M10 8a3 3 0 1 0 0 6a3 3 0 0 0 0-6zm8-3h-2.4a.888.888 0 0 1-.789-.57l-.621-1.861A.89.89 0 0 0 13.4 2H6.6c-.33 0-.686.256-.789.568L5.189 4.43A.889.889 0 0 1 4.4 5H2C.9 5 0 5.9 0 7v9c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-8 11a5 5 0 0 1-5-5a5 5 0 1 1 10 0a5 5 0 0 1-5 5zm7.5-7.8a.7.7 0 1 1 0-1.4a.7.7 0 0 1 0 1.4z"></path></svg>', - play: '<svg xmlns="http://www.w3.org/2000/svg" width="25px" height="25px" viewBox="0 0 20 20"><path fill="currentColor" d="M15 10.001c0 .299-.305.514-.305.514l-8.561 5.303C5.51 16.227 5 15.924 5 15.149V4.852c0-.777.51-1.078 1.135-.67l8.561 5.305c-.001 0 .304.215.304.514z"></path></svg>', - pause: - '<svg xmlns="http://www.w3.org/2000/svg" width="25px" height="25px" viewBox="0 0 20 20"><path fill="currentColor" d="M15 3h-2c-.553 0-1 .048-1 .6v12.8c0 .552.447.6 1 .6h2c.553 0 1-.048 1-.6V3.6c0-.552-.447-.6-1-.6zM7 3H5c-.553 0-1 .048-1 .6v12.8c0 .552.447.6 1 .6h2c.553 0 1-.048 1-.6V3.6c0-.552-.447-.6-1-.6z"></path></svg>', - volume: - '<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20"><path fill="currentColor" d="M19 13.805c0 .657-.538 1.195-1.195 1.195H1.533c-.88 0-.982-.371-.229-.822l16.323-9.055C18.382 4.67 19 5.019 19 5.9v7.905z"></path></svg>', - fullscreenOff: - '<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20"><path fill="currentColor" d="M3.28 2.22a.75.75 0 0 0-1.06 1.06L5.44 6.5H2.75a.75.75 0 0 0 0 1.5h4.5A.75.75 0 0 0 8 7.25v-4.5a.75.75 0 0 0-1.5 0v2.69L3.28 2.22Zm10.22.53a.75.75 0 0 0-1.5 0v4.5c0 .414.336.75.75.75h4.5a.75.75 0 0 0 0-1.5h-2.69l3.22-3.22a.75.75 0 0 0-1.06-1.06L13.5 5.44V2.75ZM3.28 17.78l3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 1 0 1.06 1.06Zm10.22-3.22l3.22 3.22a.75.75 0 1 0 1.06-1.06l-3.22-3.22h2.69a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0-.75.75v4.5a.75.75 0 0 0 1.5 0v-2.69Z"></path></svg>', - fullscreenOn: - '<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20"><path fill="currentColor" d="m13.28 7.78l3.22-3.22v2.69a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.69l-3.22 3.22a.75.75 0 0 0 1.06 1.06ZM2 17.25v-4.5a.75.75 0 0 1 1.5 0v2.69l3.22-3.22a.75.75 0 0 1 1.06 1.06L4.56 16.5h2.69a.75.75 0 0 1 0 1.5h-4.5a.747.747 0 0 1-.75-.75Zm10.22-3.97l3.22 3.22h-2.69a.75.75 0 0 0 0 1.5h4.5a.747.747 0 0 0 .75-.75v-4.5a.75.75 0 0 0-1.5 0v2.69l-3.22-3.22a.75.75 0 1 0-1.06 1.06ZM3.5 4.56l3.22 3.22a.75.75 0 0 0 1.06-1.06L4.56 3.5h2.69a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0-.75.75v4.5a.75.75 0 0 0 1.5 0V4.56Z"></path></svg>', -}; - -export const backButton = { - name: "back-button", - index: 10, - position: "top", - html: "<div class='parent-player-title'><div></div><div className='flex gap-2'><p className='pt-1'><ChevronLeftIcon className='w-7 h-7'/></p><div class='flex flex-col text-white'><p className='font-outfit font-bold text-2xl'>Komi-san wa, Komyushou desu.</p><p className=''>Episode 1</p></div></div></div>", - // tooltip: "Your Button", - click: function (...args) { - console.info("click", args); - }, - mounted: function (...args) { - console.info("mounted", args); - }, -}; - -export const seekBackward = { - index: 10, - name: "fast-rewind", - position: "left", - html: '<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20"><path fill="currentColor" d="M17.959 4.571L10.756 9.52s-.279.201-.279.481s.279.479.279.479l7.203 4.951c.572.38 1.041.099 1.041-.626V5.196c0-.727-.469-1.008-1.041-.625zm-9.076 0L1.68 9.52s-.279.201-.279.481s.279.479.279.479l7.203 4.951c.572.381 1.041.1 1.041-.625v-9.61c0-.727-.469-1.008-1.041-.625z"></path></svg>', - tooltip: "Backward 5s", - click: function () { - art.backward = 5; - }, -}; - -export const seekForward = { - index: 11, - name: "fast-forward", - position: "left", - html: '<svg xmlns="http://www.w3.org/2000/svg" width="20px" height="20px" viewBox="0 0 20 20"><path fill="currentColor" d="M9.244 9.52L2.041 4.571C1.469 4.188 1 4.469 1 5.196v9.609c0 .725.469 1.006 1.041.625l7.203-4.951s.279-.199.279-.478c0-.28-.279-.481-.279-.481zm9.356.481c0 .279-.279.478-.279.478l-7.203 4.951c-.572.381-1.041.1-1.041-.625V5.196c0-.727.469-1.008 1.041-.625L18.32 9.52s.28.201.28.481z"></path></svg>', - tooltip: "Forward 5s", - click: function () { - art.forward = 5; - }, -}; - -// /** -// * @type {import("artplayer/types/component").ComponentOption} -// */ -// export const diff --git a/components/watch/player/playerComponent.js b/components/watch/player/playerComponent.js deleted file mode 100644 index 665919b..0000000 --- a/components/watch/player/playerComponent.js +++ /dev/null @@ -1,527 +0,0 @@ -import React, { useEffect, useState } from "react"; -import NewPlayer from "./artplayer"; -import { icons } from "./component/overlay"; -import { useWatchProvider } from "@/lib/context/watchPageProvider"; -import { useRouter } from "next/router"; -import { useAniList } from "@/lib/anilist/useAnilist"; -import Loading from "@/components/shared/loading"; - -export function calculateAspectRatio(width, height) { - const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b)); - const divisor = gcd(width, height); - const aspectRatio = `${width / divisor}/${height / divisor}`; - return aspectRatio; -} - -const fontSize = [ - { - html: "Small", - size: "16px", - }, - { - html: "Medium", - size: "36px", - }, - { - html: "Large", - size: "56px", - }, -]; - -export default function PlayerComponent({ - playerRef, - session, - id, - info, - watchId, - proxy, - dub, - timeWatched, - skip, - track, - data, - provider, - className, -}) { - const { - aspectRatio, - setAspectRatio, - playerState, - setPlayerState, - autoplay, - marked, - setMarked, - } = useWatchProvider(); - - const router = useRouter(); - - const { markProgress } = useAniList(session); - - const [url, setUrl] = useState(""); - const [resolution, setResolution] = useState("auto"); - const [source, setSource] = useState([]); - const [subSize, setSubSize] = useState({ size: "16px", html: "Small" }); - const [defSize, setDefSize] = useState(); - const [subtitle, setSubtitle] = useState(); - const [defSub, setDefSub] = useState(); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - - useEffect(() => { - setLoading(true); - const resol = localStorage.getItem("quality"); - const sub = JSON.parse(localStorage.getItem("subSize")); - if (resol) { - setResolution(resol); - } - - const size = fontSize.map((i) => { - const isDefault = !sub ? i.html === "Small" : i.html === sub?.html; - return { - ...(isDefault && { default: true }), - html: i.html, - size: i.size, - }; - }); - - const defSize = size?.find((i) => i?.default === true); - setDefSize(defSize); - setSubSize(size); - - async function compiler() { - try { - const referer = JSON.stringify(data?.headers); - const source = data?.sources?.map((items) => { - const isDefault = - provider !== "gogoanime" - ? items.quality === "default" || items.quality === "auto" - : resolution === "auto" - ? items.quality === "default" || items.quality === "auto" - : items.quality === resolution; - return { - ...(isDefault && { default: true }), - html: items.quality === "default" ? "main" : items.quality, - url: `${proxy}/proxy/m3u8/${encodeURIComponent( - String(items.url) - )}/${encodeURIComponent(String(referer))}`, - }; - }); - - const defSource = source?.find((i) => i?.default === true); - - if (defSource) { - setUrl(defSource.url); - } - - const subtitle = data?.subtitles - ?.filter( - (subtitle) => - subtitle.lang !== "Thumbnails" && subtitle.lang !== "thumbnails" - ) - ?.map((subtitle) => { - const isEnglish = - subtitle.lang === "English" || - subtitle.lang === "English / English (US)"; - return { - ...(isEnglish && { default: true }), - url: subtitle.url, - html: `${subtitle.lang}`, - }; - }); - - if (subtitle) { - const defSub = data?.subtitles.find( - (i) => i.lang === "English" || i.lang === "English / English (US)" - ); - - setDefSub(defSub?.url); - - setSubtitle(subtitle); - } - - const alt = source?.filter( - (i) => - i?.html !== "main" && - i?.html !== "auto" && - i?.html !== "default" && - i?.html !== "backup" - ); - const server = source?.filter( - (i) => - i?.html === "main" || - i?.html === "auto" || - i?.html === "default" || - i?.html === "backup" - ); - - setSource({ alt, server }); - setLoading(false); - } catch (error) { - console.error(error); - } - } - compiler(); - - return () => { - setUrl(""); - setSource([]); - setSubtitle([]); - setLoading(true); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [provider, data]); - - /** - * @param {import("artplayer")} art - */ - function getInstance(art) { - art.on("ready", () => { - const autoplay = localStorage.getItem("autoplay_video") || false; - - // check media queries for mobile devices - const isMobile = window.matchMedia("(max-width: 768px)").matches; - - // console.log(art.fullscreen); - - if (isMobile) { - art.controls.remove("theater-button"); - // art.controls.remove("fast-rewind"); - // art.controls.remove("fast-forward"); - } - - if (autoplay === "true" || autoplay === true) { - if (playerState.currentTime === 0) { - art.play(); - } else { - if (playerState.isPlaying) { - art.play(); - } else { - art.pause(); - } - } - } else { - if (playerState.isPlaying) { - art.play(); - } else { - art.pause(); - } - } - art.seek = playerState.currentTime; - }); - - art.on("ready", () => { - if (playerState.currentTime !== 0) return; - const seek = art.storage.get(id); - const seekTime = seek?.timeWatched || 0; - const duration = art.duration; - const percentage = seekTime / duration; - const percentagedb = timeWatched / duration; - - if (subSize) { - art.subtitle.style.fontSize = subSize?.size; - } - - if (percentage >= 0.9 || percentagedb >= 0.9) { - art.currentTime = 0; - console.log("Video started from the beginning"); - } else if (timeWatched) { - art.currentTime = timeWatched; - } else { - art.currentTime = seekTime; - } - }); - - art.on("error", (error, reconnectTime) => { - if (error && reconnectTime >= 5) { - setError(true); - console.error("Error while loading video:", error); - } - }); - - art.on("play", () => { - art.notice.show = ""; - setPlayerState({ ...playerState, isPlaying: true }); - }); - art.on("pause", () => { - art.notice.show = ""; - setPlayerState({ ...playerState, isPlaying: false }); - }); - - art.on("resize", () => { - art.subtitle.style({ - fontSize: art.height * 0.05 + "px", - }); - }); - - let mark = 0; - - art.on("video:timeupdate", async () => { - if (!session) return; - - var currentTime = art.currentTime; - const duration = art.duration; - const percentage = currentTime / duration; - - if (percentage >= 0.9) { - // use >= instead of > - if (mark < 1 && marked < 1) { - mark = 1; - setMarked(1); - markProgress(info.id, track.playing.number); - } - } - }); - - art.on("video:playing", () => { - if (!session) return; - const intervalId = setInterval(async () => { - await fetch("/api/user/update/episode", { - method: "PUT", - body: JSON.stringify({ - name: session?.user?.name, - id: String(info?.id), - watchId: watchId, - title: - track.playing?.title || info.title?.romaji || info.title?.english, - aniTitle: info.title?.romaji || info.title?.english, - image: track.playing?.img || info?.coverImage?.extraLarge, - number: Number(track.playing?.number), - duration: art.duration, - timeWatched: art.currentTime, - provider: provider, - nextId: track.next?.id, - nextNumber: Number(track.next?.number), - dub: dub ? true : false, - }), - }); - // console.log("updating db", { track }); - }, 5000); - - art.on("video:pause", () => { - clearInterval(intervalId); - }); - - art.on("video:ended", () => { - clearInterval(intervalId); - }); - - art.on("destroy", () => { - clearInterval(intervalId); - // console.log("clearing interval"); - }); - }); - - art.on("video:playing", () => { - const interval = setInterval(async () => { - art.storage.set(watchId, { - aniId: String(info.id), - watchId: watchId, - title: - track.playing?.title || info.title?.romaji || info.title?.english, - aniTitle: info.title?.romaji || info.title?.english, - image: track?.playing?.img || info?.coverImage?.extraLarge, - episode: Number(track.playing?.number), - duration: art.duration, - timeWatched: art.currentTime, - provider: provider, - nextId: track?.next?.id, - nextNumber: track?.next?.number, - dub: dub ? true : false, - createdAt: new Date().toISOString(), - }); - }, 5000); - - art.on("video:pause", () => { - clearInterval(interval); - }); - - art.on("video:ended", () => { - clearInterval(interval); - }); - - art.on("destroy", () => { - clearInterval(interval); - }); - }); - - art.on("video:loadedmetadata", () => { - // get raw video width and height - // console.log(art.video.videoWidth, art.video.videoHeight); - const aspect = calculateAspectRatio( - art.video.videoWidth, - art.video.videoHeight - ); - - setAspectRatio(aspect); - }); - - art.on("video:timeupdate", () => { - var currentTime = art.currentTime; - // console.log(art.currentTime); - - if ( - skip?.op && - currentTime >= skip.op.interval.startTime && - currentTime <= skip.op.interval.endTime - ) { - // Add the layer if it's not already added - if (!art.controls["op"]) { - // Remove the other control if it's already added - if (art.controls["ed"]) { - art.controls.remove("ed"); - } - - // Add the control - art.controls.add({ - name: "op", - position: "top", - html: '<button class="skip-button">Skip Opening</button>', - click: function (...args) { - art.seek = skip.op.interval.endTime; - }, - }); - } - } else if ( - skip?.ed && - currentTime >= skip.ed.interval.startTime && - currentTime <= skip.ed.interval.endTime - ) { - // Add the layer if it's not already added - if (!art.controls["ed"]) { - // Remove the other control if it's already added - if (art.controls["op"]) { - art.controls.remove("op"); - } - - // Add the control - art.controls.add({ - name: "ed", - position: "top", - html: '<button class="skip-button">Skip Ending</button>', - click: function (...args) { - art.seek = skip.ed.interval.endTime; - }, - }); - } - } else { - // Remove the controls if they're added - if (art.controls["op"]) { - art.controls.remove("op"); - } - if (art.controls["ed"]) { - art.controls.remove("ed"); - } - } - }); - - art.on("video:ended", () => { - if (!track?.next) return; - if (localStorage.getItem("autoplay") === "true") { - art.controls.add({ - name: "next-button", - position: "top", - html: '<div class="vid-con"><button class="next-button progress">Play Next</button></div>', - click: function (...args) { - if (track?.next) { - router.push( - `/en/anime/watch/${ - info?.id - }/${provider}?id=${encodeURIComponent(track?.next?.id)}&num=${ - track?.next?.number - }${dub ? `&dub=${dub}` : ""}` - ); - } - }, - }); - - const button = document.querySelector(".next-button"); - - function stopTimeout() { - clearTimeout(timeoutId); - button.classList.remove("progress"); - } - - let timeoutId = setTimeout(() => { - art.controls.remove("next-button"); - if (track?.next) { - router.push( - `/en/anime/watch/${info?.id}/${provider}?id=${encodeURIComponent( - track?.next?.id - )}&num=${track?.next?.number}${dub ? `&dub=${dub}` : ""}` - ); - } - }, 7000); - - button.addEventListener("mouseover", stopTimeout); - } - }); - } - - /** - * @type {import("artplayer/types/option").Option} - */ - const option = { - url: url, - autoplay: autoplay ? true : false, - autoSize: false, - playbackRate: true, - fullscreen: true, - autoOrientation: true, - icons: icons, - setting: true, - screenshot: true, - hotkey: true, - pip: true, - airplay: true, - lock: true, - }; - - return ( - <div - id={id} - className={`${className} bg-black`} - style={{ aspectRatio: aspectRatio }} - > - <div className="flex-center w-full h-full"> - {!data?.error && !url && ( - <div className="flex-center w-full h-full"> - <Loading /> - </div> - )} - {!error ? ( - !loading && track && url && !data?.error ? ( - <NewPlayer - playerRef={playerRef} - res={resolution} - quality={source} - option={option} - provider={provider} - track={track} - defSize={defSize} - defSub={defSub} - subSize={subSize} - subtitles={subtitle} - getInstance={getInstance} - style={{ - width: "100%", - height: "100%", - }} - /> - ) : ( - <p className="text-center"> - {data?.status === 404 && "Not Found"} - <br /> - {data?.error} - </p> - ) - ) : ( - <p className="text-center"> - Something went wrong while loading the video, <br /> - please try from other source - </p> - )} - </div> - </div> - ); -} diff --git a/components/watch/primary/details.js b/components/watch/primary/details.tsx index 4af12ac..f20f8cf 100644 --- a/components/watch/primary/details.js +++ b/components/watch/primary/details.tsx @@ -2,7 +2,20 @@ import { useEffect, useState } from "react"; import { useAniList } from "../../../lib/anilist/useAnilist"; import Skeleton from "react-loading-skeleton"; import DisqusComments from "../../disqus"; -import Image from "next/image"; +import { AniListInfoTypes } from "types/info/AnilistInfoTypes"; +import { SessionTypes } from "pages/en"; + +type DetailsProps = { + info: AniListInfoTypes; + session: SessionTypes; + epiNumber: number; + description: string; + id: string; + onList: boolean; + setOnList: (value: boolean) => void; + handleOpen: () => void; + disqus: string; +}; export default function Details({ info, @@ -14,10 +27,14 @@ export default function Details({ setOnList, handleOpen, disqus, -}) { +}: DetailsProps) { const [showComments, setShowComments] = useState(false); const { markPlanning } = useAniList(session); + const [showDesc, setShowDesc] = useState(false); + + const truncatedDesc = truncateText(description, 420); + function handlePlan() { if (onList === false) { markPlanning(info.id); @@ -32,6 +49,10 @@ export default function Details({ } else { setShowComments(true); } + return () => { + setShowComments(false); + setShowDesc(false); + }; }, [id]); return ( @@ -133,12 +154,28 @@ export default function Details({ ))} </div> {/* <div className={`bg-secondary rounded-md mt-3 mx-3`}> */} - <div className={`bg-secondary rounded-md mt-3`}> + <div className={`relative bg-secondary rounded-md mt-3`}> {info && ( - <p - dangerouslySetInnerHTML={{ __html: description }} - className={`p-5 text-sm font-light font-roboto text-[#e4e4e4] `} - /> + <> + <p + dangerouslySetInnerHTML={{ + __html: showDesc + ? description + : description?.length > 420 + ? truncatedDesc + : description, + }} + className={`p-5 text-sm font-light font-roboto text-[#e4e4e4] `} + /> + {!showDesc && description?.length > 120 && ( + <span + onClick={() => setShowDesc((prev) => !prev)} + className="flex justify-center items-end rounded-md pb-5 font-semibold font-karla cursor-pointer w-full h-full bg-gradient-to-t from-secondary hover:from-20% to-transparent absolute inset-0" + > + Read More + </span> + )} + </> )} </div> {/* {<div className="mt-5 px-5"></div>} */} @@ -177,7 +214,6 @@ export default function Details({ <DisqusComments key={id} post={{ - id: id, title: info.title.romaji, url: window.location.href, episode: epiNumber, @@ -191,3 +227,8 @@ export default function Details({ </div> ); } + +function truncateText(txt: string, length: number) { + const text = txt.replace(/(<([^>]+)>)/gi, ""); + return text.length > length ? text.slice(0, length) + "..." : text; +} diff --git a/components/watch/secondary/episodeLists.js b/components/watch/secondary/episodeLists.tsx index a676be0..2c23f25 100644 --- a/components/watch/secondary/episodeLists.js +++ b/components/watch/secondary/episodeLists.tsx @@ -3,6 +3,19 @@ import Image from "next/image"; import Link from "next/link"; import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { useRouter } from "next/router"; +import { AniListInfoTypes } from "types/info/AnilistInfoTypes"; +import { Episode } from "types/api/Episode"; + +type EpisodeListsProps = { + info: AniListInfoTypes; + map: any; + providerId: string; + watchId: string; + episode: Episode[]; + artStorage: any; + track: any; + dub: string; +}; export default function EpisodeLists({ info, @@ -13,7 +26,7 @@ export default function EpisodeLists({ artStorage, track, dub, -}) { +}: EpisodeListsProps) { const progress = info.mediaListEntry?.progress; const router = useRouter(); @@ -45,8 +58,8 @@ export default function EpisodeLists({ router.push( `/en/anime/watch/${info.id}/${providerId}?id=${ - selectedEpisode.id - }&num=${selectedEpisode.number}${dub ? `&dub=${dub}` : ""}` + selectedEpisode?.id + }&num=${selectedEpisode?.number}${dub ? `&dub=${dub}` : ""}` ); }} className="flex items-center text-sm gap-5 rounded-[3px] bg-secondary py-1 px-3 pr-8 font-karla appearance-none cursor-pointer outline-none focus:ring-1 focus:ring-action group-hover:ring-1 group-hover:ring-action" @@ -64,7 +77,7 @@ export default function EpisodeLists({ <div className="flex flex-col gap-5 lg:pl-5 py-2 scrollbar-thin px-2 scrollbar-thumb-[#313131] scrollbar-thumb-rounded-full"> {episode && episode.length > 0 ? ( map?.some( - (item) => + (item: any) => (item?.img || item?.image) && !item?.img?.includes("https://s4.anilist.co/") ) > 0 ? ( @@ -74,7 +87,14 @@ export default function EpisodeLists({ let prog = (time / duration) * 100; if (prog > 90) prog = 100; - const mapData = map?.find((i) => i.number === item.number); + const mapData = map?.find((i: any) => i.number === item.number); + + const parsedImage = mapData + ? mapData?.img?.includes("null") || + mapData?.image?.includes("null") + ? info.coverImage?.extraLarge + : mapData?.img || mapData?.image + : info.coverImage?.extraLarge || null; return ( <Link href={`/en/anime/watch/${ @@ -93,11 +113,7 @@ export default function EpisodeLists({ <div className="relative"> {/* <div className="absolute inset-0 w-full h-full z-40" /> */} <Image - src={ - mapData?.img || - mapData?.image || - info?.coverImage?.extraLarge - } + src={parsedImage || info?.coverImage?.extraLarge} draggable={false} alt="Anime Cover" width={1000} diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index babd576..0000000 --- a/jsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@/components/*": ["components/*"], - "@/utils/*": ["utils/*"], - "@/lib/*": ["lib/*"], - "@/prisma/*": ["prisma/*"] - } - } -} diff --git a/lib/anify/getMangaId.js b/lib/anify/getMangaId.ts index 6b1445f..bf7bb71 100644 --- a/lib/anify/getMangaId.js +++ b/lib/anify/getMangaId.ts @@ -1,8 +1,25 @@ -import axios from "axios"; +import axios, { AxiosResponse } from "axios"; -export async function fetchInfo(romaji, english, native) { +interface Manga { + id: string; + title: { + romaji: string; + english: string; + native: string; + }; +} + +interface SearchResult { + results: Manga[]; +} + +export async function fetchInfo( + romaji: string, + english: string, + native: string +): Promise<{ id: string } | null> { try { - const { data: getManga } = await axios.get( + const { data: getManga }: AxiosResponse<SearchResult> = await axios.get( `https://api.anify.tv/search-advanced?query=${ english || romaji }&type=manga` @@ -26,10 +43,14 @@ export async function fetchInfo(romaji, english, native) { } } -export default async function getMangaId(romaji, english, native) { +export default async function getMangaId( + romaji: string, + english: string, + native: string +): Promise<{ id: string } | { message: string } | { error: any }> { try { const data = await fetchInfo(romaji, english, native); - if (data) { + if (data && "id" in data) { return data; } else { return { message: "Schedule not found" }; diff --git a/lib/anify/info.js b/lib/anify/info.js index 05159ce..8284e94 100644 --- a/lib/anify/info.js +++ b/lib/anify/info.js @@ -1,7 +1,6 @@ import axios from "axios"; -import { redis } from "../redis"; -export async function fetchInfo(id, key) { +export async function fetchInfo(id) { try { const { data } = await axios.get(`https://api.anify.tv/info/${id}`); return data; @@ -11,24 +10,13 @@ export async function fetchInfo(id, key) { } } -export default async function getAnifyInfo(id, key) { +export default async function getAnifyInfo(id) { try { - let cached; - if (redis) { - cached = await redis.get(id); - } - if (cached) { - return JSON.parse(cached); + const data = await fetchInfo(id); + if (data) { + return data; } else { - const data = await fetchInfo(id, key); - if (data) { - if (redis) { - await redis.set(id, JSON.stringify(data), "EX", 60 * 10); - } - return data; - } else { - return { message: "Schedule not found" }; - } + return { message: "Anify Info Not Found!" }; } } catch (error) { return { error }; diff --git a/lib/anilist/aniAdvanceSearch.js b/lib/anilist/aniAdvanceSearch.ts index ccfbd27..5251815 100644 --- a/lib/anilist/aniAdvanceSearch.js +++ b/lib/anilist/aniAdvanceSearch.ts @@ -1,8 +1,32 @@ +import { AnifySearchAdvanceTypes } from "types/info/AnifySearchAdvanceTypes"; import { advanceSearchQuery } from "../graphql/query"; +export type AniAdvanceSearch = { + search?: string; + type?: string; + genres?: any[]; + page?: number; + sort?: string; + format?: + | "TV" + | "TV_SHORT" + | "MOVIE" + | "SPECIAL" + | "OVA" + | "ONA" + | "MUSIC" + | "MANGA" + | "NOVEL" + | "ONE_SHOT" + | undefined; + season?: string; + seasonYear?: number; + perPage?: number; +}; + export async function aniAdvanceSearch({ search, - type, + type = "ANIME", genres, page, sort, @@ -10,7 +34,7 @@ export async function aniAdvanceSearch({ season, seasonYear, perPage, -}) { +}: AniAdvanceSearch) { const categorizedGenres = genres?.reduce((result, item) => { const existingEntry = result[item.type]; @@ -43,10 +67,10 @@ export async function aniAdvanceSearch({ }), }); - const data = await response.json(); + const data: AnifySearchAdvanceTypes = await response.json(); return { pageInfo: { - hasNextPage: page < data.total, + hasNextPage: page ?? 0 < data.total, currentPage: page, lastPage: Math.ceil(data.lastPage), perPage: perPage ?? 20, @@ -62,7 +86,7 @@ export async function aniAdvanceSearch({ large: item.coverImage, }, description: item.description, - duration: item.duration ?? null, + duration: item?.duration ?? null, endDate: { day: null, month: null, diff --git a/lib/anilist/getUpcomingAnime.js b/lib/anilist/getUpcomingAnime.js index 2ab9315..d5249f1 100644 --- a/lib/anilist/getUpcomingAnime.js +++ b/lib/anilist/getUpcomingAnime.js @@ -59,7 +59,7 @@ const getUpcomingAnime = async () => { `; const variables = { - season: "FALL", + season: currentSeason, year: currentYear, format: "TV", }; diff --git a/lib/anilist/useAnilist.js b/lib/anilist/useAnilist.js index 20c1964..323dd29 100644 --- a/lib/anilist/useAnilist.js +++ b/lib/anilist/useAnilist.js @@ -225,6 +225,9 @@ export const useAniList = (session) => { // if (lists.length > 0) { await fetchGraphQL(progressWatched, variables); console.log(`Progress Updated: ${progress}`, status); + toast.success(`Progress Updated: ${progress}`, { + position: "bottom-right", + }); // } } else if (media && media.type === "MANGA") { let variables = { diff --git a/lib/context/watchPageProvider.js b/lib/context/watchPageProvider.js index a9d707b..c305710 100644 --- a/lib/context/watchPageProvider.js +++ b/lib/context/watchPageProvider.js @@ -9,10 +9,14 @@ export const WatchPageProvider = ({ children }) => { currentTime: 0, isPlaying: false, }); - const [autoplay, setAutoPlay] = useState(false); + const [autoplay, setAutoPlay] = useState(null); + const [autoNext, setAutoNext] = useState(null); const [marked, setMarked] = useState(0); const [userData, setUserData] = useState(null); + const [dataMedia, setDataMedia] = useState(null); + + const [track, setTrack] = useState(null); return ( <WatchPageContext.Provider @@ -29,6 +33,12 @@ export const WatchPageProvider = ({ children }) => { setAutoPlay, marked, setMarked, + track, + setTrack, + dataMedia, + setDataMedia, + autoNext, + setAutoNext, }} > {children} diff --git a/utils/useCountdownSeconds.js b/lib/hooks/useCountdownSeconds.ts index df3cb63..3d17ede 100644 --- a/utils/useCountdownSeconds.js +++ b/lib/hooks/useCountdownSeconds.ts @@ -1,6 +1,19 @@ import { useEffect, useState } from "react"; -const useCountdown = (targetDate, update) => { +interface CountdownValues { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +interface Props { + targetDate: number; + update: Function; + countdown: CountdownValues; +} + +const useCountdown = (targetDate: number, update: Function): Props => { const countDownDate = new Date(targetDate).getTime(); const [countDown, setCountDown] = useState( @@ -19,10 +32,14 @@ const useCountdown = (targetDate, update) => { return () => clearInterval(interval); }, [countDownDate, update]); - return getReturnValues(countDown); + return { + targetDate, + update, + countdown: getReturnValues(countDown), + }; }; -const getReturnValues = (countDown) => { +const getReturnValues = (countDown: number): CountdownValues => { // calculate time left const days = Math.floor(countDown / (1000 * 60 * 60 * 24)); const hours = Math.floor( @@ -31,7 +48,7 @@ const getReturnValues = (countDown) => { const minutes = Math.floor((countDown % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((countDown % (1000 * 60)) / 1000); - return [days, hours, minutes, seconds]; + return { days, hours, minutes, seconds }; }; export { useCountdown }; diff --git a/lib/hooks/useWatchStorage.tsx b/lib/hooks/useWatchStorage.tsx new file mode 100644 index 0000000..ee24a39 --- /dev/null +++ b/lib/hooks/useWatchStorage.tsx @@ -0,0 +1,28 @@ +import { UserData } from "@/components/watch/new-player/player"; +import { useState } from "react"; + +function useWatchStorage() { + // Get initial value from local storage or empty object + const [settings, setSettings] = useState(() => { + const storedSettings = localStorage?.getItem("artplayer_settings"); + return storedSettings ? JSON.parse(storedSettings) : {}; + }); + + const getSettings = (id: string): UserData | undefined => { + return settings[id]; + }; + + // Function to update settings + const updateSettings = (id: string, data?: any) => { + // Update state + const updatedSettings = { ...settings, [id]: data }; + setSettings(updatedSettings); + + // Update local storage + localStorage.setItem("artplayer_settings", JSON.stringify(updatedSettings)); + }; + + return [getSettings, updateSettings]; +} + +export default useWatchStorage; diff --git a/lib/prisma.js b/lib/prisma.ts index ed8c421..55acf8d 100644 --- a/lib/prisma.js +++ b/lib/prisma.ts @@ -1,5 +1,9 @@ import { PrismaClient } from "@prisma/client"; +declare global { + var prisma: PrismaClient | undefined; +} + export const prisma = global.prisma || new PrismaClient(); if (process.env.NODE_ENV !== "production") global.prisma = prisma; diff --git a/lib/redis.js b/lib/redis.ts index 9522e4c..1778933 100644 --- a/lib/redis.js +++ b/lib/redis.ts @@ -1,16 +1,16 @@ import { Redis } from "ioredis"; import { RateLimiterRedis } from "rate-limiter-flexible"; -const REDIS_URL = process.env.REDIS_URL; +const REDIS_URL: string | undefined = process.env.REDIS_URL; -let redis; -let rateLimiterRedis; -let rateLimitStrict; -let rateSuperStrict; +let redis: Redis; +let rateLimiterRedis: RateLimiterRedis; +let rateLimitStrict: RateLimiterRedis; +let rateSuperStrict: RateLimiterRedis; if (REDIS_URL) { redis = new Redis(REDIS_URL); - redis.on("error", (err) => { + redis.on("error", (err: Error) => { console.error("Redis error: ", err); }); diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// <reference types="next" /> +/// <reference types="next/image-types/global" /> + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js index d3fd882..7920ac0 100644 --- a/next.config.js +++ b/next.config.js @@ -9,8 +9,13 @@ const withPWA = require("next-pwa")({ }); module.exports = withPWA({ - reactStrictMode: false, + reactStrictMode: true, + webpack(config, options) { + config.resolve.extensions.push(".ts", ".tsx"); + return config; + }, images: { + unoptimized: true, remotePatterns: [ { protocol: "https", @@ -28,6 +33,10 @@ module.exports = withPWA({ protocol: "https", hostname: "tenor.com", }, + { + protocol: "https", + hostname: "meionovel.id", + }, ], }, // distDir: process.env.BUILD_DIR || ".next", @@ -41,6 +50,24 @@ module.exports = withPWA({ permanent: false, basePath: false, }, + { + source: "/changelogs", + destination: "https://github.com/Ani-Moopa/Moopa/releases", + permanent: false, + basePath: false, + }, + { + source: "/github", + destination: "https://github.com/Ani-Moopa/Moopa", + permanent: false, + basePath: false, + }, + { + source: "/discord", + destination: "https://discord.gg/v5fjSdKwr2", + permanent: false, + basePath: false, + }, ]; }, // async headers() { diff --git a/package-lock.json b/package-lock.json index 5183bfa..613a188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,28 @@ { "name": "moopa", - "version": "4.3.0", + "version": "4.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moopa", - "version": "4.3.0", + "version": "4.3.1", "dependencies": { - "@apollo/client": "^3.7.3", "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.17", "@prisma/client": "^5.3.1", "@vercel/og": "^0.5.4", - "artplayer": "^5.0.9", - "artplayer-plugin-hls-quality": "^2.0.0", - "axios": "^1.6.0", - "closest-match": "^1.3.3", + "@vidstack/react": "^1.8.3", + "axios": "^1.4.0", + "cookies": "^0.8.0", "cron": "^2.4.0", "disqus-react": "^1.1.5", "framer-motion": "^8.5.0", "graphql": "^15.8.0", - "hls.js": "^1.3.2", + "hls.js": "^1.4.12", "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "media-icons": "^1.0.0", "next": "^13.5.5", "next-auth": "^4.24.5", "next-pwa": "^5.6.0", @@ -39,13 +39,19 @@ "workbox-webpack-plugin": "^7.0.0" }, "devDependencies": { + "@types/cookies": "^0.7.10", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.8.10", + "@types/react": "^18.2.33", "autoprefixer": "^10.4.14", "depcheck": "^1.4.3", "eslint": "^8.38.0", "eslint-config-next": "^13.5.2", "prisma": "^5.3.1", "tailwind-scrollbar": "^2.1.0", - "tailwindcss": "^3.3.1" + "tailwindcss": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.2.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -81,47 +87,6 @@ "node": ">=6.0.0" } }, - "node_modules/@apollo/client": { - "version": "3.7.17", - "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.7.17.tgz", - "integrity": "sha512-0EErSHEtKPNl5wgWikHJbKFAzJ/k11O0WO2QyqZSHpdxdAnw7UWHY4YiLbHCFG7lhrD+NTQ3Z/H9Jn4rcikoJA==", - "dependencies": { - "@graphql-typed-document-node/core": "^3.1.1", - "@wry/context": "^0.7.0", - "@wry/equality": "^0.5.0", - "@wry/trie": "^0.4.0", - "graphql-tag": "^2.12.6", - "hoist-non-react-statics": "^3.3.2", - "optimism": "^0.16.2", - "prop-types": "^15.7.2", - "response-iterator": "^0.2.6", - "symbol-observable": "^4.0.0", - "ts-invariant": "^0.10.3", - "tslib": "^2.3.0", - "zen-observable-ts": "^1.2.5" - }, - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", - "graphql-ws": "^5.5.5", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", - "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" - }, - "peerDependenciesMeta": { - "graphql-ws": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "subscriptions-transport-ws": { - "optional": true - } - } - }, "node_modules/@babel/code-frame": { "version": "7.22.13", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", @@ -1844,14 +1809,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@headlessui/react": { "version": "1.7.16", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.16.tgz", @@ -2392,6 +2349,37 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookies": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.7.10.tgz", + "integrity": "sha512-hmUCjAk2fwZVPPkkPBcI7jGLIR5mg4OVoNMBwU6aVsMm/iNPY7z9/R+x2fSwLt/ZXoGua6C5Zy2k5xOo9jUyhQ==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "8.44.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.2.tgz", @@ -2418,6 +2406,30 @@ "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", "peer": true }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.41", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", + "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz", @@ -2427,6 +2439,12 @@ "@types/node": "*" } }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", @@ -2438,15 +2456,39 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==" }, "node_modules/@types/node": { - "version": "20.4.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.7.tgz", - "integrity": "sha512-bUBrPjEry2QUTsnuEjzjbS7voGWCc30W0qzgMf90GPeDGFRakvrz47ju+oqDAKCXLUCe39u57/ORMl/O/04/9g==" + "version": "20.8.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.10.tgz", + "integrity": "sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==", + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/nprogress": { "version": "0.2.0", @@ -2459,6 +2501,33 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "node_modules/@types/prop-types": { + "version": "15.7.9", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.9.tgz", + "integrity": "sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==" + }, + "node_modules/@types/qs": { + "version": "6.9.10", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz", + "integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.33", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.33.tgz", + "integrity": "sha512-v+I7S+hu3PIBoVkKGpSYYpiBT1ijqEzWpzQD62/jm4K74hPpSP7FF9BnKG6+fg2+62weJYkkBWDJlZt5JO/9hg==", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -2467,6 +2536,32 @@ "@types/node": "*" } }, + "node_modules/@types/scheduler": { + "version": "0.16.5", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.5.tgz", + "integrity": "sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", + "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", @@ -2586,6 +2681,21 @@ "node": ">=16" } }, + "node_modules/@vidstack/react": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@vidstack/react/-/react-1.8.3.tgz", + "integrity": "sha512-QCyHy6e3LpzfajtjrhJPXzGYbBrBCUE5qYAatKXX+nxWqRvspa0fJPlnGeWb+tg6DlDsgwDLFjGNWj8qUeUVXQ==", + "dependencies": { + "media-captions": "^1.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0" + } + }, "node_modules/@vue/compiler-core": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz", @@ -2837,39 +2947,6 @@ "@xtuc/long": "4.2.2" } }, - "node_modules/@wry/context": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.3.tgz", - "integrity": "sha512-Nl8WTesHp89RF803Se9X3IiHjdmLBrIvPMaJkl+rKVJAYyPsz1TEUbu89943HpvujtSJgDUx9W4vZw3K1Mr3sA==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@wry/equality": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.6.tgz", - "integrity": "sha512-D46sfMTngaYlrH+OspKf8mIJETntFnf6Hsjb0V41jAXJ7Bx2kB8Rv8RCUujuVWYttFtHkUNp7g+FwxNQAr6mXA==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@wry/trie": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", - "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -3149,19 +3226,6 @@ "node": ">=8" } }, - "node_modules/artplayer": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/artplayer/-/artplayer-5.0.9.tgz", - "integrity": "sha512-IM/DShYdmKFEA9jl08LYbTK2Jfz9s7qIjEH0xWjnxvVArUKZZKcoqwr6i54U0c4grtc/Uvb4wtCd78kvtSVlgw==", - "dependencies": { - "option-validator": "^2.0.6" - } - }, - "node_modules/artplayer-plugin-hls-quality": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/artplayer-plugin-hls-quality/-/artplayer-plugin-hls-quality-2.0.0.tgz", - "integrity": "sha512-+/tiLXi2BNOuw7z2ayI6cYlZBZEP/ujS01bTtanRi2P0zl8wHafPEk0bAA8VbXxpP9gYT0/DjBIifNR9W0xqhA==" - }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -3249,9 +3313,9 @@ } }, "node_modules/axios": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", - "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.1.tgz", + "integrity": "sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -3448,6 +3512,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3659,11 +3728,6 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/closest-match": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/closest-match/-/closest-match-1.3.3.tgz", - "integrity": "sha512-RSdHrZwNOvt2uMQgqJDJdM/I+5MlJ1tQJEXYrbRjSMXWiCRo06g2hwObJ7+WKt2J9ySK9/pJ0Q2vbL+BPkofDA==" - }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -3773,6 +3837,18 @@ "node": ">= 0.6" } }, + "node_modules/cookies": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", + "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/core-js-compat": { "version": "3.32.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.1.tgz", @@ -3871,6 +3947,11 @@ "node": ">=4" } }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -4073,6 +4154,14 @@ "node": ">=10" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/deps-regex": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deps-regex/-/deps-regex-0.1.4.tgz", @@ -4140,6 +4229,14 @@ "node": ">=6.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ejs": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", @@ -5392,20 +5489,6 @@ "node": ">= 10.x" } }, - "node_modules/graphql-tag": { - "version": "2.12.6", - "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", - "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" - } - }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -5497,17 +5580,9 @@ "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==" }, "node_modules/hls.js": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.4.10.tgz", - "integrity": "sha512-wAVSj4Fm2MqOHy5+BlYnlKxXvJlv5IuZHjlzHu18QmjRzSDFQiUDWdHs5+NsFMQrgKEBwuWDcyvaMC9dUzJ5Uw==" - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.4.12.tgz", + "integrity": "sha512-1RBpx2VihibzE3WE9kGoVCtrhhDWTzydzElk/kyRbEOLnb1WIE+3ZabM/L8BqKFTCL3pUy4QzhXgD1Q6Igr1JA==" }, "node_modules/idb": { "version": "7.1.1", @@ -6259,6 +6334,27 @@ "node": ">=0.10.0" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -6274,12 +6370,34 @@ "node": ">=4.0" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dependencies": { + "tsscmp": "1.0.6" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.6" } }, "node_modules/language-subtag-registry": { @@ -6394,17 +6512,52 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -6471,6 +6624,22 @@ "semver": "bin/semver.js" } }, + "node_modules/media-captions": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/media-captions/-/media-captions-1.0.1.tgz", + "integrity": "sha512-vicgtBYqNLvZStIPOpxHJxg/T7sVFVyi6A43PQLl5jMjblvRWhZ8V/LVBboeBxddSlPYnLWUQQI41Uv6V0tQRQ==", + "engines": { + "node": ">=16" + } + }, + "node_modules/media-icons": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/media-icons/-/media-icons-1.0.0.tgz", + "integrity": "sha512-NpGZOUqNLz5BhvGB1CkB/ejinnsiQQCjwNMrz4X6e9PO5E+am44jo75fnxCdXSVEp445B3U3gSbwYyGR9GQV2w==", + "engines": { + "node": ">=16" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7174,34 +7343,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, - "node_modules/optimism": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.16.2.tgz", - "integrity": "sha512-zWNbgWj+3vLEjZNIh/okkY2EUfX+vB9TJopzIZwT1xxaMqC5hRLLraePod4c5n4He08xuXNH+zhKFFCu390wiQ==", - "dependencies": { - "@wry/context": "^0.7.0", - "@wry/trie": "^0.3.0" - } - }, - "node_modules/optimism/node_modules/@wry/trie": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.3.2.tgz", - "integrity": "sha512-yRTyhWSls2OY/pYLfwff867r8ekooZ4UI+/gxot5Wj8EFwSf2rG+n+Mo/6LoLQm1TKA4GRj2+LCpbfS937dClQ==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/option-validator": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/option-validator/-/option-validator-2.0.6.tgz", - "integrity": "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==", - "dependencies": { - "kind-of": "^6.0.3" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -8064,14 +8205,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/response-iterator": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/response-iterator/-/response-iterator-0.2.6.tgz", - "integrity": "sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -8800,14 +8933,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/symbol-observable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "engines": { - "node": ">=0.10" - } - }, "node_modules/tailwind-scrollbar": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-2.1.0.tgz", @@ -8862,6 +8987,15 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dev": true, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/tailwindcss/node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -9099,17 +9233,6 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, - "node_modules/ts-invariant": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", - "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", @@ -9139,6 +9262,14 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.1.tgz", "integrity": "sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", @@ -9256,11 +9387,10 @@ } }, "node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9283,6 +9413,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", @@ -10062,19 +10197,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz", "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==" - }, - "node_modules/zen-observable": { - "version": "0.8.15", - "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", - "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" - }, - "node_modules/zen-observable-ts": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", - "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", - "dependencies": { - "zen-observable": "0.8.15" - } } } } diff --git a/package.json b/package.json index 5b2c345..3ea7c4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moopa", - "version": "4.3.0", + "version": "4.3.1", "private": true, "founder": "Factiven", "scripts": { @@ -8,24 +8,25 @@ "build": "next build", "export": "next build && next export", "start": "next start", + "type-check": "tsc", "lint": "next lint" }, "dependencies": { - "@apollo/client": "^3.7.3", "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.17", "@prisma/client": "^5.3.1", "@vercel/og": "^0.5.4", - "artplayer": "^5.0.9", - "artplayer-plugin-hls-quality": "^2.0.0", - "axios": "^1.6.0", - "closest-match": "^1.3.3", + "@vidstack/react": "^1.8.3", + "axios": "^1.4.0", + "cookies": "^0.8.0", "cron": "^2.4.0", "disqus-react": "^1.1.5", "framer-motion": "^8.5.0", "graphql": "^15.8.0", - "hls.js": "^1.3.2", + "hls.js": "^1.4.12", "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.2", + "media-icons": "^1.0.0", "next": "^13.5.5", "next-auth": "^4.24.5", "next-pwa": "^5.6.0", @@ -42,12 +43,18 @@ "workbox-webpack-plugin": "^7.0.0" }, "devDependencies": { + "@types/cookies": "^0.7.10", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.8.10", + "@types/react": "^18.2.33", "autoprefixer": "^10.4.14", "depcheck": "^1.4.3", "eslint": "^8.38.0", "eslint-config-next": "^13.5.2", "prisma": "^5.3.1", "tailwind-scrollbar": "^2.1.0", - "tailwindcss": "^3.3.1" + "tailwindcss": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.2.2" } } diff --git a/pages/404.js b/pages/404.tsx index 085d984..d8e38da 100644 --- a/pages/404.js +++ b/pages/404.tsx @@ -1,8 +1,7 @@ import Head from "next/head"; -import Link from "next/link"; import Image from "next/image"; import Footer from "@/components/shared/footer"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; import { useRouter } from "next/router"; import { ArrowLeftIcon } from "@heroicons/react/24/outline"; @@ -16,7 +15,7 @@ export default function Custom404() { <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" href="/svg/c.svg" /> </Head> - <NewNavbar withNav shrink /> + <Navbar withNav shrink /> <div className="min-h-screen w-screen flex flex-col items-center justify-center "> <Image width={500} diff --git a/pages/_app.js b/pages/_app.tsx index e2f780d..c6b72ca 100644 --- a/pages/_app.js +++ b/pages/_app.tsx @@ -1,25 +1,25 @@ +import "../styles/globals.css"; +import "react-loading-skeleton/dist/skeleton.css"; import { useRouter } from "next/router"; import { AnimatePresence, motion as m } from "framer-motion"; import NextNProgress from "nextjs-progressbar"; import { SessionProvider } from "next-auth/react"; -import "../styles/globals.css"; -import "react-loading-skeleton/dist/skeleton.css"; import { SkeletonTheme } from "react-loading-skeleton"; import SearchPalette from "@/components/searchPalette"; import { SearchProvider } from "@/lib/context/isOpenState"; -import Head from "next/head"; import { WatchPageProvider } from "@/lib/context/watchPageProvider"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { unixTimestampToRelativeTime } from "@/utils/getTimes"; -import SecretPage from "@/components/secret"; +// import SecretPage from "@/components/secret"; import { Toaster, toast } from "sonner"; +import ChangeLogs from "../components/shared/changelogs"; +import type { AppProps } from "next/app"; export default function App({ Component, pageProps: { session, ...pageProps }, -}) { +}: AppProps) { const router = useRouter(); - const [info, setInfo] = useState(null); useEffect(() => { async function getBroadcast() { @@ -33,30 +33,16 @@ export default function App({ }); const data = await res.json(); if (data?.show === true) { - toast.message( - `🚧${data.message} ${ + toast.message(`Update Notice!`, { + position: "bottom-right", + important: true, + duration: 100000, + className: "font-karla", + description: `${data.message} ${ data?.startAt ? unixTimestampToRelativeTime(data.startAt) : "" - }🚧`, - { - position: "bottom-right", - important: true, - duration: 100000, - className: "flex-center font-karla text-white", - // description: `🚧${info}🚧`, - } - ); - // toast.message(`Announcement`, { - // position: "top-center", - // important: true, - // // duration: 10000, - // description: `🚧${info}🚧`, - // }); + }`, + }); } - setInfo( - `${data.message} ${ - data?.startAt ? unixTimestampToRelativeTime(data.startAt) : "" - }` - ); } catch (err) { console.log(err); } @@ -70,33 +56,17 @@ export default function App({ return ( <> - <Head> - <meta - name="viewport" - content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, viewport-fit=cover" - /> - </Head> <SessionProvider session={session}> <SearchProvider> <WatchPageProvider> <AnimatePresence mode="wait"> <SkeletonTheme baseColor="#232329" highlightColor="#2a2a32"> <Toaster richColors theme="dark" closeButton /> - <SecretPage + {/* <SecretPage cheatCode={"aofienaef"} onCheatCodeEntered={handleCheatCodeEntered} - /> - {/* {info && ( - <div className="relative px-3 flex items-center justify-center font-karla w-full py-2 bg-secondary/80 text-white text-center"> - <span className="line-clamp-1 mr-5">🚧{info}🚧</span> - <span - onClick={() => setInfo()} - className="absolute right-3 cursor-pointer" - > - <XMarkIcon className="w-6 h-6" /> - </span> - </div> - )} */} + /> */} + <ChangeLogs /> <m.div key={`route-${router.route}`} transition={{ duration: 0.5 }} diff --git a/pages/_document.js b/pages/_document.tsx index e89e516..e89e516 100644 --- a/pages/_document.js +++ b/pages/_document.tsx diff --git a/pages/_error.js b/pages/_error.tsx index 19dfcff..35a9691 100644 --- a/pages/_error.js +++ b/pages/_error.tsx @@ -1,16 +1,16 @@ import MobileNav from "@/components/shared/MobileNav"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; import Footer from "@/components/shared/footer"; import Head from "next/head"; import Link from "next/link"; -function Error({ statusCode }) { +function Error({ statusCode }: any) { return ( <> <Head> <title>An Error Has Occurred</title> </Head> - <NewNavbar withNav shrink /> + <Navbar withNav shrink /> <MobileNav hideProfile /> <div className="w-screen h-screen flex-center flex-col gap-5"> <div className="relative text-3xl">(╯°□°)╯︵ ┻━┻</div> @@ -33,7 +33,7 @@ function Error({ statusCode }) { ); } -Error.getInitialProps = ({ res, err }) => { +Error.getInitialProps = ({ res, err }: any) => { const statusCode = res ? res.statusCode : err ? err.statusCode : 404; return { statusCode }; }; diff --git a/pages/_offline.js b/pages/_offline.tsx index f440b39..f440b39 100644 --- a/pages/_offline.js +++ b/pages/_offline.tsx diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].ts index da78d07..70b2e3d 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].ts @@ -1,29 +1,6 @@ -import NextAuth from "next-auth"; -import { ApolloClient, InMemoryCache, gql } from "@apollo/client"; +import NextAuth, { NextAuthOptions } from "next-auth"; -const defaultOptions = { - watchQuery: { - fetchPolicy: "no-cache", - errorPolicy: "ignore", - }, - query: { - fetchPolicy: "no-cache", - errorPolicy: "all", - }, -}; - -const client = new ApolloClient({ - uri: "https://graphql.anilist.co", - cache: new InMemoryCache(), - defaultOptions: defaultOptions, -}); - -// import clientPromise from "../../../lib/mongodb"; -// import { MongoDBAdapter } from "@next-auth/mongodb-adapter"; - -export const authOptions = { - // Configure one or more authentication providers - // adapter: MongoDBAdapter(clientPromise), +export const authOptions: NextAuthOptions = { secret: process.env.NEXTAUTH_SECRET, providers: [ { @@ -38,8 +15,17 @@ export const authOptions = { userinfo: { url: process.env.GRAPHQL_ENDPOINT, async request(context) { - const { data } = await client.query({ - query: gql` + // console.log(context.tokens.access_token); + const { data } = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + // ...(context.tokens.access_token && { + Authorization: `Bearer ${context.tokens.access_token}`, + // }), + }, + body: JSON.stringify({ + query: ` query { Viewer { id @@ -57,34 +43,33 @@ export const authOptions = { } } `, - context: { - headers: { - Authorization: "Bearer " + context.tokens.access_token, - }, - }, - }); + }), + }).then((res) => res.json()); - const userLists = data.Viewer.mediaListOptions.animeList.customLists; + const userLists = data.Viewer?.mediaListOptions.animeList.customLists; let custLists = userLists || []; if (!userLists?.includes("Watched using Moopa")) { custLists.push("Watched using Moopa"); - const fetchGraphQL = async (query, variables) => { + const fetchGraphQL = async ( + query: string, + variables: { lists: any } + ) => { const response = await fetch("https://graphql.anilist.co/", { method: "POST", headers: { "Content-Type": "application/json", - Authorization: context.tokens.access_token - ? `Bearer ${context.tokens.access_token}` - : undefined, + ...(context.tokens.access_token && { + Authorization: `Bearer ${context.tokens.access_token}`, + }), }, body: JSON.stringify({ query, variables }), }); return response.json(); }; - const customLists = async (lists) => { + const customLists = async (lists: any) => { const setList = ` mutation($lists: [String]){ UpdateUser(animeListOptions: { customLists: $lists }){ @@ -104,7 +89,7 @@ export const authOptions = { name: data.Viewer.name, sub: data.Viewer.id, image: data.Viewer.avatar, - list: data.Viewer.mediaListOptions.animeList.customLists, + list: data.Viewer?.mediaListOptions.animeList.customLists, }; }, }, diff --git a/pages/api/og.jsx b/pages/api/og.tsx index d52f90e..47619bc 100644 --- a/pages/api/og.jsx +++ b/pages/api/og.tsx @@ -11,7 +11,7 @@ const outfit = fetch( new URL("../../assets/Outfit-Regular.ttf", import.meta.url) ).then((res) => res.arrayBuffer()); -export default async function handler(request) { +export default async function handler(request: any) { const Karla = await karla; const Outfit = await outfit; diff --git a/pages/api/v2/episode/[id].js b/pages/api/v2/episode/[id].tsx index b601f62..b646126 100644 --- a/pages/api/v2/episode/[id].js +++ b/pages/api/v2/episode/[id].tsx @@ -1,17 +1,21 @@ +// @ts-nocheck + import axios from "axios"; import { rateLimiterRedis, rateSuperStrict, redis } from "@/lib/redis"; import appendMetaToEpisodes from "@/utils/appendMetaToEpisodes"; +import { NextApiRequest, NextApiResponse } from "next"; +import { AnifyEpisode, ConsumetInfo, EpisodeData } from "types"; +import { Episode } from "@/types/api/Episode"; +import { getProviderWithMostEpisodesAndImage } from "@/utils/parseMetaData"; -let CONSUMET_URI; +let CONSUMET_URI: string | null; CONSUMET_URI = process.env.API_URI || null; if (CONSUMET_URI && CONSUMET_URI.endsWith("/")) { CONSUMET_URI = CONSUMET_URI.slice(0, -1); } -const API_KEY = process.env.API_KEY; - -const isAscending = (data) => { +const isAscending = (data: Episode[]) => { for (let i = 1; i < data.length; i++) { if (data[i].number < data[i - 1].number) { return false; @@ -20,7 +24,16 @@ const isAscending = (data) => { return true; }; -function filterData(data, type) { +export interface RawEpisodeData { + map?: boolean; + providerId: string; + episodes: { + sub: Episode[]; + dub: Episode[]; + }; +} + +function filterData(data: RawEpisodeData[], type: "sub" | "dub") { // Filter the data based on the type (sub or dub) and providerId const filteredData = data.map((item) => { if (item?.map === true) { @@ -44,10 +57,10 @@ function filterData(data, type) { return noEmpty; } -async function fetchConsumet(id) { +async function fetchConsumet(id?: string | string[] | undefined) { try { - async function fetchData(dub) { - const { data } = await axios.get( + const fetchData = async (dub?: any) => { + const { data } = await axios.get<ConsumetInfo>( `${CONSUMET_URI}/meta/anilist/info/${id}${dub ? "?dub=true" : ""}` ); if (data?.message === "Anime not found" && data?.length < 1) { @@ -59,23 +72,32 @@ async function fetchConsumet(id) { } const reformatted = data.episodes?.map((item) => ({ - id: item?.id || null, + id: item.id, title: item?.title || null, img: item?.image || null, number: item?.number || null, - createdAt: item?.createdAt || null, + createdAt: item?.airDate || null, description: item?.description || null, - url: item?.url || null, })); return reformatted; - } + }; const [subData, dubData] = await Promise.all([ fetchData(), fetchData(true), ]); + if (subData.every((i) => i.id?.includes("dub"))) { + // replace dub in title with sub + subData.forEach((item) => { + if (item.id?.includes("dub")) { + item.id = item.id?.replace("dub", "anime"); + } + }); + console.log("replaced dub with sub"); + } + const array = [ { map: true, @@ -88,38 +110,34 @@ async function fetchConsumet(id) { ]; return array; - } catch (error) { + } catch (error: any) { console.error("Error fetching and processing data:", error.message); return []; } } -async function fetchAnify(id) { +async function fetchAnify(id?: string) { try { - const { data } = await axios.get(`https://api.anify.tv/episodes/${id}`); + const { data } = await axios.get<AnifyEpisode[]>( + `https://api.anify.tv/episodes/${id}` + ); if (!data) { return []; } - const filtered = data.filter((item) => item.providerId !== "kass"); - // const modifiedData = filtered.map((provider) => { - // if (provider.providerId === "gogoanime") { - // const reversedEpisodes = [...provider.episodes].reverse(); - // return { ...provider, episodes: reversedEpisodes }; - // } - // return provider; - // }); + const filtered = data.filter( + (item) => item.providerId !== "9anime" && item.providerId !== "kass" + ); - // return modifiedData; return filtered; - } catch (error) { + } catch (error: any) { console.error("Error fetching and processing data:", error.message); return []; } } -async function fetchCoverImage(id, available = false) { +async function fetchCoverImage(id: string, available = false) { try { if (!process.env.API_KEY) { return []; @@ -137,16 +155,20 @@ async function fetchCoverImage(id, available = false) { return []; } - const getData = data[0].data; + const getData = getProviderWithMostEpisodesAndImage(data); + // const getData = data?.[0]?.data; - return getData; - } catch (error) { + return getData.data; + } catch (error: any) { console.error("Error fetching and processing data:", error.message); return []; } } -export default async function handler(req, res) { +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { const { id, releasing = "false", dub = false, refresh = null } = req.query; // if releasing is true then cache for 1 hour, if it false cache for 1 month; @@ -159,11 +181,11 @@ export default async function handler(req, res) { let cached; let meta; - let headers; + let headers: any = {}; if (redis) { try { - const ipAddress = req.socket.remoteAddress; + const ipAddress: any = req.socket.remoteAddress; refresh ? await rateSuperStrict.consume(ipAddress) : await rateLimiterRedis.consume(ipAddress); @@ -171,9 +193,7 @@ export default async function handler(req, res) { headers = refresh ? await rateSuperStrict.get(ipAddress) : await rateLimiterRedis.get(ipAddress); - - console.log(headers); - } catch (error) { + } catch (error: any) { return res.status(429).json({ error: `Too Many Requests, retry after ${getTimeFromMs( error.msBeforeNext @@ -182,20 +202,28 @@ export default async function handler(req, res) { }); } + meta = await redis.get(`meta:${id}`); + const parsedMeta = JSON.parse(meta); + if (parsedMeta?.length === 0) { + await redis.del(`meta:${id}`); + console.log("deleted meta cache"); + meta = null; + } + if (refresh) { await redis.del(`episode:${id}`); - console.log("deleted cache"); } else { cached = await redis.get(`episode:${id}`); - console.log("using redis"); + if (cached?.length === 0) { + await redis.del(`episode:${id}`); + cached = null; + } } - - meta = await redis.get(`meta:${id}`); } if (cached && !refresh) { if (dub) { - const filteredData = filterData(JSON.parse(cached), "dub"); + const filteredData: EpisodeData[] = filterData(JSON.parse(cached), "dub"); let filtered = filteredData.filter((item) => item?.episodes?.some((epi) => epi.hasDub !== false) @@ -208,7 +236,9 @@ export default async function handler(req, res) { res.setHeader("X-RateLimit-Remaining", headers.remainingPoints); res.setHeader("X-RateLimit-BeforeReset", headers.msBeforeNext); - return res.status(200).json(filtered); + return res + .status(200) + .json(filtered?.filter((i) => i?.providerId !== "9anime")); } else { const filteredData = filterData(JSON.parse(cached), "sub"); @@ -221,11 +251,13 @@ export default async function handler(req, res) { res.setHeader("X-RateLimit-Remaining", headers.remainingPoints); res.setHeader("X-RateLimit-BeforeReset", headers.msBeforeNext); - return res.status(200).send(filtered); + return res + .status(200) + .send(filtered?.filter((i) => i?.providerId !== "9anime")); } } else { const [consumet, anify, cover] = await Promise.all([ - fetchConsumet(id, dub), + fetchConsumet(id), fetchAnify(id), fetchCoverImage(id, meta), ]); @@ -249,12 +281,16 @@ export default async function handler(req, res) { if (meta) { data = await appendMetaToEpisodes(filteredData, JSON.parse(meta)); - } else if (cover && !cover.some((e) => e.img === null)) { + } else if ( + cover && + // !cover?.some((item: { img: null }) => item.img === null) && + cover?.length > 0 + ) { if (redis) await redis.set(`meta:${id}`, JSON.stringify(cover)); data = await appendMetaToEpisodes(filteredData, cover); } - if (redis && cacheTime !== null) { + if (redis && cacheTime !== null && rawData?.length > 0) { await redis.set( `episode:${id}`, JSON.stringify(rawData), @@ -282,7 +318,7 @@ export default async function handler(req, res) { } } -function getTimeFromMs(time) { +function getTimeFromMs(time: number) { const timeInSeconds = time / 1000; if (timeInSeconds >= 3600) { diff --git a/pages/api/v2/etc/recent/[page].js b/pages/api/v2/etc/recent/[page].js deleted file mode 100644 index 2ff22ea..0000000 --- a/pages/api/v2/etc/recent/[page].js +++ /dev/null @@ -1,57 +0,0 @@ -import { rateLimitStrict, redis } from "@/lib/redis"; - -let API_URL; -API_URL = process.env.API_URI || null; -if (API_URL && API_URL.endsWith("/")) { - API_URL = API_URL.slice(0, -1); -} - -export default async function handler(req, res) { - try { - if (redis) { - try { - const ipAddress = req.socket.remoteAddress; - await rateLimitStrict.consume(ipAddress); - } catch (error) { - return res.status(429).json({ - error: `Too Many Requests, retry after ${error.msBeforeNext / 1000}`, - }); - } - } - - let cache; - - if (redis) { - cache = await redis.get(`recent-episode`); - } - - if (cache) { - return res.status(200).json({ results: JSON.parse(cache) }); - } else { - const page = req.query.page || 1; - - var hasNextPage = true; - var datas = []; - - async function fetchData(page) { - const data = await fetch( - `https://api.anify.tv/recent?type=anime&page=${page}&perPage=45` - ).then((res) => res.json()); - - // const filtered = data?.results?.filter((i) => i.type !== "ONA"); - // hasNextPage = data?.hasNextPage; - datas = data; - } - - await fetchData(page); - - if (redis) { - await redis.set(`recent-episode`, JSON.stringify(datas), "EX", 60 * 60); - } - - return res.status(200).json({ results: datas }); - } - } catch (error) { - res.status(500).json({ error }); - } -} diff --git a/pages/api/v2/etc/recent/[page].tsx b/pages/api/v2/etc/recent/[page].tsx new file mode 100644 index 0000000..e49591c --- /dev/null +++ b/pages/api/v2/etc/recent/[page].tsx @@ -0,0 +1,81 @@ +import { rateLimitStrict, redis } from "@/lib/redis"; +import { AnifyRecentEpisode } from "@/utils/types"; +import axios from "axios"; +import { NextApiRequest, NextApiResponse } from "next"; + +let API_URL: string | null; +API_URL = process.env.API_URI || null; +if (API_URL && API_URL.endsWith("/")) { + API_URL = API_URL.slice(0, -1); +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + if (redis) { + try { + const ipAddress: any = req.socket.remoteAddress; + await rateLimitStrict?.consume(ipAddress); + } catch (error: any) { + return res.status(429).json({ + error: `Too Many Requests, retry after ${error.msBeforeNext / 1000}`, + }); + } + } + + let cache; + + if (redis) { + cache = await redis.get(`recent-episode`); + } + + if (cache) { + return res.status(200).json({ results: JSON.parse(cache) }); + } else { + const page = req.query.page || 1; + + var hasNextPage = true; + let datas: AnifyRecentEpisode[] = []; + + const fetchData = async (page: any) => { + const { data } = await axios.get( + `https://api.anify.tv/recent?type=anime&page=${page}&perPage=45&fields=[id,slug,title,currentEpisode,coverImage,episodes]` + ); + + // const filtered = data?.results?.filter((i) => i.type !== "ONA"); + // hasNextPage = data?.hasNextPage; + + const newData = data.map((i: AnifyRecentEpisode) => { + const getGogo = i.episodes?.data?.find( + (x) => x.providerId === "gogoanime" + ); + const getGogoEpisode = getGogo?.episodes?.find( + (x) => x.number === i.currentEpisode + ); + + return { + id: i.id, + slug: getGogoEpisode?.id, + title: i.title, + currentEpisode: i.currentEpisode, + coverImage: i.coverImage, + }; + }); + + datas = newData; + }; + + await fetchData(page); + + if (redis) { + await redis.set(`recent-episode`, JSON.stringify(datas), "EX", 60 * 60); + } + + return res.status(200).json({ results: datas }); + } + } catch (error) { + res.status(500).json({ error }); + } +} diff --git a/pages/api/v2/etc/schedule/index.js b/pages/api/v2/etc/schedule/index.tsx index 2ddc82a..e6f0b26 100644 --- a/pages/api/v2/etc/schedule/index.js +++ b/pages/api/v2/etc/schedule/index.tsx @@ -1,6 +1,7 @@ import axios from "axios"; import cron from "cron"; import { rateLimiterRedis, redis } from "@/lib/redis"; +import { NextApiRequest, NextApiResponse } from "next"; // Function to fetch new data async function fetchData() { @@ -37,22 +38,42 @@ const job = new cron.CronJob("0 0 * * 1", () => { }); job.start(); -export default async function handler(req, res) { +interface Title { + romaji: string; + english: string; + native: string; +} + +type CachedData = { + id: string; + title: Title; + coverImage: string; + bannerImage: string; + airingAt: number; + airingEpisode: number; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { try { - let cached; + let cached: CachedData | null = null; if (redis) { try { - const ipAddress = req.socket.remoteAddress; - await rateLimiterRedis.consume(ipAddress); - } catch (error) { + const ipAddress: any = req.socket.remoteAddress; + await rateLimiterRedis?.consume(ipAddress); + } catch (error: any) { return res.status(429).json({ error: `Too Many Requests, retry after ${error.msBeforeNext / 1000}`, }); } - cached = await redis.get("schedule"); + const cachedData = await redis.get("schedule"); + cached = cachedData ? JSON.parse(cachedData) : null; } + if (cached) { - return res.status(200).json(JSON.parse(cached)); + return res.status(200).json(cached); } else { const data = await fetchData(); diff --git a/pages/en/about.js b/pages/en/about.tsx index aa0ba30..c5e9c51 100644 --- a/pages/en/about.js +++ b/pages/en/about.tsx @@ -1,7 +1,7 @@ import Head from "next/head"; import { motion } from "framer-motion"; import Link from "next/link"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; import Footer from "@/components/shared/footer"; export default function About() { @@ -21,7 +21,7 @@ export default function About() { <meta name="viewport" content="width=device-width, initial-scale=1" /> <link rel="icon" href="/svg/c.svg" /> </Head> - <NewNavbar withNav={true} scrollP={5} shrink={true} /> + <Navbar withNav={true} scrollP={5} shrink={true} /> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} diff --git a/pages/en/anime/[...id].js b/pages/en/anime/[...id].tsx index 25cc4d6..42cae38 100644 --- a/pages/en/anime/[...id].js +++ b/pages/en/anime/[...id].tsx @@ -16,18 +16,30 @@ import Footer from "@/components/shared/footer"; import { mediaInfoQuery } from "@/lib/graphql/query"; import MobileNav from "@/components/shared/MobileNav"; +import pls from "@/utils/request/index"; + import Characters from "@/components/anime/charactersCard"; import { redis } from "@/lib/redis"; - -export default function Info({ info, color }) { - const { data: session } = useSession(); +import { toast } from "sonner"; +import { Navbar } from "@/components/shared/NavBar"; +import { AniListInfoTypes } from "types/info/AnilistInfoTypes"; + +type InfoTypes = { + info: AniListInfoTypes; + color: string; + api: string; + chapterNotFound: string; +}; + +export default function Info({ info, color, chapterNotFound }: InfoTypes) { + const { data: session }: any = useSession(); const { getUserLists } = useAniList(session); const [loading, setLoading] = useState(false); - const [progress, setProgress] = useState(0); - const [statuses, setStatuses] = useState(null); + const [progress, setProgress] = useState<number>(0); + const [statuses, setStatuses] = useState<any>(null); const [domainUrl, setDomainUrl] = useState(""); - const [watch, setWatch] = useState(); + const [watch, setWatch] = useState<string>(); const [open, setOpen] = useState(false); const { id } = useRouter().query; @@ -37,6 +49,14 @@ export default function Info({ info, color }) { ); useEffect(() => { + if (chapterNotFound) { + toast.error("Source not found"); + const cleanUrl = window.location.origin + window.location.pathname; + window.history.replaceState(null, "", cleanUrl); + } + }, [chapterNotFound]); + + useEffect(() => { handleClose(); async function fetchData() { setLoading(true); @@ -53,7 +73,9 @@ export default function Info({ info, color }) { if (user) { setProgress(user.progress); - const statusMapping = { + const statusMapping: { + [key: string]: { name: string; value: string }; + } = { CURRENT: { name: "Watching", value: "CURRENT" }, PLANNING: { name: "Plan to watch", value: "PLANNING" }, COMPLETED: { name: "Completed", value: "COMPLETED" }, @@ -118,6 +140,7 @@ export default function Info({ info, color }) { }&image=${info.bannerImage || info.coverImage.extraLarge}`} /> </Head> + <Navbar info={info} /> <Modal open={open} onClose={() => handleClose()}> <div> {!session && ( @@ -151,7 +174,7 @@ export default function Info({ info, color }) { )} </div> </Modal> - <MobileNav sessions={session} hideProfile={true} /> + <MobileNav hideProfile={true} /> <main className="w-screen min-h-screen relative flex flex-col items-center bg-primary gap-5"> <div className="w-screen absolute"> <div className="bg-gradient-to-t from-primary from-10% to-transparent absolute h-[280px] w-screen z-10 inset-0" /> @@ -169,12 +192,10 @@ export default function Info({ info, color }) { <div className="w-full lg:max-w-screen-lg xl:max-w-screen-2xl z-30 flex flex-col gap-5"> <DetailTop info={info} - session={session} handleOpen={handleOpen} - loading={loading} statuses={statuses} watchUrl={watch} - progress={progress} + progress={progress || 0} color={color} /> @@ -188,6 +209,9 @@ export default function Info({ info, color }) { {info?.characters?.edges && ( <div className="w-full"> + {/* <div className="w-full h-[150px] bg-white flex-center text-black"> + ad banner + </div> */} <Characters info={info?.characters?.edges} /> </div> )} @@ -208,8 +232,8 @@ export default function Info({ info, color }) { ); } -export async function getServerSideProps(ctx) { - const { id } = ctx.query; +export async function getServerSideProps(ctx: any) { + const { id, notfound } = ctx.query; let API_URI; API_URI = process.env.API_URI || null || null; @@ -217,7 +241,12 @@ export async function getServerSideProps(ctx) { API_URI = API_URI.slice(0, -1); } - let cache; + let cache, chapterNotFound; + + if (notfound) { + // create random id string + chapterNotFound = Math.random().toString(36).substring(7); + } if (redis) { cache = await redis.get(`anime:${id}`); @@ -230,14 +259,15 @@ export async function getServerSideProps(ctx) { info, color, api: API_URI, + chapterNotFound: chapterNotFound || null, }, }; } else { - const resp = await fetch("https://graphql.anilist.co/", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, + const [resp] = await pls.post("https://graphql.anilist.co/", { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // }, body: JSON.stringify({ query: mediaInfoQuery, variables: { @@ -246,10 +276,10 @@ export async function getServerSideProps(ctx) { }), }); - const json = await resp.json(); - const data = json?.data?.Media; + // const json = await resp.json(); + const data = resp?.data?.Media; - const cacheTime = data.nextAiringEpisode?.episode + const cacheTime = data?.nextAiringEpisode?.episode ? 60 * 10 : 60 * 60 * 24 * 30; @@ -283,12 +313,13 @@ export async function getServerSideProps(ctx) { info: data, color: color, api: API_URI, + chapterNotFound: chapterNotFound || null, }, }; } } -function getBrightness(hexColor) { +function getBrightness(hexColor: { match: (arg0: RegExp) => any[] }) { if (!hexColor) { return 200; } @@ -299,7 +330,7 @@ function getBrightness(hexColor) { return (299 * rgb[0] + 587 * rgb[1] + 114 * rgb[2]) / 1000; } -function setTxtColor(hexColor) { +function setTxtColor(hexColor: { match: (arg0: RegExp) => any[] }) { const brightness = getBrightness(hexColor); return brightness < 150 ? "#fff" : "#000"; } diff --git a/pages/en/anime/recent.js b/pages/en/anime/recent.js index 4a8111d..240ed1d 100644 --- a/pages/en/anime/recent.js +++ b/pages/en/anime/recent.js @@ -83,7 +83,7 @@ export default function Recent({ sessions }) { <div className="z-50 bg-primary pt-5 pb-3 shadow-md shadow-primary w-full fixed px-3"> <Link href="/en" className="flex gap-2 items-center font-karla"> <ChevronLeftIcon className="w-5 h-5" /> - <h1 className="text-xl">New Episodes</h1> + <h1 className="text-xl">Freshly Added</h1> </Link> </div> <div className="grid grid-cols-2 xs:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-6 gap-5 max-w-6xl pt-20"> diff --git a/pages/en/anime/watch/[...info].js b/pages/en/anime/watch/[...info].js index beab366..dc1f412 100644 --- a/pages/en/anime/watch/[...info].js +++ b/pages/en/anime/watch/[...info].js @@ -1,5 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; -import PlayerComponent from "@/components/watch/player/playerComponent"; +import { useEffect, useState } from "react"; import { FlagIcon, ShareIcon } from "@heroicons/react/24/solid"; import Details from "@/components/watch/primary/details"; import EpisodeLists from "@/components/watch/secondary/episodeLists"; @@ -9,13 +8,16 @@ import { authOptions } from "../../../api/auth/[...nextauth]"; import { createList, createUser, getEpisode } from "@/prisma/user"; import Link from "next/link"; import MobileNav from "@/components/shared/MobileNav"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; import Modal from "@/components/modal"; import AniList from "@/components/media/aniList"; import { signIn } from "next-auth/react"; import BugReportForm from "@/components/shared/bugReport"; import Skeleton from "react-loading-skeleton"; import Head from "next/head"; +import VidStack from "@/components/watch/new-player/player"; +import { useRouter } from "next/router"; +import { Spinner } from "@vidstack/react"; export async function getServerSideProps(context) { let userData = null; @@ -81,7 +83,7 @@ export async function getServerSideProps(context) { color } synonyms - + } } `, @@ -91,6 +93,8 @@ export async function getServerSideProps(context) { }), }); const data = await ress.json(); + // const variables = { id: aniId }; + // const data = await getAnilistMediaInfo(variables, context.req); try { if (session) { @@ -142,17 +146,24 @@ export default function Watch({ const [episodesList, setepisodesList] = useState(); const [mapEpisode, setMapEpisode] = useState(null); - const [episodeSource, setEpisodeSource] = useState(null); - const [open, setOpen] = useState(false); const [isOpen, setIsOpen] = useState(false); + const { setAutoNext } = useWatchProvider(); + const [onList, setOnList] = useState(false); - const { theaterMode, setPlayerState, setAutoPlay, setMarked } = - useWatchProvider(); + const router = useRouter(); - const playerRef = useRef(null); + const { + theaterMode, + setPlayerState, + setAutoPlay, + setMarked, + setTrack, + aspectRatio, + setDataMedia, + } = useWatchProvider(); useEffect(() => { async function getInfo() { @@ -160,6 +171,8 @@ export default function Watch({ setOnList(true); } + setDataMedia(info); + const response = await fetch( `/api/v2/episode/${info.id}?releasing=${ info.status === "RELEASING" ? "true" : "false" @@ -202,17 +215,18 @@ export default function Watch({ const previousEpisode = episodeList?.find( (i) => i.number === parseInt(epiNumber) - 1 ); - setEpisodeNavigation({ + const vidNav = { prev: previousEpisode, playing: { id: currentEpisode.id, - title: playingData?.title, + title: playingData?.title || info?.title?.romaji, description: playingData?.description, img: playingData?.img || playingData?.image, number: currentEpisode.number, }, next: nextEpisode, - }); + }; + setEpisodeNavigation(vidNav); } } @@ -228,12 +242,17 @@ export default function Watch({ }, [sessions?.user?.name, epiNumber, dub]); useEffect(() => { + const autoNext = localStorage.getItem("autoNext"), + autoPlay = localStorage.getItem("autoplay"); + if (autoNext) { + setAutoNext(autoNext); + } + if (autoPlay) { + setAutoPlay(autoPlay); + } + async function fetchData() { if (info) { - const autoplay = - localStorage.getItem("autoplay_video") === "true" ? true : false; - setAutoPlay(autoplay); - const anify = await fetch("/api/v2/source", { method: "POST", headers: { @@ -252,6 +271,11 @@ export default function Watch({ }), }).then((res) => res.json()); + if (!anify?.sources?.length > 0) { + router.push(`/en/anime/${info.id}?notfound=true`); + return; + } + const skip = await fetch( `https://api.aniskip.com/v2/skip-times/${info.idMal}/${parseInt( epiNumber @@ -267,31 +291,77 @@ export default function Watch({ return res.json(); }); - const op = - skip?.results?.find((item) => item.skipType === "op") || null; - const ed = - skip?.results?.find((item) => item.skipType === "ed") || null; + let getOp = + skip?.results?.find((item) => item.skipType === "op") || null, + getEd = skip?.results?.find((item) => item.skipType === "ed") || null; + + const op = getOp + ? { + startTime: + anify?.intro?.start ?? Math.round(getOp?.interval.startTime), + endTime: + anify?.intro?.end ?? Math.round(getOp?.interval.endTime), + text: "Opening", + } + : null, + ed = { + startTime: + anify?.outro?.start ?? Math.round(getEd?.interval.startTime), + endTime: anify?.outro?.end ?? Math.round(getEd?.interval.endTime), + text: "Ending", + }; + const skipData = [op, ed].filter((i) => i !== null); + + const quality = + anify?.sources?.find( + (i) => i.quality === "default" || i.quality === "auto" + ) || anify?.sources[0]; + + const reFormSubtitles = anify?.subtitles?.map((i) => { + return { + src: proxy + "/" + i.url, + label: i.lang, + kind: i.lang === "Thumbnails" ? "thumbnails" : "subtitles", + ...(i.lang === "English" && { default: true }), + }; + }); + + const thumbnails = reFormSubtitles?.find( + (i) => i.kind === "thumbnails" + ); + + const subtitles = reFormSubtitles?.filter( + (i) => i.kind !== "thumbnails" + ); const episode = { - epiData: anify, - skip: { - op, - ed, + provider, + isDub: dub, + defaultQuality: { + // url: quality?.url, + url: `${proxy}/proxy/m3u8/${encodeURIComponent( + String(quality?.url) + )}/${encodeURIComponent(JSON.stringify(anify?.headers))}`, + headers: anify?.headers, }, + subtitles: subtitles, + thumbnails: thumbnails?.src, + epiData: anify, + skip: skipData, }; - setEpisodeSource(episode); + setTrack(episode); } } fetchData(); return () => { - setEpisodeSource(); setPlayerState({ currentTime: 0, isPlaying: false, }); setMarked(0); + setTrack(null); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -424,7 +494,7 @@ export default function Watch({ </Modal> <BugReportForm isOpen={isOpen} setIsOpen={setIsOpen} /> <main className="w-screen h-full"> - <NewNavbar + <Navbar scrollP={20} withNav={true} shrink={true} @@ -435,21 +505,23 @@ export default function Watch({ className={`mx-auto pt-16 ${theaterMode ? "lg:pt-16" : "lg:pt-20"}`} > {theaterMode && ( - <PlayerComponent - id={"cinematic"} - session={sessions} - playerRef={playerRef} - dub={dub} - info={info} - watchId={watchId} - proxy={proxy} - track={episodeNavigation} - data={episodeSource?.epiData} - skip={episodeSource?.skip} - timeWatched={userData?.timeWatched} - provider={provider} - className="w-screen max-h-[85dvh]" - /> + <div + className={`bg-black w-full max-h-[84dvh] h-full flex-center rounded-md`} + style={{ aspectRatio: aspectRatio }} + > + {episodeNavigation ? ( + <VidStack + id={`${watchId}-theater`} + navigation={episodeNavigation} + sessions={sessions} + userData={userData} + /> + ) : ( + <div className="flex-center aspect-video w-full h-full relative"> + <SpinLoader /> + </div> + )} + </div> )} <div id="default" @@ -459,20 +531,25 @@ export default function Watch({ > <div id="primary" className="w-full"> {!theaterMode && ( - <PlayerComponent - id={"default"} - session={sessions} - playerRef={playerRef} - dub={dub} - info={info} - watchId={watchId} - proxy={proxy} - track={episodeNavigation} - data={episodeSource?.epiData} - skip={episodeSource?.skip} - timeWatched={userData?.timeWatched} - provider={provider} - /> + <div + className={`bg-black w-full flex-center rounded-md overflow-hidden ${ + aspectRatio === "4/3" ? "aspect-video" : "" + }`} + // style={{ aspectRatio: aspectRatio }} + > + {episodeNavigation ? ( + <VidStack + id={`${watchId}-default`} + navigation={episodeNavigation} + sessions={sessions} + userData={userData} + /> + ) : ( + <div className="flex-center aspect-video w-full h-full relative"> + <SpinLoader /> + </div> + )} + </div> )} <div id="details" @@ -506,7 +583,7 @@ export default function Watch({ className="flex items-center gap-2 px-3 py-1 ring-[1px] ring-white/20 rounded overflow-hidden" > <ShareIcon className="w-5 h-5" /> - share + <span className="hidden lg:block">share</span> </button> <button type="button" @@ -514,11 +591,10 @@ export default function Watch({ className="flex items-center gap-2 px-3 py-1 ring-[1px] ring-white/20 rounded overflow-hidden" > <FlagIcon className="w-5 h-5" /> - report + <span className="hidden lg:block">report</span> </button> </div> </div> - {/* <div>right</div> */} </div> <Details @@ -538,6 +614,11 @@ export default function Watch({ id="secondary" className={`relative ${theaterMode ? "pt-5" : "pt-4 lg:pt-0"}`} > + {/* <div className="w-full h-[150px] text-black p-3"> + <span className="bg-white w-full h-full flex-center"> + ad banner + </span> + </div> */} <EpisodeLists info={info} session={sessions} @@ -556,3 +637,17 @@ export default function Watch({ </> ); } + +function SpinLoader() { + return ( + <div className="pointer-events-none absolute inset-0 z-50 flex h-full w-full items-center justify-center"> + <Spinner.Root + className="text-white animate-spin opacity-100" + size={84} + > + <Spinner.Track className="opacity-25" width={8} /> + <Spinner.TrackFill className="opacity-75" width={8} /> + </Spinner.Root> + </div> + ); +} diff --git a/pages/en/contact.js b/pages/en/contact.tsx index 385bdb1..9954f95 100644 --- a/pages/en/contact.js +++ b/pages/en/contact.tsx @@ -1,10 +1,10 @@ -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; import Footer from "@/components/shared/footer"; const Contact = () => { return ( <> - <NewNavbar withNav={true} scrollP={5} shrink={true} /> + <Navbar withNav={true} scrollP={5} shrink={true} /> <div className=" flex h-screen w-screen flex-col items-center justify-center font-karla font-bold"> <h1>Contact Us</h1> <p>If you have any questions or comments, please email us at:</p> diff --git a/pages/en/dmca.js b/pages/en/dmca.tsx index e559829..eba28fe 100644 --- a/pages/en/dmca.js +++ b/pages/en/dmca.tsx @@ -1,5 +1,5 @@ import MobileNav from "@/components/shared/MobileNav"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; import Footer from "@/components/shared/footer"; import Head from "next/head"; @@ -21,7 +21,7 @@ export default function DMCA() { <link rel="icon" href="/svg/c.svg" /> </Head> <> - <NewNavbar withNav={true} scrollP={5} shrink={true} /> + <Navbar withNav={true} scrollP={5} shrink={true} /> <MobileNav hideProfile={true} /> <div className="min-h-screen z-20 flex w-screen justify-center items-center"> diff --git a/pages/en/index.js b/pages/en/index.tsx index 29b0778..4141015 100644 --- a/pages/en/index.js +++ b/pages/en/index.tsx @@ -14,11 +14,11 @@ import Schedule from "@/components/home/schedule"; import getUpcomingAnime from "@/lib/anilist/getUpcomingAnime"; import GetMedia from "@/lib/anilist/getMedia"; -// import UserRecommendation from "../../components/home/recommendation"; import MobileNav from "@/components/shared/MobileNav"; import { getGreetings } from "@/utils/getGreetings"; import { redis } from "@/lib/redis"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; +import UserRecommendation from "@/components/home/recommendation"; export async function getServerSideProps() { let cachedData; @@ -75,12 +75,55 @@ export async function getServerSideProps() { } } -export default function Home({ detail, populars, upComing }) { - const { data: sessions } = useSession(); - const { anime: currentAnime, manga: currentManga } = GetMedia(sessions, { +type HomeProps = { + genre: any; + detail: any; + populars: any; + upComing: any; +}; + +export interface SessionTypes { + name: string; + picture: Picture; + sub: string; + token: string; + id: number; + image: Image; + list: string[]; + version: string; + iat: number; + exp: number; + jti: string; +} + +interface Picture { + large: string; + medium: string; +} + +interface Image { + large: string; + medium: string; +} + +export default function Home({ detail, populars, upComing }: HomeProps) { + const { data: sessions }: any = useSession(); + const userSession: SessionTypes = sessions?.user; + + const { + anime: currentAnime, + manga: currentManga, + recommendations, + }: { + anime: CurrentMediaTypes[]; + manga: CurrentMediaTypes[]; + recommendations: CurrentMediaTypes[]; + } = GetMedia(sessions, { stats: "CURRENT", }); - const { anime: plan } = GetMedia(sessions, { stats: "PLANNING" }); + const { anime: plan }: { anime: CurrentMediaTypes[] } = GetMedia(sessions, { + stats: "PLANNING", + }); const { anime: release } = GetMedia(sessions); const [schedules, setSchedules] = useState(null); @@ -97,12 +140,12 @@ export default function Home({ detail, populars, upComing }) { } useEffect(() => { - if (sessions?.user?.version) { - if (sessions.user.version !== "1.0.1") { - signOut("AniListProvider"); + if (userSession?.version) { + if (userSession?.version !== "1.0.1") { + signOut({ redirect: true }); } } - }, [sessions?.user?.version]); + }, [userSession?.version]); useEffect(() => { getRecent(); @@ -118,33 +161,15 @@ export default function Home({ detail, populars, upComing }) { } }, [upComing]); - // useEffect(() => { - // const getSchedule = async () => { - // try { - // const res = await fetch(`/api/v2/etc/schedule`); - // const data = await res.json(); - - // if (!res.ok) { - // setSchedules(null); - // } else { - // setSchedules(data); - // } - // } catch (err) { - // console.log(err); - // } - // }; - // getSchedule(); - // }, []); - - const [releaseData, setReleaseData] = useState([]); + const [releaseData, setReleaseData] = useState<any[]>([]); useEffect(() => { function getRelease() { - let releasingAnime = []; - let progress = []; - let seenIds = new Set(); // Create a Set to store the IDs of seen anime - release.map((list) => { - list.entries.map((entry) => { + let releasingAnime: any[] = []; + let progress: any[] = []; + let seenIds = new Set<number>(); // Create a Set to store the IDs of seen anime + (release as any[]).forEach((list: any) => { + list.entries.forEach((entry: any) => { if ( entry.media.status === "RELEASING" && !seenIds.has(entry.media.id) @@ -156,18 +181,18 @@ export default function Home({ detail, populars, upComing }) { }); }); setReleaseData(releasingAnime); - setProg(progress); + if (progress.length > 0) setProg(progress); } getRelease(); }, [release]); - const [listAnime, setListAnime] = useState(null); - const [listManga, setListManga] = useState(null); - const [planned, setPlanned] = useState(null); - const [user, setUser] = useState(null); + const [listAnime, setListAnime] = useState<any[] | null>(); + const [listManga, setListManga] = useState<any[] | null>(null); + const [planned, setPlanned] = useState<any[] | null>(null); + const [user, setUser] = useState<any[] | null>(null); const [removed, setRemoved] = useState(); - const [prog, setProg] = useState(null); + const [prog, setProg] = useState<any[] | null>(); const popular = populars?.data; const data = detail.data[0]; @@ -175,7 +200,7 @@ export default function Home({ detail, populars, upComing }) { useEffect(() => { async function userData() { try { - if (sessions?.user?.name) { + if (userSession?.name) { await fetch(`/api/user/profile`, { method: "POST", headers: { @@ -189,9 +214,9 @@ export default function Home({ detail, populars, upComing }) { } catch (error) { console.log(error); } - let data; + let data: UserDataType | null = null; try { - if (sessions?.user?.name) { + if (userSession?.name) { const res = await fetch( `/api/user/profile?name=${sessions.user.name}` ); @@ -220,17 +245,20 @@ export default function Home({ detail, populars, upComing }) { // Handle the error here } if (!data) { - const dat = JSON.parse(localStorage.getItem("artplayer_settings")); + const dat: any = localStorage.getItem("artplayer_settings"); if (dat) { - const arr = Object.keys(dat).map((key) => dat[key]); - const newFirst = arr?.sort((a, b) => { - return new Date(b?.createdAt) - new Date(a?.createdAt); + const arr = Object.keys(dat).map((key: string) => dat[key] as any); + const newFirst = arr?.sort((a: any, b: any) => { + return ( + new Date(b?.createdAt).getTime() - + new Date(a?.createdAt).getTime() + ); }); const uniqueTitles = new Set(); // Filter out duplicates and store unique entries - const filteredData = newFirst.filter((entry) => { + const filteredData = newFirst.filter((entry: any) => { if (uniqueTitles.has(entry.aniTitle)) { return false; } @@ -238,7 +266,9 @@ export default function Home({ detail, populars, upComing }) { return true; }); - setUser(filteredData); + if (filteredData) { + setUser(filteredData); + } } } else { // Create a Set to store unique aniTitles @@ -257,11 +287,11 @@ export default function Home({ detail, populars, upComing }) { // const data = await res.json(); } userData(); - }, [sessions?.user?.name, removed]); + }, [userSession?.name, removed]); useEffect(() => { async function userData() { - if (!sessions?.user?.name) return; + if (!userSession?.name) return; const getMedia = currentAnime.find((item) => item.status === "CURRENT") || null; @@ -292,9 +322,7 @@ export default function Home({ detail, populars, upComing }) { userData(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessions?.user?.name, currentAnime, plan]); - - // console.log({ recentAdded }); + }, [userSession?.name, currentAnime, plan]); return ( <Fragment> @@ -304,7 +332,6 @@ export default function Home({ detail, populars, upComing }) { <link rel="icon" href="/svg/c.svg" /> <link rel="canonical" href="https://moopa.live/en/" /> <meta name="twitter:card" content="summary_large_image" /> - {/* Write the best SEO for this homepage */} <meta name="description" content="Discover your new favorite anime or manga title! Moopa offers a vast library of high-quality content, accessible on multiple devices and without any interruptions. Start using Moopa today!" @@ -339,9 +366,9 @@ export default function Home({ detail, populars, upComing }) { /> <meta name="twitter:image" content="/preview.png" /> </Head> - <MobileNav sessions={sessions} hideProfile={true} /> + <MobileNav hideProfile={true} /> - <NewNavbar paddingY="pt-2 lg:pt-10" withNav={true} home={true} /> + <Navbar paddingY="pt-2 lg:pt-10" withNav={true} home={true} /> <div className="h-auto w-screen bg-[#141519] text-[#dbdcdd]"> {/* PC / TABLET */} <div className=" hidden justify-center lg:flex my-16"> @@ -381,6 +408,16 @@ export default function Home({ detail, populars, upComing }) { </div> </div> </div> + {/* <div className="relative w-screen h-screen overflow-hidden"> + <iframe + width="560" + height="315" + src="https://www.youtube.com/embed/VVfdqw-qvNE?autoplay=1&controls=0&rel=0&mute=1" + frameborder="0" + allowfullscreen + className="absolute w-screen h-screen top-0 scale-[115%] left-0 z-0" + /> + </div> */} {sessions && ( <div className="flex items-center justify-center lg:bg-none mt-4 lg:mt-0 w-screen"> @@ -411,7 +448,7 @@ export default function Home({ detail, populars, upComing }) { animate={{ opacity: 1 }} transition={{ duration: 0.5, staggerChildren: 0.2 }} // Add staggerChildren prop > - {user?.length > 0 && user?.some((i) => i?.watchId) && ( + {user && user?.length > 0 && user?.some((i) => i?.watchId) && ( <motion.section // Add motion.div to each child component key="recentlyWatched" initial={{ y: 20, opacity: 0 }} @@ -423,7 +460,7 @@ export default function Home({ detail, populars, upComing }) { ids="recentlyWatched" section="Recently Watched" userData={user} - userName={sessions?.user?.name} + userName={userSession?.name} setRemoved={setRemoved} /> </motion.section> @@ -442,12 +479,12 @@ export default function Home({ detail, populars, upComing }) { section="On-Going Anime" data={releaseData} og={prog} - userName={sessions?.user?.name} + userName={userSession?.name} /> </motion.section> )} - {sessions && listAnime?.length > 0 && ( + {sessions && listAnime && listAnime?.length > 0 && ( <motion.section // Add motion.div to each child component key="listAnime" initial={{ y: 20, opacity: 0 }} @@ -460,12 +497,12 @@ export default function Home({ detail, populars, upComing }) { section="Your Watch List" data={listAnime} og={prog} - userName={sessions?.user?.name} + userName={userSession?.name} /> </motion.section> )} - {sessions && listManga?.length > 0 && ( + {sessions && listManga && listManga?.length > 0 && ( <motion.section // Add motion.div to each child component key="listManga" initial={{ y: 20, opacity: 0 }} @@ -478,13 +515,13 @@ export default function Home({ detail, populars, upComing }) { section="Your Manga List" data={listManga} // og={prog} - userName={sessions?.user?.name} + userName={userSession?.name} /> </motion.section> )} - {/* {recommendations.length > 0 && ( - <div className="space-y-5 mb-10"> + {recommendations.length > 0 && ( + <div className="space-y-4 lg:space-y-5 mb-5 lg:mb-10"> <div className="px-5"> <p className="text-sm lg:text-base"> Based on Your List @@ -496,10 +533,10 @@ export default function Home({ detail, populars, upComing }) { </div> <UserRecommendation data={recommendations} /> </div> - )} */} + )} {/* SECTION 2 */} - {sessions && planned?.length > 0 && ( + {sessions && planned && planned?.length > 0 && ( <motion.section // Add motion.div to each child component key="plannedAnime" initial={{ y: 20, opacity: 0 }} @@ -511,7 +548,7 @@ export default function Home({ detail, populars, upComing }) { ids="plannedAnime" section="Your Plan" data={planned} - userName={sessions?.user?.name} + userName={userSession?.name} /> </motion.section> )} @@ -534,7 +571,7 @@ export default function Home({ detail, populars, upComing }) { > <Content ids="recentAdded" - section="New Episodes" + section="Freshly Added" data={recentAdded} /> </motion.section> @@ -556,6 +593,9 @@ export default function Home({ detail, populars, upComing }) { /> </motion.section> )} + {/* <div className="w-full h-[150px] bg-white flex-center my-5 text-black"> + ad banner + </div> */} {/* Schedule */} {anime.length > 0 && ( @@ -608,3 +648,65 @@ export default function Home({ detail, populars, upComing }) { </Fragment> ); } + +export interface CurrentMediaTypes { + status?: string; + name: string; + entries: Entry[]; +} + +export interface Entry { + id: number; + mediaId: number; + status: string; + progress: number; + score: number; + media: Media; +} + +export interface Media { + id: number; + status: string; + nextAiringEpisode: any; + title: Title; + episodes: number; + coverImage: CoverImage; +} + +export interface Title { + english: string; + romaji: string; +} + +export interface CoverImage { + large: string; +} + +export interface UserDataType { + id: string; + name: string; + setting: Setting; + WatchListEpisode: WatchListEpisode[]; +} + +export interface Setting { + CustomLists: boolean; +} + +export interface WatchListEpisode { + id: string; + aniId?: string; + title?: string; + aniTitle?: string; + image?: string; + episode?: number; + timeWatched?: number; + duration?: number; + provider?: string; + nextId?: string; + nextNumber?: number; + dub?: boolean; + createdDate: string; + userProfileId: string; + watchId: string; +} diff --git a/pages/en/manga/[...id].js b/pages/en/manga/[...id].js deleted file mode 100644 index 5648b2c..0000000 --- a/pages/en/manga/[...id].js +++ /dev/null @@ -1,427 +0,0 @@ -import ChapterSelector from "@/components/manga/chapters"; -import Footer from "@/components/shared/footer"; -import Head from "next/head"; -import { useEffect, useState } from "react"; -import { getServerSession } from "next-auth"; -import { authOptions } from "../../api/auth/[...nextauth]"; -import { mediaInfoQuery } from "@/lib/graphql/query"; -import Modal from "@/components/modal"; -import { signIn, useSession } from "next-auth/react"; -import AniList from "@/components/media/aniList"; -import ListEditor from "@/components/listEditor"; -import MobileNav from "@/components/shared/MobileNav"; -import Image from "next/image"; -import DetailTop from "@/components/anime/mobile/topSection"; -import Characters from "@/components/anime/charactersCard"; -import Content from "@/components/home/content"; -import { toast } from "sonner"; -import axios from "axios"; -import getAnifyInfo from "@/lib/anify/info"; -import { redis } from "@/lib/redis"; -import getMangaId from "@/lib/anify/getMangaId"; - -export default function Manga({ info, anifyData, color, chapterNotFound }) { - const [domainUrl, setDomainUrl] = useState(""); - const { data: session } = useSession(); - - const [loading, setLoading] = useState(false); - const [progress, setProgress] = useState(0); - const [statuses, setStatuses] = useState(null); - const [watch, setWatch] = useState(); - - const [chapter, setChapter] = useState(null); - - const [open, setOpen] = useState(false); - - const rec = info?.recommendations?.nodes?.map( - (data) => data.mediaRecommendation - ); - - useEffect(() => { - setDomainUrl(window.location.origin); - }, []); - - useEffect(() => { - if (chapterNotFound) { - toast.error("Chapter not found"); - const cleanUrl = window.location.origin + window.location.pathname; - window.history.replaceState(null, null, cleanUrl); - } - }, [chapterNotFound]); - - useEffect(() => { - async function fetchData() { - try { - setLoading(true); - - const { data } = await axios.get(`/api/v2/info?id=${anifyData.id}`); - - if (!data.chapters) { - setLoading(false); - return; - } - - setChapter(data); - setLoading(false); - } catch (error) { - console.error(error); - } - } - fetchData(); - - return () => { - setChapter(null); - }; - }, [info?.id]); - - function handleOpen() { - setOpen(true); - document.body.style.overflow = "hidden"; - } - - function handleClose() { - setOpen(false); - document.body.style.overflow = "auto"; - } - - return ( - <> - <Head> - <title> - {info - ? `Manga - ${ - info.title.romaji || info.title.english || info.title.native - }` - : "Getting Info..."} - </title> - <meta name="twitter:card" content="summary_large_image" /> - <meta - name="twitter:title" - content={`Moopa - ${info.title.romaji || info.title.english}`} - /> - <meta - name="twitter:description" - content={`${info.description?.slice(0, 180)}...`} - /> - <meta - name="twitter:image" - content={`${domainUrl}/api/og?title=${ - info.title.romaji || info.title.english - }&image=${info.bannerImage || info.coverImage}`} - /> - <meta - name="title" - data-title-romaji={info?.title?.romaji} - data-title-english={info?.title?.english} - data-title-native={info?.title?.native} - /> - </Head> - <Modal open={open} onClose={() => handleClose()}> - <div> - {!session && ( - <div className="flex-center flex-col gap-5 px-10 py-5 bg-secondary rounded-md"> - <div className="text-md font-extrabold font-karla"> - Edit your list - </div> - <button - className="flex items-center bg-[#363642] rounded-md text-white p-1" - onClick={() => signIn("AniListProvider")} - > - <h1 className="px-1 font-bold font-karla"> - Login with AniList - </h1> - <div className="scale-[60%] pb-[1px]"> - <AniList /> - </div> - </button> - </div> - )} - {session && info && ( - <ListEditor - animeId={info?.id} - session={session} - stats={statuses?.value} - prg={progress} - max={info?.episodes} - info={info} - close={handleClose} - /> - )} - </div> - </Modal> - <MobileNav sessions={session} hideProfile={true} /> - <main className="w-screen min-h-screen overflow-hidden relative flex flex-col items-center gap-5"> - {/* <div className="absolute bg-gradient-to-t from-primary from-85% to-100% to-transparent w-screen h-full z-10" /> */} - <div className="w-screen absolute"> - <div className="bg-gradient-to-t from-primary from-10% to-transparent absolute h-[280px] w-screen z-10 inset-0" /> - {info?.bannerImage && ( - <Image - src={info?.bannerImage} - alt="banner anime" - height={1000} - width={1000} - blurDataURL={info?.bannerImage} - className="object-cover bg-image blur-[2px] w-screen absolute top-0 left-0 h-[250px] brightness-[55%] z-0" - /> - )} - </div> - <div className="w-full lg:max-w-screen-lg xl:max-w-screen-2xl z-30 flex flex-col gap-5 pb-10"> - <DetailTop - info={info} - session={session} - handleOpen={handleOpen} - loading={loading} - statuses={statuses} - watchUrl={watch} - progress={progress} - color={color} - /> - - {!loading ? ( - chapter?.chapters?.length > 0 ? ( - <ChapterSelector - chaptersData={chapter.chapters} - mangaId={chapter.id} - data={info} - setWatch={setWatch} - /> - ) : ( - <div className="h-[20vh] lg:w-full flex-center flex-col gap-5"> - <p className="text-center font-karla font-bold lg:text-lg"> - Oops!<br></br> It looks like this manga is not available. - </p> - </div> - ) - ) : ( - <div className="flex justify-center"> - <div className="lds-ellipsis"> - <div></div> - <div></div> - <div></div> - <div></div> - </div> - </div> - )} - - {info?.characters?.edges?.length > 0 && ( - <div className="w-full"> - <Characters info={info?.characters?.edges} /> - </div> - )} - - {info && rec && rec?.length !== 0 && ( - <div className="w-full"> - <Content - ids="recommendAnime" - section="Recommendations" - type="manga" - data={rec} - /> - </div> - )} - </div> - </main> - <Footer /> - </> - ); -} - -export async function getServerSideProps(context) { - const session = await getServerSession(context.req, context.res, authOptions); - const accessToken = session?.user?.token || null; - - const { chapter } = context.query; - const [id1, id2] = context.query.id; - - let cached; - let aniId, mangadexId; - let info, data, color, chapterNotFound; - - if (String(id1).length > 6) { - aniId = id2; - mangadexId = id1; - } else { - aniId = id1; - mangadexId = id2; - } - - if (chapter) { - // create random id string - chapterNotFound = Math.random().toString(36).substring(7); - } - - if (aniId === "na" && mangadexId) { - const datas = await getAnifyInfo(mangadexId); - - aniId = - datas.mappings?.filter((i) => i.providerId === "anilist")[0]?.id || null; - - if (!aniId) { - info = datas; - data = datas; - color = { - backgroundColor: `${"#ffff"}`, - color: "#000", - }; - // return { - // redirect: { - // destination: "/404", - // permanent: false, - // }, - // }; - } - } else if (aniId && !mangadexId) { - // console.log({ aniId }); - const response = await fetch("https://graphql.anilist.co/", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(accessToken && { Authorization: `Bearer ${accessToken}` }), - }, - body: JSON.stringify({ - query: `query ($id: Int, $type: MediaType) { - Media (id: $id, type: $type) { - id - title { - romaji - english - native - } - } - }`, - variables: { - id: parseInt(aniId), - type: "MANGA", - }, - }), - }); - const aniListData = await response.json(); - const info = aniListData?.data?.Media; - - const mangaId = await getMangaId( - info?.title?.romaji, - info?.title?.english, - info?.title?.native - ); - mangadexId = mangaId?.id; - - if (!mangadexId) { - return { - redirect: { - destination: "/404", - permanent: false, - }, - }; - } - - return { - redirect: { - destination: `/en/manga/${aniId}/${mangadexId}${ - chapter ? "?chapter=404" : "" - }`, - permanent: true, - }, - }; - } else if (!aniId && mangadexId) { - const data = await getAnifyInfo(mangadexId); - - aniId = - data.mappings.filter((i) => i.providerId === "anilist")[0]?.id || null; - - if (!aniId) { - info = data; - // return { - // redirect: { - // destination: "/404", - // permanent: false, - // }, - // }; - } - - return { - redirect: { - destination: `/en/manga/${aniId ? aniId : "na"}${`/${mangadexId}`}${ - chapter ? "?chapter=404" : "" - }`, - permanent: true, - }, - }; - } else { - if (redis) { - const getCached = await redis.get(`mangaPage:${mangadexId}`); - - if (getCached) { - cached = JSON.parse(getCached); - } - } - // let chapters; - if (cached) { - data = cached.data; - info = cached.info; - color = cached.color; - } else { - data = await getAnifyInfo(mangadexId); - - const aniListId = - data.mappings?.filter((i) => i.providerId === "anilist")[0]?.id || null; - - const response = await fetch("https://graphql.anilist.co/", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(accessToken && { Authorization: `Bearer ${accessToken}` }), - }, - body: JSON.stringify({ - query: mediaInfoQuery, - variables: { - id: parseInt(aniListId), - type: "MANGA", - }, - }), - }); - const aniListData = await response.json(); - if (aniListData?.data?.Media) info = aniListData?.data?.Media; - - const textColor = setTxtColor(info?.color); - - color = { - backgroundColor: `${info?.color || "#ffff"}`, - color: textColor, - }; - - if (redis) { - await redis.set( - `mangaPage:${mangadexId}`, - JSON.stringify({ data, info, color }), - "ex", - 60 * 60 * 24 - ); - } - } - } - - return { - props: { - info: info || null, - anifyData: data || null, - chapterNotFound: chapterNotFound || null, - color: color || null, - }, - }; -} - -function getBrightness(hexColor) { - if (!hexColor) { - return 200; - } - const rgb = hexColor - .match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i) - .slice(1) - .map((x) => parseInt(x, 16)); - return (299 * rgb[0] + 587 * rgb[1] + 114 * rgb[2]) / 1000; -} - -function setTxtColor(hexColor) { - const brightness = getBrightness(hexColor); - return brightness < 150 ? "#fff" : "#000"; -} diff --git a/pages/en/manga/[...id].tsx b/pages/en/manga/[...id].tsx new file mode 100644 index 0000000..d1c10a4 --- /dev/null +++ b/pages/en/manga/[...id].tsx @@ -0,0 +1,456 @@ +import Footer from "@/components/shared/footer"; +import Head from "next/head"; +import { useEffect, useState } from "react"; +import { getServerSession } from "next-auth"; +import { authOptions } from "../../api/auth/[...nextauth]"; +import { mediaInfoQuery } from "@/lib/graphql/query"; +import Modal from "@/components/modal"; +import { signIn } from "next-auth/react"; +import AniList from "@/components/media/aniList"; +import ListEditor from "@/components/listEditor"; +import MobileNav from "@/components/shared/MobileNav"; +import Image from "next/image"; +import DetailTop from "@/components/anime/mobile/topSection"; +import Characters from "@/components/anime/charactersCard"; +import Content from "@/components/home/content"; +import { toast } from "sonner"; +import getAnifyInfo from "@/lib/anify/info"; +import getMangaId from "@/lib/anify/getMangaId"; +import { useRouter } from "next/router"; +import ChaptersComponent from "@/components/manga/ChaptersComponent"; +import pls from "@/utils/request/index"; +import { AniListInfoTypes } from "types/info/AnilistInfoTypes"; +import { Navbar } from "@/components/shared/NavBar"; + +type MangaProps = { + aniId: string; + mangadexId: string; + sessions: any; + metaData: any; + chapterNotFound: string; +}; + +export default function Manga({ + aniId, + mangadexId, + sessions: session, + chapterNotFound, + metaData, +}: MangaProps) { + const [domainUrl, setDomainUrl] = useState(""); + + const [loading, setLoading] = useState(false); + const [watch, setWatch] = useState(); + + const [mangaId, setMangaId] = useState<string | null>(mangadexId); + const [chapters, setChapters] = useState(null); + const [notFound, setNotFound] = useState(false); + + const [info, setInfo] = useState<AniListInfoTypes | null>(null); + const [color, setColor] = useState(null); + + const [open, setOpen] = useState(false); + + const router = useRouter(); + + const rec = info?.recommendations?.nodes?.map( + (data) => data.mediaRecommendation + ); + + useEffect(() => { + setDomainUrl(window.location.origin); + }, []); + + useEffect(() => { + if (chapterNotFound) { + toast.error("Chapter not found"); + const cleanUrl = window.location.origin + window.location.pathname; + window.history.replaceState(null, "", cleanUrl); + } + }, [chapterNotFound]); + + useEffect(() => { + setMangaId(null); + }, [aniId]); + + useEffect(() => { + async function fetchData() { + try { + let info, data, color: any; + setChapters(null); + setNotFound(false); + + if (aniId && mangadexId) { + const [aniListData] = await pls.post("https://graphql.anilist.co/", { + body: JSON.stringify({ + query: mediaInfoQuery, + variables: { + id: parseInt(aniId), + type: "MANGA", + }, + }), + }); + // const aniListData = await response.json(); + info = aniListData?.data?.Media; + const textColor = setTxtColor(info?.color); + + color = { + backgroundColor: `${info?.color || "#ffff"}`, + color: textColor, + }; + + setInfo(info); + setColor(color); + setMangaId(mangadexId); + // console.log("wow two of them here"); + } else if (aniId && !mangadexId) { + const [aniListData] = await pls.post("https://graphql.anilist.co/", { + body: JSON.stringify({ + query: mediaInfoQuery, + variables: { + id: parseInt(aniId), + type: "MANGA", + }, + }), + }); + // const aniListData = await response.json(); + info = aniListData?.data?.Media; + const textColor = setTxtColor(info?.color); + + color = { + backgroundColor: `${info?.color || "#ffff"}`, + color: textColor, + }; + + setInfo(info); + setColor(color); + + const mangaId = await getMangaId( + info?.title?.romaji, + info?.title?.english, + info?.title?.native + ); + + mangadexId = (mangaId as { id: string }).id; + + if (mangadexId) { + setMangaId(mangadexId); + // console.log("mangadex is here", mangadexId); + router.push("/en/manga/" + aniId + "/" + mangadexId, undefined, { + shallow: true, + }); + } else { + // console.log("why is this running?"); + setMangaId(null); + setLoading(false); + setNotFound(true); + // router.push("/en/manga/" + aniId, undefined, { shallow: true }); + } + } else if (!aniId && mangadexId) { + data = await getAnifyInfo(mangadexId); + + const aniListId = + data.mappings?.filter((i: any) => i.providerId === "anilist")[0] + ?.id || null; + + if (aniListId) { + const [aniListData] = await pls.post( + "https://graphql.anilist.co/", + { + body: JSON.stringify({ + query: mediaInfoQuery, + variables: { + id: parseInt(aniListId), + type: "MANGA", + }, + }), + } + ); + // const aniListData = await response.json(); + info = aniListData?.data?.Media; + + router.push( + "/en/manga/" + aniListId + "/" + mangadexId, + undefined, + { shallow: true } + ); + } + + const textColor = setTxtColor(data?.color); + + color = { + backgroundColor: `${data?.color || "#ffff"}`, + color: textColor, + }; + + setInfo(aniListId ? info : data); + setColor(color); + setMangaId(mangadexId); + } + } catch (error) { + console.log(error); + } + } + fetchData(); + + return () => { + setInfo(null); + }; + }, [session?.user?.token, aniId, mangadexId]); + + function handleOpen() { + setOpen(true); + document.body.style.overflow = "hidden"; + } + + function handleClose() { + setOpen(false); + document.body.style.overflow = "auto"; + } + + return ( + <> + <Head> + <title> + {metaData + ? `Manga - ${ + metaData.title.romaji || + metaData.title.english || + metaData.title.native + }` + : "Getting Info..."} + </title> + <meta + name="description" + content={`${metaData?.description?.slice(0, 180)}...`} + /> + <meta + name="keywords" + content={`${metaData?.genres}, ${metaData?.author} `} + /> + <meta + property="og:title" + content={`Moopa - ${ + metaData?.title.romaji || metaData?.title.english + }`} + /> + <meta + property="og:description" + content={`${metaData?.description?.slice(0, 180)}...`} + /> + <meta + property="og:image" + content={`${domainUrl}/api/og?title=${ + metaData?.title.romaji || metaData?.title.english + }&image=${metaData?.bannerImage || metaData?.coverImage}`} + /> + <meta + property="og:url" + content={`${domainUrl}/en/manga/${metaData?.id}`} + /> + <meta property="og:type" content="book" /> + <meta property="og:locale" content="en_US" /> + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:site" content="@yourTwitterHandle" /> + <meta + name="twitter:title" + content={`Moopa - ${ + metaData?.title.romaji || metaData?.title.english + }`} + /> + <meta + name="twitter:description" + content={`${metaData?.description?.slice(0, 180)}...`} + /> + <meta name="robots" content="noindex" /> + <meta + name="twitter:image" + content={`${domainUrl}/api/og?title=${ + metaData?.title.romaji || metaData?.title.english + }&image=${metaData?.bannerImage || metaData?.coverImage}`} + /> + </Head> + <Navbar info={info} manga /> + <Modal open={open} onClose={() => handleClose()}> + <div> + {!session && ( + <div className="flex-center flex-col gap-5 px-10 py-5 bg-secondary rounded-md"> + <div className="text-md font-extrabold font-karla"> + Edit your list + </div> + <button + className="flex items-center bg-[#363642] rounded-md text-white p-1" + onClick={() => signIn("AniListProvider")} + > + <h1 className="px-1 font-bold font-karla"> + Login with AniList + </h1> + <div className="scale-[60%] pb-[1px]"> + <AniList /> + </div> + </button> + </div> + )} + {session && info && ( + <ListEditor + animeId={info?.id} + session={session} + // stats={statuses?.value} + // prg={progress} + max={info?.episodes} + info={info} + close={handleClose} + /> + )} + </div> + </Modal> + <MobileNav hideProfile={true} /> + <main className="w-screen min-h-screen overflow-hidden relative flex flex-col items-center gap-5"> + <div className="w-screen absolute"> + <div className="bg-gradient-to-t from-primary from-10% to-transparent absolute h-[280px] w-screen z-10 inset-0" /> + {info?.bannerImage && ( + <Image + src={info?.bannerImage} + alt="banner anime" + height={1000} + width={1000} + blurDataURL={info?.bannerImage} + className="object-cover bg-image blur-[2px] w-screen absolute top-0 left-0 h-[250px] brightness-[55%] z-0" + /> + )} + </div> + <div className="w-full lg:max-w-screen-lg xl:max-w-screen-2xl z-30 flex flex-col gap-5 pb-10"> + {/* {info && ( */} + <DetailTop + info={info} + handleOpen={handleOpen} + // statuses={statuses} + watchUrl={watch} + // progress={progress} + color={color} + /> + {/* )} */} + + <ChaptersComponent + info={info} + mangaId={mangaId} + aniId={aniId} + setWatch={setWatch} + chapter={chapters} + setChapter={setChapters} + loading={loading} + setLoading={setLoading} + notFound={notFound} + setNotFound={setNotFound} + /> + + {info && info.characters.edges.length > 0 && ( + <div className="w-full"> + <Characters info={info?.characters?.edges} /> + </div> + )} + + {info && rec && rec?.length !== 0 && ( + <div className="w-full"> + <Content + ids="recommendAnime" + section="Recommendations" + type="manga" + data={rec} + /> + </div> + )} + </div> + </main> + <Footer /> + </> + ); +} + +export async function getServerSideProps(context: any) { + const session: any = await getServerSession( + context.req, + context.res, + authOptions + ); + const accessToken = session?.user?.token || null; + + const { chapter } = context.query; + const [id1, id2] = context.query.id; + + let aniId, mangadexId; + let chapterNotFound; + + if (String(id1).length > 6) { + aniId = id2; + mangadexId = id1; + } else { + aniId = id1; + mangadexId = id2; + } + + if (chapter) { + // create random id string + chapterNotFound = Math.random().toString(36).substring(7); + } + + const [aniListData] = await pls.post("https://graphql.anilist.co/", { + body: JSON.stringify({ + query: `query ($id: Int, $type: MediaType) { + Media(id: $id, type: $type) { + id + title { + romaji + english + native + } + bannerImage + genres + coverImage { + extraLarge + large + medium + color + } + status + description + } + }`, + variables: { + id: parseInt(aniId), + type: "MANGA", + }, + }), + }); + const info = aniListData?.data?.Media; + + return { + props: { + aniId: aniId || null, + mangadexId: mangadexId || null, + accessToken: accessToken || null, + sessions: session || null, + metaData: info || null, + // info: info || null, + // anifyData: data || null, + chapterNotFound: chapterNotFound || null, + // color: color || null, + }, + }; +} + +function getBrightness(hexColor: { match: (arg0: RegExp) => any[] }) { + if (!hexColor) { + return 200; + } + const rgb = hexColor + .match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i) + .slice(1) + .map((x) => parseInt(x, 16)); + return (299 * rgb[0] + 587 * rgb[1] + 114 * rgb[2]) / 1000; +} + +function setTxtColor(hexColor: { match: (arg0: RegExp) => any[] }) { + const brightness = getBrightness(hexColor); + return brightness < 150 ? "#fff" : "#000"; +} diff --git a/pages/en/manga/read/[...params].js b/pages/en/manga/read/[...params].js index a7fa78b..036b999 100644 --- a/pages/en/manga/read/[...params].js +++ b/pages/en/manga/read/[...params].js @@ -150,6 +150,7 @@ export default function Read({ data-title-native={info?.title?.native} /> <meta id="CoverImage" data-manga-cover={info?.coverImage} /> + <meta name="robots" content="noindex" /> </Head> <div className="w-screen flex justify-evenly relative"> <ShortCutModal isOpen={isKeyOpen} setIsOpen={setIsKeyOpen} /> diff --git a/pages/en/profile/[user].js b/pages/en/profile/[user].tsx index 7ef5de3..82b88af 100644 --- a/pages/en/profile/[user].js +++ b/pages/en/profile/[user].tsx @@ -1,14 +1,28 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "../../api/auth/[...nextauth]"; import Image from "next/image"; import Link from "next/link"; import Head from "next/head"; import { useEffect, useState } from "react"; import { getUser } from "@/prisma/user"; -import { NewNavbar } from "@/components/shared/NavBar"; import { toast } from "sonner"; +import { Navbar } from "@/components/shared/NavBar"; +import pls from "@/utils/request"; +import { CurrentMediaTypes } from ".."; -export default function MyList({ media, sessions, user, time, userSettings }) { +type MyListProps = { + media: CurrentMediaTypes[]; + sessions: any; + user: any; + time: any; + userSettings: any; +}; + +export default function MyList({ + media, + sessions, + user, + time, + userSettings, +}: MyListProps) { const [listFilter, setListFilter] = useState("all"); const [visible, setVisible] = useState(false); const [useCustomList, setUseCustomList] = useState(true); @@ -40,26 +54,27 @@ export default function MyList({ media, sessions, user, time, userSettings }) { if (data) { toast.success(`Custom List is now ${!useCustomList ? "on" : "off"}`); } - localStorage.setItem("customList", !useCustomList); + localStorage.setItem("customList", String(!useCustomList)); } catch (error) { console.error(error); } }; - const filterMedia = (status) => { + const filterMedia = (status: string) => { if (status === "all") { return media; } - return media.filter((m) => m.name === status); + return media.filter((m: { name: string }) => m.name === status); }; return ( <> <Head> <title>My Lists</title> </Head> - <NewNavbar /> - <div className="w-screen lg:flex justify-between lg:px-10 xl:px-32 py-5 relative"> + <Navbar withNav toTop shrink bgHover scrollP={110} paddingY={"py-1"} /> + + <div className="w-screen lg:flex justify-between lg:px-10 xl:px-32 py-5 mt-10 xl:mt-16 relative"> <div className="lg:w-[30%] h-full mt-12 lg:mr-10 grid gap-5 mx-3 lg:mx-0 antialiased"> <div className="flex items-center gap-5"> <Image @@ -289,7 +304,7 @@ export default function MyList({ media, sessions, user, time, userSettings }) { <div className="absolute -top-10 -left-40 invisible lg:group-hover:visible"> <Image src={item.media.coverImage.large} - alt={item.media.id} + alt={String(item.media.id)} width={1000} height={1000} className="object-cover h-[186px] w-[140px] shrink-0 rounded-md" @@ -362,19 +377,14 @@ export default function MyList({ media, sessions, user, time, userSettings }) { ); } -export async function getServerSideProps(context) { - const session = await getServerSession(context.req, context.res, authOptions); - const accessToken = session?.user?.token || null; +export async function getServerSideProps(context: any) { const query = context.query; - const response = await fetch("https://graphql.anilist.co/", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(accessToken && { Authorization: `Bearer ${accessToken}` }), - }, - body: JSON.stringify({ - query: ` + const [data, session] = await pls.post( + "https://graphql.anilist.co/", + { + body: JSON.stringify({ + query: ` query ($username: String, $status: MediaListStatus) { MediaListCollection(userName: $username, type: ANIME, status: $status, sort: SCORE_DESC) { user { @@ -426,15 +436,15 @@ export async function getServerSideProps(context) { } } `, - variables: { - username: query.user, - }, - }), - }); - - const data = await response.json(); + variables: { + username: query.user, + }, + }), + }, + context + ); - const get = data.data.MediaListCollection; + const get = data?.data?.MediaListCollection; const sectionOrder = get?.user.mediaListOptions.animeList.sectionOrder; if (!sectionOrder) { @@ -451,12 +461,15 @@ export async function getServerSideProps(context) { const prog = get.lists; - function getIndex(status) { + function getIndex(status: string) { const index = sectionOrder.indexOf(status); return index === -1 ? sectionOrder.length : index; } - prog.sort((a, b) => getIndex(a.name) - getIndex(b.name)); + prog.sort( + (a: { name: string }, b: { name: string }) => + getIndex(a.name) - getIndex(b.name) + ); const user = get.user; @@ -473,24 +486,24 @@ export async function getServerSideProps(context) { }; } -function UnixTimeConverter({ unixTime }) { +function UnixTimeConverter({ unixTime }: { unixTime: number }) { const date = new Date(unixTime * 1000); // multiply by 1000 to convert to milliseconds const formattedDate = date.toISOString().slice(0, 10); // format date to YYYY-MM-DD return <p>{formattedDate}</p>; } -function convertMinutesToDays(minutes) { +function convertMinutesToDays(minutes: number) { const hours = minutes / 60; const days = hours / 24; if (days >= 1) { return days % 1 === 0 - ? { days: `${parseInt(days)}` } + ? { days: `${days}` } : { days: `${days.toFixed(1)}` }; } else { return hours % 1 === 0 - ? { hours: `${parseInt(hours)}` } + ? { hours: `${hours}` } : { hours: `${hours.toFixed(1)}` }; } } diff --git a/pages/en/schedule/index.js b/pages/en/schedule/index.tsx index f1e6730..aa30259 100644 --- a/pages/en/schedule/index.js +++ b/pages/en/schedule/index.tsx @@ -18,7 +18,7 @@ import MobileNav from "@/components/shared/MobileNav"; import { useSession } from "next-auth/react"; import { redis } from "@/lib/redis"; import Head from "next/head"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; const day = [ "Sunday", @@ -30,7 +30,8 @@ const day = [ "Saturday", ]; -const isAired = (timestamp) => { +const isAired = (timestamp: number | null) => { + if (!timestamp) return false; const currentTime = new Date().getTime() / 1000; return timestamp <= currentTime; }; @@ -51,7 +52,7 @@ export async function getServerSideProps() { 0 ); const timeUntilMidnightJapan = Math.round( - (midnightTomorrowJapan - nowJapan) / 1000 + (midnightTomorrowJapan.getTime() - nowJapan.getTime()) / 1000 ); let cachedData; @@ -109,12 +110,13 @@ export async function getServerSideProps() { page++; } - const timestampToDay = (timestamp) => { - const options = { weekday: "long" }; - return new Date(timestamp * 1000).toLocaleDateString(undefined, options); + const timestampToDay = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString(undefined, { + weekday: "long", + }); }; - const scheduleByDay = {}; + const scheduleByDay: { [key: string]: any } = {}; airingSchedules.forEach((schedule) => { const day = timestampToDay(schedule.airingAt); if (!scheduleByDay[day]) { @@ -142,10 +144,7 @@ export async function getServerSideProps() { // setSchedule(scheduleByDay); } -export default function Schedule({ schedule }) { - const { data: session } = useSession(); - - // const [schedule, setSchedule] = useState({}); +export default function Schedule({ schedule }: any) { const [filterDay, setFilterDay] = useState("All"); const [loading, setLoading] = useState(true); @@ -178,7 +177,7 @@ export default function Schedule({ schedule }) { let nextAiring = null; let currentlyAiring = null; - for (const [, schedules] of Object.entries(sortedSchedule)) { + for (const [, schedules] of Object.entries(sortedSchedule as object)) { for (const s of schedules) { if (s.airingAt > now) { if (!nextAiring) { @@ -196,16 +195,16 @@ export default function Schedule({ schedule }) { setCurrentlyAiringAnime(currentlyAiring); }, [sortedSchedule]); - const scrollContainerRef = useRef(null); + const scrollContainerRef = useRef<HTMLUListElement>(null); useEffect(() => { // Scroll to center the active button when it changes if (scrollContainerRef.current) { const activeButton = - scrollContainerRef.current.querySelector(".text-action"); + scrollContainerRef.current?.querySelector(".text-action"); if (activeButton) { const containerWidth = scrollContainerRef.current.clientWidth; - const buttonLeft = activeButton.offsetLeft; + const buttonLeft = (activeButton as HTMLElement).offsetLeft; const buttonWidth = activeButton.clientWidth; const scrollLeft = buttonLeft - containerWidth / 2 + buttonWidth / 2; scrollContainerRef.current.scrollLeft = scrollLeft; @@ -264,8 +263,8 @@ export default function Schedule({ schedule }) { content="Moopa is a website where you can find all the information about your favorite anime and manga." /> </Head> - <MobileNav sessions={session} hideProfile={true} /> - <NewNavbar scrollP={10} toTop={true} /> + <MobileNav hideProfile={true} /> + <Navbar scrollP={10} toTop={true} /> <div className="w-screen"> <span className="absolute z-20 top-0 left-0 w-screen h-[190px] lg:h-[250px] bg-secondary overflow-hidden"> <div className="absolute top-40 lg:top-36 w-full h-full bg-primary rounded-t-3xl xl:rounded-t-[50px]" /> @@ -340,7 +339,7 @@ export default function Schedule({ schedule }) { > <div className="ml-4 flex items-center gap-2"> <h3 className="text-lg text-gray-200 font-semibold"> - {timeStamptoAMPM(time)} + {time && timeStamptoAMPM(time)} </h3> {/* {!isAired(time) && <p>Airing Next</p>} */} <p diff --git a/pages/en/search/[...param].js b/pages/en/search/[...param].tsx index c1fd94c..5a34ff5 100644 --- a/pages/en/search/[...param].js +++ b/pages/en/search/[...param].tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { Key, useEffect, useRef, useState } from "react"; import { motion as m } from "framer-motion"; import Skeleton from "react-loading-skeleton"; import { useRouter } from "next/router"; @@ -23,12 +23,15 @@ import { import InputSelect from "@/components/search/dropdown/inputSelect"; import { Cog6ToothIcon, TrashIcon } from "@heroicons/react/20/solid"; import useDebounce from "@/lib/hooks/useDebounce"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; import MobileNav from "@/components/shared/MobileNav"; -import SearchByImage from "@/components/search/searchByImage"; +import SearchByImage, { + TraceMoeResultTypes, +} from "@/components/search/searchByImage"; import { PlayIcon } from "@heroicons/react/24/outline"; +import { StaticImport } from "next/dist/shared/lib/get-img-props"; -export async function getServerSideProps(context) { +export async function getServerSideProps(context: any) { const { param } = context.query; const { search, format, genres, season, year } = context.query; @@ -81,6 +84,15 @@ export async function getServerSideProps(context) { }; } +type CardProps = { + index: number; + query: string; + genres: any; + formats: any; + seasons: any; + years: any; +}; + export default function Card({ index, query, @@ -88,22 +100,25 @@ export default function Card({ formats, seasons, years, -}) { +}: CardProps) { const inputRef = useRef(null); const router = useRouter(); - const [data, setData] = useState(); - const [imageSearch, setImageSearch] = useState(); + const [data, setData] = useState<any>(); + const [imageSearch, setImageSearch] = useState<TraceMoeResultTypes[]>(); const [loading, setLoading] = useState(true); - const [search, setQuery] = useState(query); + const [search, setQuery] = useState<string | null | undefined>(query); const debounceSearch = useDebounce(search, 500); - const [type, setSelectedType] = useState(mediaType[index]); + const [type, setSelectedType] = useState<{ + name: string; + value: string; + } | null>(mediaType[index]); const [year, setYear] = useState(years); const [season, setSeason] = useState(seasons); - const [sort, setSelectedSort] = useState(); + const [sort, setSelectedSort] = useState<{ name: string; value: string }>(); const [genre, setGenre] = useState(genres); const [format, setFormat] = useState(formats); @@ -116,7 +131,7 @@ export default function Card({ setLoading(true); const data = await aniAdvanceSearch({ search: debounceSearch, - type: type?.value, + type: type?.value as "ANIME" | "MANGA" | undefined, genres: genre, page: page, sort: sort?.value, @@ -128,7 +143,7 @@ export default function Card({ setNextPage(false); setLoading(false); } else if (data !== null && page > 1) { - setData((prevData) => { + setData((prevData: any) => { return [...(prevData ?? []), ...data?.media]; }); setNextPage(data?.pageInfo.hasNextPage); @@ -144,7 +159,9 @@ export default function Card({ setData(null); setPage(1); setNextPage(true); - advance(); + if (page === 1) { + advance(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ debounceSearch, @@ -158,7 +175,9 @@ export default function Card({ useEffect(() => { if (imageSearch) return; - advance(); + if (page > 1) { + advance(); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, imageSearch]); @@ -177,21 +196,23 @@ export default function Card({ window.innerHeight + window.pageYOffset >= document.body.offsetHeight - 3 ) { - setPage((prevPage) => prevPage + 1); + if (!loading) { + setPage((prevPage) => prevPage + 1); + } } } window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); - }, [page, nextPage, imageSearch]); + }, [page, nextPage, imageSearch, loading]); - const handleKeyDown = async (event) => { + const handleKeyDown = async (event: any) => { if (event.key === "Enter") { event.preventDefault(); const inputValue = event.target.value; if (inputValue === "") { - setQuery(null); + setQuery(undefined); } else { setQuery(inputValue); } @@ -199,13 +220,13 @@ export default function Card({ }; function trash() { - setImageSearch(); - setQuery(); - setGenre(); - setFormat(); - setSelectedSort(); - setSeason(); - setYear(); + setImageSearch(undefined); + setQuery(undefined); + setGenre(undefined); + setFormat(undefined); + setSelectedSort(undefined); + setSeason(undefined); + setYear(undefined); router.push(`/en/search/${mediaType[index]?.value?.toLowerCase()}`); } @@ -213,8 +234,8 @@ export default function Card({ setIsVisible(!isVisible); } - const handleVideoHover = (hovered, id) => { - const updatedImageSearch = imageSearch?.map((item) => { + const handleVideoHover = (hovered: boolean, id: any) => { + const updatedImageSearch = imageSearch?.map((item: any) => { if (item.filename === id) { return { ...item, hovered }; } @@ -234,7 +255,7 @@ export default function Card({ <link rel="icon" href="/svg/c.svg" /> </Head> - <NewNavbar + <Navbar scrollP={10} withNav={true} shrink={true} @@ -366,7 +387,7 @@ export default function Card({ </div> )} {/* <div> */} - <div className="flex flex-col gap-14 items-center z-30"> + <div className="flex flex-col gap-14 items-center z-30 overflow-x-hidden"> <div key="card-keys" className={`${ @@ -384,69 +405,75 @@ export default function Card({ {data && data?.length > 0 && !imageSearch && - data?.map((anime, index) => { - const anilistId = anime?.mappings?.find( - (x) => x.providerId === "anilist" - )?.id; - return ( - <m.div - initial={{ scale: 0.98 }} - animate={{ scale: 1, transition: { duration: 0.35 } }} - className="w-full" - key={index} - > - <Link - href={ - anime.format === "MANGA" || anime.format === "NOVEL" - ? `/en/manga/${anilistId ? `${anilistId}/` : ""}${ - anime.id - }` - : `/en/anime/${anime.id}` - } - title={anime.title.userPreferred} - className="block relative overflow-hidden bg-secondary hover:scale-[1.03] scale-100 transition-all cursor-pointer duration-200 ease-out rounded" - style={{ - paddingTop: "145%", // 2:3 aspect ratio (3/2 * 100%) - }} - > - <Image - className="object-cover" - src={anime.coverImage.extraLarge} - alt={anime.title.userPreferred} - sizes="(min-width: 808px) 50vw, 100vw" - quality={100} - fill - /> - </Link> - <Link - href={ - anime.format === "MANGA" || anime.format === "NOVEL" - ? `/en/manga/${anilistId ? `${anilistId}/` : ""}${ - anime.id - }` - : `/en/anime/${anime.id}` - } - title={anime.title.userPreferred} + data?.map( + ( + anime: { + format: string; + id: any; + title: { userPreferred: string }; + coverImage: { extraLarge: string | StaticImport }; + status: string; + episodes: any; + chapters: any; + }, + index: Key | null | undefined + ) => { + return ( + <m.div + initial={{ scale: 0.98 }} + animate={{ scale: 1, transition: { duration: 0.35 } }} + className="w-full" + key={index} > - <h1 className="font-outfit font-bold xl:text-base text-[15px] pt-4 line-clamp-2"> - {anime.status === "RELEASING" ? ( - <span className="dots bg-green-500" /> - ) : anime.status === "NOT_YET_RELEASED" ? ( - <span className="dots bg-red-500" /> - ) : null} - {anime.title.userPreferred} - </h1> - </Link> - <h2 className="font-outfit xl:text-[15px] text-[11px] font-light pt-2 text-[#8B8B8B]"> - {anime.format || <p>-</p>} ·{" "} - {anime.status || <p>-</p>} ·{" "} - {anime.episodes - ? `${anime.episodes || "N/A"} Episodes` - : `${anime.chapters || "N/A"} Chapters`} - </h2> - </m.div> - ); - })} + <Link + href={ + anime.format === "MANGA" || anime.format === "NOVEL" + ? `/en/manga/${anime.id}` + : `/en/anime/${anime.id}` + } + title={anime.title.userPreferred} + className="block relative overflow-hidden bg-secondary hover:scale-[1.03] scale-100 transition-all cursor-pointer duration-200 ease-out rounded" + style={{ + paddingTop: "145%", // 2:3 aspect ratio (3/2 * 100%) + }} + > + <Image + className="object-cover" + src={anime.coverImage.extraLarge} + alt={anime.title.userPreferred} + sizes="(min-width: 808px) 50vw, 100vw" + quality={100} + fill + /> + </Link> + <Link + href={ + anime.format === "MANGA" || anime.format === "NOVEL" + ? `/en/manga/${anime.id}` + : `/en/anime/${anime.id}` + } + title={anime.title.userPreferred} + > + <h1 className="font-outfit font-bold xl:text-base text-[15px] pt-4 line-clamp-2"> + {anime.status === "RELEASING" ? ( + <span className="dots bg-green-500" /> + ) : anime.status === "NOT_YET_RELEASED" ? ( + <span className="dots bg-red-500" /> + ) : null} + {anime.title.userPreferred} + </h1> + </Link> + <h2 className="font-outfit xl:text-[15px] text-[11px] font-light pt-2 text-[#8B8B8B]"> + {anime.format || <p>-</p>} ·{" "} + {anime.status || <p>-</p>} ·{" "} + {anime.episodes + ? `${anime.episodes || "N/A"} Episodes` + : `${anime.chapters || "N/A"} Chapters`} + </h2> + </m.div> + ); + } + )} {loading && ( <> @@ -532,7 +559,7 @@ export default function Card({ href={`/en/anime/${a.anilist.id}`} > {/* <h1 className="font-semibold">{a.title}</h1> */} - <p className="flex items-center gap-1 text-sm text-gray-400 w-[320px]"> + <p className="flex items-center gap-1 text-sm text-gray-400 max-w-[320px]"> <span className="text-white max-w-[120px] md:max-w-[200px] lg:max-w-[220px]" style={{ diff --git a/pages/id/index.js b/pages/id/index.tsx index 5ef870d..9af2d06 100644 --- a/pages/id/index.js +++ b/pages/id/index.tsx @@ -3,7 +3,7 @@ import React from "react"; import Image from "next/image"; import Link from "next/link"; import Footer from "@/components/shared/footer"; -import { NewNavbar } from "@/components/shared/NavBar"; +import { Navbar } from "@/components/shared/NavBar"; import MobileNav from "@/components/shared/MobileNav"; export default function Home() { @@ -16,7 +16,7 @@ export default function Home() { <link rel="icon" href="/svg/c.svg" /> </Head> <main className="flex flex-col h-screen"> - <NewNavbar /> + <Navbar /> <MobileNav hideProfile /> {/* Create an under construction page with tailwind css */} <div className="h-full w-screen flex-center flex-grow flex-col"> diff --git a/pages/id/manga/[...id].tsx b/pages/id/manga/[...id].tsx new file mode 100644 index 0000000..513001e --- /dev/null +++ b/pages/id/manga/[...id].tsx @@ -0,0 +1,159 @@ +import axios from "axios"; +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Navbar } from "../../../components/shared/NavBar"; +import MobileNav from "../../../components/shared/MobileNav"; +import pls from "@/utils/request"; + +export interface DataType { + id: string; + title: string; + description: string; + image: string; + chapters: ChapterType[]; +} + +export interface ChapterType { + id: string; + title: string; + rilis: string; +} + +interface InfoNovelProps { + id: string; + API: string; +} + +export default function InfoNovel({ id, API }: InfoNovelProps) { + const [data, setData] = useState<DataType | null>(null); + const [loading, setLoading] = useState<boolean>(true); + + const [filter, setFilter] = useState<string>(""); + + useEffect(() => { + async function fetchData() { + setLoading(true); + try { + const data = await pls.get(`${API}/api/manga/info/` + id); + setData(data); + } catch (error) { + setData(null); + } finally { + setLoading(false); + } + } + fetchData(); + + return () => { + setData(null); + }; + }, [id]); + + const fuzzySearch = (text: string, query: string): boolean => { + const textLower = text.toLowerCase().replace(/\.|\s/g, ""); + const queryLower = query.toLowerCase().replace(/\.|\s/g, ""); + + let i = 0; + let j = 0; + + while (i < textLower.length && j < queryLower.length) { + if (textLower[i] === queryLower[j]) { + j++; + } + i++; + } + + return j === queryLower.length; + }; + + const filteredData = data?.chapters?.filter((chapter: ChapterType) => + fuzzySearch(chapter.title, filter) + ); + + return ( + <div className="flex flex-col items-center"> + <Navbar withNav paddingY="" scrollP={0} /> + <MobileNav hideProfile /> + <div className="relative w-full max-w-screen-lg mx-5 mt-5 px-5 lg:px-0 lg:mt-14"> + {data && ( + <div className="flex lg:flex-row flex-col z-30 pt-24 lg:px-5"> + <div className="shrink-0 z-50 w-[170px] h-[240px] rounded overflow-hidden bg-secondary/20"> + {data?.image && ( + <Image + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( + data?.image + )}${`&headers=${encodeURIComponent( + JSON.stringify({ Referer: "https://komikindo.tv/" }) + )}`}`} + width={200} + height={200} + alt="coverImage" + className="z-50 w-[170px] h-[240px] object-cover" + /> + )} + </div> + <div className="flex flex-col items-start justify-end gap-2 lg:pl-5 z-30 mt-5 lg:mt-0"> + <h1 className="font-bold text-2xl lg:text-3xl font-outfit line-clamp-2"> + {data?.title} + </h1> + {/* <div className="flex gap-5 w-full"> + <p className="flex gap-2 font-bold font-karla"> + Format: <span>{data?.format}</span> + </p> + <p className="flex gap-2 font-bold font-karla"> + Release: <span>{data?.year}</span> + </p> + <p className="flex gap-2 font-bold font-karla"> + Status: <span>{data?.status}</span> + </p> + </div> */} + <p className="line-clamp-2 font-light font-karla"> + {data?.description} + </p> + </div> + </div> + )} + + <div className="mt-10"> + <input + className="appearance-none rounded bg-secondary px-2 py-1 font-karla outline-none" + placeholder="Search..." + value={filter} + onChange={(e) => setFilter(e.target.value)} + /> + </div> + + <div className="mt-5 flex flex-col gap-3"> + {filteredData?.map((chapter: ChapterType) => ( + <Link + key={chapter?.id} + href={`/id/manga/read/${id}/${chapter?.id}`} + className="py-3 bg-secondary w-full px-5 rounded" + > + <div className="flex justify-between items-center font-karla w-full"> + <div className=""> + <p className="font-bold">{chapter?.title}</p> + </div> + <p className="font-light">{chapter?.rilis}</p> + </div> + </Link> + ))} + </div> + <div className="w-full bg-secondary rounded-xl h-[200px] absolute inset-0 z-10" /> + </div> + </div> + ); +} + +export async function getServerSideProps({ params }: any) { + const { id } = params; + const API = process.env.ID_API; + // console.log(id); + return { + props: { + id, + API, + }, + }; +} diff --git a/pages/id/manga/read/[...id].tsx b/pages/id/manga/read/[...id].tsx new file mode 100644 index 0000000..4978e36 --- /dev/null +++ b/pages/id/manga/read/[...id].tsx @@ -0,0 +1,87 @@ +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { Navbar } from "@/components/shared/NavBar"; +import MobileNav from "@/components/shared/MobileNav"; +import pls from "@/utils/request"; + +type DataType = { + id: string; + title: string; + pages: PageType[]; +}; + +type PageType = { + index: string; + src: string; +}; + +interface ReadNovelProps { + mangaId: string; + chapterId: string; + API: string; +} + +export default function ReadNovel({ mangaId, chapterId, API }: ReadNovelProps) { + const [data, setData] = useState<DataType | null>(); + const [hideNav, setHideNav] = useState(false); + + useEffect(() => { + async function fetchData() { + if (chapterId) { + const data = await pls.get(`${API}/api/manga/pages/${chapterId}`); + setData(data); + } + } + fetchData(); + + return () => { + setData(null); + }; + }, [chapterId]); + + return ( + <div className="w-screen flex flex-col items-center"> + {!hideNav && ( + <> + <Navbar paddingY="2" scrollP={0} /> + <MobileNav hideProfile /> + </> + )} + <div className="block mt-12" onClick={() => setHideNav((prev) => !prev)}> + <div className="w-full h-full max-w-screen-lg pointer-events-none select-none"> + {data?.pages?.map((i) => ( + <div key={i.index}> + <Image + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( + i.src + )}${`&headers=${encodeURIComponent( + JSON.stringify({ Referer: "https://komikindo.tv/" }) + )}`}`} + alt="image" + width={500} + height={500} + className="w-full h-full" + /> + </div> + ))} + </div> + </div> + </div> + ); +} + +export async function getServerSideProps({ params }: any) { + const { id } = params; + + const [mangaId, chapterId] = id; + + const API = process.env.ID_API; + + return { + props: { + mangaId, + chapterId, + API, + }, + }; +} diff --git a/pages/id/novel/[...id].tsx b/pages/id/novel/[...id].tsx new file mode 100644 index 0000000..7e9e155 --- /dev/null +++ b/pages/id/novel/[...id].tsx @@ -0,0 +1,121 @@ +import axios from "axios"; +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Navbar } from "../../../components/shared/NavBar"; +import MobileNav from "../../../components/shared/MobileNav"; +import { GetServerSideProps } from "next"; + +type InfoNovelProps = { + id: string; + API: string; +}; + +type NovelData = { + image?: string; + title?: string; + Release?: string; + Status?: string; + Author?: string; + description?: string; + chapters?: { + chapterId?: string; + chapter?: string; + release?: string; + }[]; + notFound?: boolean; +}; + +export default function InfoNovel({ id, API }: InfoNovelProps) { + const [data, setData] = useState<NovelData>(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchData() { + setLoading(true); + try { + const { data } = await axios.get(`${API}/api/novel/info/` + id); + setData(data); + } catch (error) { + setData({ + notFound: true, + }); + } finally { + setLoading(false); + } + } + fetchData(); + + return () => { + setData(undefined); + }; + }, [id]); + + return ( + <div className="flex flex-col items-center"> + <Navbar withNav paddingY="" scrollP={0} /> + <MobileNav hideProfile /> + <div className="relative w-full max-w-screen-lg mx-5 mt-5 px-5 lg:px-0 lg:mt-14"> + {data && ( + <div className="flex lg:flex-row flex-col z-30 pt-24 lg:px-5"> + {data?.image && ( + <Image + src={data?.image} + width={200} + height={200} + alt="coverImage" + className="z-50 w-[170px] h-[240px] object-cover rounded" + /> + )} + <div className="flex flex-col items-start justify-end gap-2 lg:pl-5 z-30 mt-5 lg:mt-0"> + <h1 className="font-bold text-2xl lg:text-3xl font-outfit line-clamp-2"> + {data?.title} + </h1> + <div className="flex gap-5 w-full"> + <p className="flex gap-2 font-bold font-karla"> + Release: <span>{data?.Release}</span> + </p> + <p className="flex gap-2 font-bold font-karla"> + Status: <span>{data?.Status}</span> + </p> + <p className="flex-1 gap-2 font-bold font-karla overflow-x-hidden text-ellipsis whitespace-nowrap"> + Author: <span>{data?.Author}</span> + </p> + </div> + <p className="line-clamp-2 font-light font-karla"> + {data?.description} + </p> + </div> + </div> + )} + + <div className="mt-10 flex flex-col gap-3"> + {data?.chapters?.map((chapter) => ( + <Link + key={chapter?.chapterId} + href={`/id/novel/read/?id=${chapter?.chapterId}`} + className="py-3 bg-secondary w-full px-5 rounded" + > + <div className="flex justify-between w-full"> + <p className="font-bold font-karla">{chapter?.chapter}</p> + <p className="font-light font-karla">{chapter?.release}</p> + </div> + </Link> + ))} + </div> + <div className="w-full bg-secondary rounded-xl h-[200px] absolute inset-0 z-10" /> + </div> + </div> + ); +} + +export const getServerSideProps: GetServerSideProps = async ({ params }) => { + const { id } = params || {}; + const API = process.env.ID_API; + return { + props: { + id, + API, + }, + }; +}; diff --git a/pages/id/novel/read/index.tsx b/pages/id/novel/read/index.tsx new file mode 100644 index 0000000..5f36e54 --- /dev/null +++ b/pages/id/novel/read/index.tsx @@ -0,0 +1,115 @@ +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { Navbar } from "@/components/shared/NavBar"; +import MobileNav from "@/components/shared/MobileNav"; +import pls from "@/utils/request/index"; + +interface IData { + novelTitle: string; + title: string; + navigation: { + next: string; + prev: string; + }; + content: string; +} + +export async function getServerSideProps() { + const API = process.env.ID_API; + return { + props: { + API, + }, + }; +} + +export default function ReadNovel({ API }: { API: string }) { + const [data, setData] = useState<IData>(); + + const searchParams = useSearchParams(); + const id = searchParams.get("id"); + const mangaId = id?.split("/")[0]; + + useEffect(() => { + async function fetchData() { + if (id) { + const data = await pls.get(`${API}/api/novel/chapter/${id}`); + setData(data); + } + } + fetchData(); + + return () => { + setData(undefined); + }; + }, [id]); + + return ( + <> + <Navbar withNav paddingY="py-2" scrollP={2} /> + <MobileNav hideProfile /> + <div className="w-screen flex flex-col items-center"> + {/* {data && ( */} + <div className="flex items-center gap-5 w-full max-w-screen-lg px-5 mt-16 font-karla font-bold"> + <div className="flex gap-2"> + <Link + href={`/id/novel/read/?id=${data?.navigation?.prev}`} + className={`${ + data?.navigation?.prev ? "" : "pointer-events-none opacity-60" + } py-1 px-2 bg-secondary rounded`} + > + prev + </Link> + <Link + href={`/id/novel/read/?id=${data?.navigation?.next}`} + className={`${ + data?.navigation?.next ? "" : "pointer-events-none opacity-60" + } py-1 px-2 bg-secondary rounded`} + > + next + </Link> + </div> + <span>/</span> + <Link href={`/id/novel/${mangaId}`} className="text-lg line-clamp-1"> + {data?.novelTitle} + </Link> + </div> + {/* )} */} + <div className="block mt-5"> + <div className="px-5 w-full h-full max-w-screen-lg pointer-events-none select-none"> + <p className="text-xl font-bold my-5">{data?.title}</p> + {data?.content && ( + <p + dangerouslySetInnerHTML={{ __html: data?.content }} + className="space-y-5" + /> + )} + </div> + </div> + {data?.content && ( + <div className="px-5 py-10 w-full h-full max-w-screen-lg"> + <div className="flex w-full gap-2"> + <Link + href={`/id/novel/read/?id=${data?.navigation?.prev}`} + className={`${ + data?.navigation?.prev ? "" : "pointer-events-none opacity-60" + } py-1 px-2 bg-secondary rounded`} + > + prev + </Link> + <Link + href={`/id/novel/read/?id=${data?.navigation?.next}`} + className={`${ + data?.navigation?.next ? "" : "pointer-events-none opacity-60" + } py-1 px-2 bg-secondary rounded`} + > + next + </Link> + </div> + </div> + )} + </div> + </> + ); +} diff --git a/pages/id/search.tsx b/pages/id/search.tsx new file mode 100644 index 0000000..aa53fcd --- /dev/null +++ b/pages/id/search.tsx @@ -0,0 +1,221 @@ +import Image from "next/image"; +import { Fragment, useEffect, useState } from "react"; +import { + CheckIcon, + ChevronDownIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/24/outline"; +import Link from "next/link"; +import { Combobox, Transition } from "@headlessui/react"; +import pls from "@/utils/request"; + +const types = [ + { + name: "Novel", + value: "novel", + }, + { + name: "Manga", + value: "manga", + }, +]; + +type DataType = { + id: string; + title: string; + img: string; + synonym?: string; + status?: string; + genres?: string; + release?: string; +}; + +export async function getServerSideProps() { + const API = process.env.ID_API; + return { + props: { + API, + }, + }; +} + +export default function Search({ API }: { API: string }) { + const [data, setData] = useState<DataType[] | null>([]); + const [query, setQuery] = useState("a"); + + const [type, setType] = useState(types[0]); + + const handleQuery = async (e: any) => { + e.preventDefault(); + setData([]); + + try { + const data = await pls.get(`${API}/api/${type.value}/search/${query}`); + setData(data); + } catch (error) { + setData(null); + } + }; + + useEffect(() => { + async function fetchData() { + try { + const data = await pls.get(`${API}/api/${type.value}/search/${query}`); + setData(data); + } catch (error) { + setData(null); + } + } + fetchData(); + return () => { + setData(null); + }; + }, [type?.value]); + + useEffect(() => { + // run handleQuery when pressing enter + const handleEnter = (e: any) => { + if (e.key === "Enter") { + handleQuery(e); + } + }; + window.addEventListener("keydown", handleEnter); + + return () => { + window.removeEventListener("keydown", handleEnter); + }; + }, [query, type?.value]); + + const handleChange = (e: any) => { + setType(e); + setData(null); + }; + + return ( + <div className="flex flex-col items-center"> + <div className="w-full max-w-screen-lg px-5"> + <div className="flex justify-between mt-16"> + <div className="flex-1 max-w-[20%] items-center justify-end text-lg relative"> + <Combobox value={type} onChange={(e) => handleChange(e)}> + <Combobox.Button className="h-full w-full gap-5 py-[2px] bg-secondary/70 rounded text-sm font-karla flex items-center justify-between px-2"> + {type.name} + <ChevronDownIcon + className="h-5 w-5 text-gray-400" + aria-hidden="true" + /> + </Combobox.Button> + <Transition + as={Fragment} + enter="transition ease-out duration-200" + enterFrom="transform opacity-0 scale-95 translate-y-5" + enterTo="transform opacity-100 scale-100" + leave="transition ease-in duration-75" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95 translate-y-5" + afterLeave={() => setQuery("")} + > + <Combobox.Options + className="absolute z-[55] mt-1 max-h-60 w-full rounded-md bg-secondary py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + style={{ scrollbarGutter: "stable" }} + > + {types.length === 0 && query !== "" ? ( + <div className="relative cursor-default select-none py-2 px-4 text-gray-300"> + Nothing found. + </div> + ) : ( + types.map((item) => ( + <Combobox.Option + key={item.value} + className={({ active }) => + `relative cursor-pointer select-none py-2 px-2 mx-2 rounded-md ${ + active ? "bg-white/5 text-white" : "text-gray-300" + }` + } + value={item} + > + {({ selected, active }) => ( + <Fragment> + <span + className={`block truncate ${ + selected + ? "font-medium text-white" + : "font-normal" + }`} + > + {item.name} + </span> + {selected ? ( + <span + className={`absolute inset-y-0 right-0 flex items-center pl-3 pr-1 ${ + active ? "text-white" : "text-action" + }`} + > + <CheckIcon + className="h-5 w-5" + aria-hidden="true" + /> + </span> + ) : null} + </Fragment> + )} + </Combobox.Option> + )) + )} + </Combobox.Options> + </Transition> + </Combobox> + </div> + <form + onSubmit={handleQuery} + className="flex items-center justify-end relative space-x-2" + > + <input + type="text" + value={query} + onChange={(e) => setQuery(e.target.value)} + className="bg-secondary h-10 px-5 pr-16 rounded-lg text-sm focus:outline-none" + /> + <button type="submit" className="text-white"> + <MagnifyingGlassIcon className="h-6 w-6 text-white" /> + </button> + </form> + </div> + <div className="mt-5 grid xxs:grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-6 gap-5 gap-y-5"> + {data !== null + ? data?.map((x, index) => ( + <div key={x.id + index} className="flex flex-col gap-2"> + <Link + href={`/id/${type.value}/${x.id}`} + className="block relative overflow-hidden bg-secondary hover:scale-[1.03] scale-100 transition-all cursor-pointer duration-200 ease-out rounded" + style={{ + paddingTop: "145%", // 2:3 aspect ratio (3/2 * 100%) + }} + > + {x.img && ( + <Image + src={`https://aoi.moopa.live/utils/image-proxy?url=${encodeURIComponent( + x.img + )}${`&headers=${encodeURIComponent( + JSON.stringify({ Referer: "https://komikindo.tv/" }) + )}`}`} + alt={x.title} + sizes="(min-width: 808px) 50vw, 100vw" + quality={100} + fill + className="object-cover" + /> + )} + </Link> + <div> + <h1 className="line-clamp-2 font-karla font-bold"> + {x.title} + </h1> + </div> + </div> + )) + : "No results found"} + </div> + </div> + </div> + ); +} diff --git a/pages/index.js b/pages/index.tsx index 25d5b20..25d5b20 100644 --- a/pages/index.js +++ b/pages/index.tsx diff --git a/prisma/user.js b/prisma/user.ts index c2ba5fd..8a0d856 100644 --- a/prisma/user.js +++ b/prisma/user.ts @@ -1,9 +1,24 @@ -import { Prisma } from "@prisma/client"; -// const prisma = new PrismaClient(); +import { Prisma, UserProfile, WatchListEpisode } from "@prisma/client"; import { prisma } from "../lib/prisma"; -export const createUser = async (name) => { +interface UpdateUserEpisodeParams { + name: string; + id: string; + watchId: string; + title: string; + image: string; + number: number; + duration: number; + timeWatched: number; + aniTitle: string; + provider: string; + nextId: string; + nextNumber: number; + dub: boolean; +} + +export const createUser = async (name: string): Promise<UserProfile | null> => { try { const checkUser = await prisma.userProfile.findUnique({ where: { @@ -36,9 +51,12 @@ export const createUser = async (name) => { } }; -export const updateUser = async (name, setting) => { +export const updateUser = async ( + name: string, + setting: any +): Promise<{ name: string; setting: any } | null> => { try { - const user = await prisma.userProfile.updateMany({ + await prisma.userProfile.updateMany({ where: { name: name, }, @@ -46,58 +64,17 @@ export const updateUser = async (name, setting) => { setting, }, }); - return user; - // const checkAnime = await prisma.watchListItem.findUnique({ - // where: { - // title: anime.title, - // userProfileId: name, - // }, - // }); - // if (checkAnime) { - // const checkEpisode = await prisma.watchListEpisode.findUnique({ - // where: { - // url: anime.id, - // }, - // }); - // if (checkEpisode) { - // return null; - // } else { - // const user = await prisma.watchListItem.update({ - // where: { - // title: anime.title, - // userProfileId: name, - // }, - // }); - // } - // } else { - // const user = await prisma.userProfile.update({ - // where: { name: name }, - // data: { - // watchList: { - // create: { - // title: anime.title, - // episodes: { - // create: { - // url: anime.id, - // }, - // }, - // }, - // }, - // }, - // include: { - // watchList: true, - // }, - // }); - - // return user; - // } + return { name: name, setting: setting }; } catch (error) { console.error(error); throw new Error("Error updating user"); } }; -export const getUser = async (name, list = true) => { +export const getUser = async ( + name: string, + list = true +): Promise<any | null> => { try { if (!name) { const user = await prisma.userProfile.findMany({ @@ -127,7 +104,7 @@ export const getUser = async (name, list = true) => { } }; -export const deleteUser = async (name) => { +export const deleteUser = async (name: string): Promise<UserProfile | null> => { try { const user = await prisma.userProfile.delete({ where: { @@ -141,7 +118,11 @@ export const deleteUser = async (name) => { } }; -export const createList = async (name, id, title) => { +export const createList = async ( + name: string, + id: string, + title: string +): Promise<UserProfile | null> => { try { const checkEpisode = await prisma.watchListEpisode.findFirst({ where: { @@ -175,7 +156,10 @@ export const createList = async (name, id, title) => { } }; -export const getEpisode = async (name, id) => { +export const getEpisode = async ( + name: string, + id: string +): Promise<WatchListEpisode[] | null> => { try { const episode = await prisma.watchListEpisode.findMany({ where: { @@ -212,9 +196,9 @@ export const updateUserEpisode = async ({ nextId, nextNumber, dub, -}) => { +}: UpdateUserEpisodeParams) => { try { - const user = await prisma.watchListEpisode.updateMany({ + await prisma.watchListEpisode.updateMany({ where: { userProfileId: name, watchId: watchId, @@ -235,14 +219,17 @@ export const updateUserEpisode = async ({ }, }); - return user; + // return user; } catch (error) { console.error(error); throw new Error("Error updating user episode"); } }; -export const deleteEpisode = async (name, id) => { +export const deleteEpisode = async ( + name: string, + id: string +): Promise<{ success?: boolean; message?: string } | null> => { try { const user = await prisma.watchListEpisode.deleteMany({ where: { @@ -251,7 +238,7 @@ export const deleteEpisode = async (name, id) => { }, }); if (user) { - return user; + return { success: true }; } else { return { message: "Episode not found" }; } @@ -261,7 +248,10 @@ export const deleteEpisode = async (name, id) => { } }; -export const deleteList = async (name, id) => { +export const deleteList = async ( + name: string, + id: string +): Promise<{ success?: boolean; message?: string } | null> => { try { const user = await prisma.watchListEpisode.deleteMany({ where: { @@ -270,7 +260,7 @@ export const deleteList = async (name, id) => { }, }); if (user) { - return user; + return { success: true }; } else { return { message: "Episode not found" }; } diff --git a/public/icon-144x144.png b/public/icon-144x144.png Binary files differnew file mode 100644 index 0000000..d2430c6 --- /dev/null +++ b/public/icon-144x144.png diff --git a/public/manifest.json b/public/manifest.json index cd77de2..5fe3e5d 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -9,6 +9,12 @@ "description": "Watch and Read your favorite Anime/Manga in one single app", "icons": [ { + "src": "/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any" + }, + { "src": "/icon-192x192.png", "sizes": "192x192", "type": "image/png", diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..2c3f889 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Disallow: /en/anime/ +Disallow: /en/manga/ +Disallow: /admin/ +Disallow: /api/
\ No newline at end of file diff --git a/styles/globals.css b/styles/globals.css index 17ca472..10645f0 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -13,6 +13,11 @@ html { -webkit-tap-highlight-color: transparent; } +:root { + --media-brand: 245 245 245; + --media-focus: 78 156 246; +} + body { @apply bg-primary scrollbar-hide text-txt; } @@ -413,7 +418,8 @@ pre code { .next-button { position: relative; - @apply xs:w-28 xs:h-9 w-24 h-7 rounded-md font-karla shadow-xl text-black xs:text-[15px] text-xs md:text-sm flex-center hover:bg-[#b9b9b9]; + @apply px-4 py-2 font-karla text-primary hover:bg-white/80 font-semibold; + /* @apply xs:w-28 xs:h-9 w-24 h-7 rounded-md font-karla shadow-xl text-black xs:text-[15px] text-xs md:text-sm flex-center hover:bg-[#b9b9b9]; */ background: #ffffff; border-radius: 6px; cursor: pointer; @@ -432,7 +438,7 @@ pre code { border-radius: 6px; } .next-button.progress::before { - animation: progress 7s ease forwards; + animation: progress 7s linear forwards; } @keyframes progress { 0% { @@ -557,3 +563,27 @@ pre code { left: unset; } } + +[data-media-player] { + height: 100%; + display: block; +} + +[data-media-provider] { + height: 100%; + border: none; +} + +[data-media-provider] video { + height: 100%; + object-fit: contain; + display: block; +} + +.chat { + @apply flex flex-col gap-[10px]; +} + +.chat > span { + @apply font-karla w-full italic text-white/70; +} diff --git a/tailwind.config.js b/tailwind.config.cjs index 13e9999..e072608 100644 --- a/tailwind.config.js +++ b/tailwind.config.cjs @@ -77,5 +77,18 @@ module.exports = { nocompatible: true, }), require("tailwind-scrollbar-hide"), + require("@vidstack/react/tailwind.cjs")({ + // Change the media variants prefix. + prefix: "media", + }), + require("tailwindcss-animate"), + customVariants, ], }; + +function customVariants({ addVariant, matchVariant }) { + // Strict version of `.group` to help with nesting. + matchVariant("parent-data", (value) => `.parent[data-${value}] > &`); + addVariant("hocus", ["&:hover", "&:focus-visible"]); + addVariant("group-hocus", [".group:hover &", ".group:focus-visible &"]); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2838c72 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "baseUrl": ".", + "paths": { + "@/components/*": ["components/*"], + "@/utils/*": ["utils/*"], + "@/lib/*": ["lib/*"], + "@/prisma/*": ["prisma/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "pages/api/v2/episode/[id].tsx", + "utils/schedulesUtils.ts", + "pages/middleware.js", + "pages/api/auth/[...nextauth].ts", + "components/anime/mobile/reused/infoChip.tsx" + ], + "exclude": ["node_modules"] +} diff --git a/types/api/AnifyEpisode.ts b/types/api/AnifyEpisode.ts new file mode 100644 index 0000000..93b7e5d --- /dev/null +++ b/types/api/AnifyEpisode.ts @@ -0,0 +1,16 @@ +export interface AnifyEpisode { + providerId: string; + episodes: Episode[]; +} + +export interface Episode { + id: string; + number: number; + title: string; + isFiller: boolean; + img: null; + hasDub: boolean; + description: null; + rating: null; + updatedAt: number; +} diff --git a/types/api/ConsumetInfo.ts b/types/api/ConsumetInfo.ts new file mode 100644 index 0000000..946f64a --- /dev/null +++ b/types/api/ConsumetInfo.ts @@ -0,0 +1,154 @@ +export interface ConsumetInfo { + message: string; + length: number; + id: string; + title: Title; + malId: number; + synonyms: string[]; + isLicensed: boolean; + isAdult: boolean; + countryOfOrigin: string; + trailer: Trailer; + image: string; + popularity: number; + color: string; + cover: string; + description: string; + status: Status; + releaseDate: number; + startDate: EndDateClass; + endDate: EndDateClass; + totalEpisodes: number; + currentEpisode: number; + rating: number; + duration: number; + genres: string[]; + season: string; + studios: string[]; + subOrDub: string; + type: RecommendationType; + recommendations: Ation[]; + characters: Character[]; + relations: Ation[]; + mappings: Mapping[]; + artwork: Artwork[]; + episodes: Episode[]; +} + +export interface Artwork { + img: string; + type: ArtworkType; + providerId: ProviderID; +} + +export enum ProviderID { + Anilist = "anilist", + Mal = "mal", + Tvdb = "tvdb", +} + +export enum ArtworkType { + Banner = "banner", + ClearArt = "clear_art", + ClearLogo = "clear_logo", + Icon = "icon", + Poster = "poster", + TopBanner = "top_banner", +} + +export interface Character { + id: number; + role: Role; + name: Name; + image: string; + voiceActors: VoiceActor[]; +} + +export interface Name { + first: string; + last: null | string; + full: string; + native: null | string; + userPreferred: string; +} + +export enum Role { + Main = "MAIN", + Supporting = "SUPPORTING", +} + +export interface VoiceActor { + id: number; + language: Language; + name: Name; + image: string; +} + +export enum Language { + English = "English", + French = "French", + German = "German", + Japanese = "Japanese", + Portuguese = "Portuguese", + Spanish = "Spanish", +} + +export interface EndDateClass { + year: number; + month: number; + day: number; +} + +export interface Episode { + id: string; + title: string; + description: null; + number: number; + image: string; + airDate: null; +} + +export interface Mapping { + id: string; + providerId: string; + similarity: number; + providerType: string; +} + +export interface Ation { + id: number; + malId: number; + title: Title; + status: Status; + episodes: number | null; + image: string; + cover: string; + rating: number; + type: RecommendationType; + relationType?: string; + color?: string; +} + +export enum Status { + Completed = "Completed", +} + +export interface Title { + romaji: string; + english: string; + native: string; + userPreferred?: string; +} + +export enum RecommendationType { + Manga = "MANGA", + Ona = "ONA", + Tv = "TV", + TvShort = "TV_SHORT", +} + +export interface Trailer { + id: string; + site: string; + thumbnail: string; +} diff --git a/types/api/Episode.ts b/types/api/Episode.ts new file mode 100644 index 0000000..1a7440b --- /dev/null +++ b/types/api/Episode.ts @@ -0,0 +1,19 @@ +export interface EpisodeData { + map?: boolean; + providerId: string; + episodes: Episode[]; +} + +export interface Episode { + id: string; + title: string; + img: string; + number: number; + createdAt?: Date; + description: string; + url?: string; + isFiller?: boolean; + hasDub?: boolean; + rating?: null; + updatedAt?: number; +} diff --git a/types/episodes/AnifyRecentEpisode.ts b/types/episodes/AnifyRecentEpisode.ts new file mode 100644 index 0000000..b4dc466 --- /dev/null +++ b/types/episodes/AnifyRecentEpisode.ts @@ -0,0 +1,91 @@ +export interface AnifyRecentEpisode { + id: string; + slug: string; + coverImage: string; + bannerImage: string; + trailer: string; + status: string; + season: string; + title: Title; + currentEpisode: number; + mappings?: MappingsEntity[] | null; + synonyms?: string[] | null; + countryOfOrigin: string; + description: string; + duration: number; + color: string; + year: number; + rating: RatingOrPopularity; + popularity: RatingOrPopularity; + type: string; + format: string; + relations?: RelationsEntity[] | null; + totalEpisodes: number; + genres?: string[] | null; + tags?: string[] | null; + episodes: Episodes; + averageRating: number; + averagePopularity: number; + artwork?: ArtworkEntity[] | null; + characters?: CharactersEntity[] | null; +} +export interface Title { + native: string; + romaji: string; + english: string; +} +export interface MappingsEntity { + id: string; + providerId: string; + similarity: number; + providerType: string; +} +export interface RatingOrPopularity { + anidb: number; + anilist: number; +} +export interface RelationsEntity { + id: string; + type: string; + title: Title; + format: string; + relationType: string; +} +export interface Episodes { + data?: DataEntity[] | null; + latest: Latest; +} +export interface DataEntity { + episodes?: EpisodesEntity[] | null; + providerId: string; +} +export interface EpisodesEntity { + id: string; + img?: null; + title: string; + hasDub: boolean; + number: number; + rating?: null; + isFiller: boolean; + updatedAt: number; + description?: null; +} +export interface Latest { + updatedAt: number; + latestTitle: string; + latestEpisode: number; +} +export interface ArtworkEntity { + img: string; + type: string; + providerId: string; +} +export interface CharactersEntity { + name: string; + image: string; + voiceActor: VoiceActor; +} +export interface VoiceActor { + name: string; + image: string; +} diff --git a/types/episodes/ConsumetInfo.ts b/types/episodes/ConsumetInfo.ts new file mode 100644 index 0000000..811e90b --- /dev/null +++ b/types/episodes/ConsumetInfo.ts @@ -0,0 +1,126 @@ +// Consumets types +export interface ConsumetInfo { + id: string; + title: Title; + malId: number; + synonyms?: null[] | null; + isLicensed: boolean; + isAdult: boolean; + countryOfOrigin: string; + trailer: Trailer; + image: string; + popularity: number; + color: string; + cover: string; + description: string; + status: string; + releaseDate: number; + startDate: StartDateOrEndDate; + endDate: StartDateOrEndDate; + totalEpisodes: number; + currentEpisode: number; + rating: number; + duration: number; + genres?: string[] | null; + season: string; + studios?: string[] | null; + subOrDub: string; + type: string; + recommendations?: RecommendationsEntity[] | null; + characters?: CharactersEntityConsumet[] | null; + relations?: RelationsEntityConsumet[] | null; + mappings?: MappingsEntity[] | null; + artwork?: ArtworkEntity[] | null; + episodes?: EpisodesEntity[] | null; +} +export interface Trailer { + id: string; + site: string; + thumbnail: string; +} +export interface StartDateOrEndDate { + year: number; + month: number; + day: number; +} +export interface RecommendationsEntity { + id: number; + malId: number; + title: Title1; + status: string; + episodes: number; + image: string; + cover: string; + rating: number; + type: string; +} +export interface Title1 { + romaji: string; + english?: string | null; + native: string; + userPreferred: string; +} +export interface Title { + native: string; + romaji: string; + english: string; +} +export interface CharactersEntityConsumet { + id: number; + role: string; + name: Name; + image: string; + voiceActors?: VoiceActorsEntity[] | null; +} +export interface Name { + first: string; + last?: string | null; + full: string; + native: string; + userPreferred: string; +} +export interface VoiceActorsEntity { + id: number; + language: string; + name: Name1; + image: string; +} +export interface Name1 { + first: string; + last: string; + full: string; + native?: string | null; + userPreferred: string; +} +export interface RelationsEntityConsumet { + id: number; + relationType: string; + malId: number; + title: Title1; + status: string; + episodes?: number | null; + image: string; + color: string; + type: string; + cover: string; + rating: number; +} +export interface MappingsEntity { + id: string; + providerId: string; + similarity: number; + providerType: string; +} +export interface ArtworkEntity { + img: string; + type: string; + providerId: string; +} +export interface EpisodesEntity { + id: string; + title: string; + description?: null; + number: number; + image: string; + airDate?: null; +} diff --git a/types/episodes/Sessions.ts b/types/episodes/Sessions.ts new file mode 100644 index 0000000..cb92fe7 --- /dev/null +++ b/types/episodes/Sessions.ts @@ -0,0 +1,30 @@ +export interface Sessions { + user: User; + expires: string; +} + +export interface User { + name: string; + picture: Picture; + sub: string; + token: string; + id: number; + image: Image; + list: string[]; + version: string; + iat: number; + exp: number; + jti: string; +} + +export interface Picture { + large: string; + medium: string; + __typename: string; +} + +export interface Image { + large: string; + medium: string; + __typename: string; +} diff --git a/types/episodes/TrackData.ts b/types/episodes/TrackData.ts new file mode 100644 index 0000000..143ea23 --- /dev/null +++ b/types/episodes/TrackData.ts @@ -0,0 +1,70 @@ +export type TrackData = { + provider: string; + defaultQuality: DefaultQuality; + subtitles: Subtitle[]; + thumbnails: string; + epiData: EpiData; + skip: Skip; +}; + +export interface DefaultQuality { + url: string; + headers: Headers; +} + +export interface Headers {} + +export interface Subtitle { + src: string; + label: string; + kind: "subtitles" | "captions" | "descriptions" | "chapters" | "metadata"; + default?: boolean; +} + +export interface EpiData { + sources: Source[]; + subtitles: Subtitle2[]; + audio: any[]; + intro: Intro; + outro: Outro; + headers: Headers2; +} + +export interface Source { + url: string; + quality: string; +} + +export interface Subtitle2 { + url: string; + lang: string; +} + +export interface Intro { + start: number; + end: number; +} + +export interface Outro { + start: number; + end: number; +} + +export interface Headers2 {} + +export interface Skip { + op: Op; + ed: any; +} + +export interface Op { + interval: Interval; + skipType: string; + skipId: string; + episodeLength: number; +} + +export interface Interval { + startTime: number; + endTime: number; +} diff --git a/types/index.tsx b/types/index.tsx new file mode 100644 index 0000000..36f6aba --- /dev/null +++ b/types/index.tsx @@ -0,0 +1,17 @@ +import { AnifyEpisode } from "./api/AnifyEpisode"; +import { EpisodeData } from "./api/Episode"; +import { ConsumetInfo as APIConsumetInfo } from "./api/ConsumetInfo"; +import { AnifyRecentEpisode } from "./episodes/AnifyRecentEpisode"; +import { ConsumetInfo } from "./episodes/ConsumetInfo"; +import { Sessions } from "./episodes/Sessions"; +import { TrackData } from "./episodes/TrackData"; + +export type { + AnifyEpisode, + EpisodeData, + APIConsumetInfo, + ConsumetInfo, + AnifyRecentEpisode, + Sessions, + TrackData, +}; diff --git a/types/info/AnifySearchAdvanceTypes.ts b/types/info/AnifySearchAdvanceTypes.ts new file mode 100644 index 0000000..18bc108 --- /dev/null +++ b/types/info/AnifySearchAdvanceTypes.ts @@ -0,0 +1,87 @@ +export type AnifySearchAdvanceTypes = { + total: number; + lastPage: number; + results: Array<{ + id: string; + slug: string; + coverImage: string; + bannerImage: string; + status: string; + title: { + native: string; + romaji: string; + english: string; + }; + duration: number; + mappings: Array<{ + id: string; + providerId: string; + similarity: number; + providerType: string; + }>; + synonyms: Array<string>; + countryOfOrigin: string; + description: string; + color: string; + year: number; + rating: { + comick: number; + anilist: number; + }; + popularity: { + comick: number; + anilist: number; + }; + type: string; + format: string; + relations: Array<{ + id: string; + type: string; + title: { + native: string; + romaji: string; + english: string; + }; + format: string; + relationType: string; + }>; + currentChapter: any; + totalChapters: number; + totalVolumes: number; + genres: Array<string>; + tags: Array<string>; + chapters: { + data: Array<{ + chapters: Array<{ + id: string; + title: string; + number: number; + rating: any; + mixdrop?: string; + updatedAt: number; + }>; + providerId: string; + }>; + latest: { + updatedAt: number; + latestTitle: string; + latestChapter: number; + }; + }; + averageRating: number; + averagePopularity: number; + artwork: Array<{ + img: string; + type: string; + providerId: string; + }>; + characters: Array<{ + name: string; + image: string; + voiceActor: { + name: any; + image: any; + }; + }>; + }>; +}; diff --git a/types/info/AnilistInfoTypes.ts b/types/info/AnilistInfoTypes.ts new file mode 100644 index 0000000..f6cfb6f --- /dev/null +++ b/types/info/AnilistInfoTypes.ts @@ -0,0 +1,138 @@ +export interface AniListInfoTypes { + mediaListEntry: MediaListEntry; + id: number; + type: string; + format: string; + title: Title; + coverImage: CoverImage; + startDate: StartDate; + bannerImage: string; + description: string; + episodes: any; + nextAiringEpisode: any; + averageScore: number; + popularity: number; + status: string; + genres: string[]; + season: any; + studios: Studios; + seasonYear: any; + duration: any; + relations: Relations; + recommendations: Recommendations; + characters: Characters; +} + +interface Studios { + edges: Studio[]; +} + +interface Studio { + isMain: boolean; + node: Node4; +} + +interface Node4 { + id: number; + name: string; +} + +export interface MediaListEntry { + status: string; + progress: number; + progressVolumes: number; +} + +export interface Title { + romaji: string; + english: string; + native: string; +} + +export interface CoverImage { + extraLarge: string; + large: string; + color: string; +} + +export interface StartDate { + year: number; + month: number; +} + +export interface Relations { + edges: Edge[]; +} + +export interface Edge { + id: number; + relationType: string; + node: Node; +} + +export interface Node { + id: number; + title: Title2; + format: string; + type: string; + status: string; + bannerImage?: string; + coverImage: CoverImage2; +} + +export interface Title2 { + userPreferred: string; +} + +export interface CoverImage2 { + extraLarge: string; + color: string; +} + +export interface Recommendations { + nodes: Node2[]; +} + +export interface Node2 { + mediaRecommendation: MediaRecommendation; +} + +export interface MediaRecommendation { + id: number; + title: Title3; + coverImage: CoverImage3; +} + +export interface Title3 { + romaji: string; +} + +export interface CoverImage3 { + extraLarge: string; + large: string; +} + +export interface Characters { + edges: Edge2[]; +} + +export interface Edge2 { + role: string; + node: Node3; +} + +export interface Node3 { + id: number; + image: Image; + name: Name; +} + +export interface Image { + large: string; + medium: string; +} + +export interface Name { + full: string; + userPreferred: string; +} diff --git a/utils/appendMetaToEpisodes.js b/utils/appendMetaToEpisodes.ts index 197788b..5f74df3 100644 --- a/utils/appendMetaToEpisodes.js +++ b/utils/appendMetaToEpisodes.ts @@ -1,8 +1,31 @@ -async function appendMetaToEpisodes(episodesData, images) { +type Image = { + number?: number; + episode?: number; + img: string; + title: string; + description: string; +}; + +type Episode = { + number: number; + img?: string; + title?: string; + description?: string; +}; + +type ProviderEpisodes = { + episodes: Episode[]; +}; + +async function appendMetaToEpisodes( + episodesData: ProviderEpisodes[], + images: Image[] +): Promise<ProviderEpisodes[]> { // Create a dictionary for faster lookup of images based on episode number - const episodeImages = {}; + const episodeImages: { [key: number]: Image } = {}; images.forEach((image) => { - episodeImages[image.number || image.episode] = image; + image.episode && (episodeImages[image.episode] = image); + image.number && (episodeImages[image.number] = image); }); // Iterate through each provider's episodes data diff --git a/utils/combineImages.js b/utils/combineImages.ts index abf34ed..01b7ef3 100644 --- a/utils/combineImages.js +++ b/utils/combineImages.ts @@ -1,6 +1,23 @@ -async function appendImagesToEpisodes(episodesData, images) { +interface Image { + episode: number; + img: string; +} + +interface Episode { + number: number; + img?: string; +} + +interface ProviderEpisodes { + episodes: Episode[]; +} + +async function appendImagesToEpisodes( + episodesData: ProviderEpisodes[], + images: Image[] +) { // Create a dictionary for faster lookup of images based on episode number - const episodeImages = {}; + const episodeImages: { [key: number]: string } = {}; images.forEach((image) => { episodeImages[image.episode] = image.img; }); diff --git a/utils/getFormat.js b/utils/getFormat.ts index 9a2e3e3..7f3eece 100644 --- a/utils/getFormat.js +++ b/utils/getFormat.ts @@ -11,7 +11,7 @@ const data = [ { name: "One Shot", value: "ONE_SHOT" }, ]; -export function getFormat(format) { +export function getFormat(format: string) { const results = data.find((item) => item.value === format); return results?.name; } diff --git a/utils/getGreetings.js b/utils/getGreetings.ts index 1dd2a53..1dd2a53 100644 --- a/utils/getGreetings.js +++ b/utils/getGreetings.ts diff --git a/utils/getRedisWithPrefix.js b/utils/getRedisWithPrefix.ts index b85589b..dacf78e 100644 --- a/utils/getRedisWithPrefix.js +++ b/utils/getRedisWithPrefix.ts @@ -1,6 +1,6 @@ import { redis } from "@/lib/redis"; -export async function getValuesWithPrefix(prefix) { +export async function getValuesWithPrefix(prefix: string) { let cursor = "0"; // Start at the beginning of the keyspace let values = []; @@ -16,7 +16,9 @@ export async function getValuesWithPrefix(prefix) { // Retrieve values for matching keys and add them to the array for (const key of matchingKeys) { const value = await redis.get(key); - values.push(JSON.parse(value)); + if (value !== null) { + values.push(JSON.parse(value)); + } } // Update the cursor for the next iteration @@ -26,7 +28,7 @@ export async function getValuesWithPrefix(prefix) { return values; } -export async function countKeysWithPrefix(prefix) { +export async function countKeysWithPrefix(prefix: string) { let cursor = "0"; // Start at the beginning of the keyspace let count = 0; @@ -67,10 +69,10 @@ export async function getKeysWithNumericKeys() { const allKeys = await redis.keys("*"); // Fetch all keys in Redis const numericKeys = allKeys.filter((key) => /^\d+$/.test(key)); // Filter keys that contain only numbers - const values = []; + const values: any[] = []; for (const key of numericKeys) { - const value = await redis.del(key); + await redis.del(key); } return values; diff --git a/utils/getTimes.js b/utils/getTimes.ts index 95df803..c3fe0ad 100644 --- a/utils/getTimes.js +++ b/utils/getTimes.ts @@ -1,4 +1,4 @@ -export function convertUnixToTime(timestamp) { +export function convertUnixToTime(timestamp: number) { const date = new Date(timestamp); const hours = date.getHours(); const minutes = date.getMinutes(); @@ -34,7 +34,7 @@ export function getCurrentSeason() { } } -export function convertUnixToCountdown(time) { +export function convertUnixToCountdown(time: number) { let date = new Date(time * 1000); let days = date.getDay(); let hours = date.getHours(); @@ -57,7 +57,7 @@ export function convertUnixToCountdown(time) { return countdown.trim(); } -export function convertSecondsToTime(sec) { +export function convertSecondsToTime(sec: number) { let days = Math.floor(sec / (3600 * 24)); let hours = Math.floor((sec % (3600 * 24)) / 3600); let minutes = Math.floor((sec % 3600) / 60); @@ -85,8 +85,8 @@ export function convertSecondsToTime(sec) { } // Function to convert timestamp to AM/PM time format -export const timeStamptoAMPM = (timestamp) => { - const date = new Date(timestamp * 1000); +export const timeStamptoAMPM = (timestamp: number | string) => { + const date = new Date(Number(timestamp) * 1000); const hours = date.getHours(); const minutes = date.getMinutes(); const ampm = hours >= 12 ? "PM" : "AM"; @@ -95,19 +95,18 @@ export const timeStamptoAMPM = (timestamp) => { return `${formattedHours}:${minutes.toString().padStart(2, "0")} ${ampm}`; }; -export const timeStamptoHour = (timestamp) => { - const options = { hour: "numeric", minute: "numeric", hour12: true }; +export const timeStamptoHour = (timestamp: number) => { const currentTime = new Date().getTime() / 1000; const formattedTime = new Date(timestamp * 1000).toLocaleTimeString( undefined, - options + { hour: "numeric", minute: "numeric", hour12: true } ); const status = timestamp <= currentTime ? "aired" : "airing"; return `${status} at ${formattedTime}`; }; -export function unixTimestampToRelativeTime(unixTimestamp) { +export function unixTimestampToRelativeTime(unixTimestamp: number) { const now = Math.floor(Date.now() / 1000); // Current Unix timestamp in seconds let secondsDifference = now - unixTimestamp; @@ -135,9 +134,26 @@ export function unixTimestampToRelativeTime(unixTimestamp) { return "just now"; } -export function unixToSeconds(unixTimestamp) { +export function unixToSeconds(unixTimestamp: number) { const now = Math.floor(Date.now() / 1000); // Current Unix timestamp in seconds const secondsAgo = now - unixTimestamp; return secondsAgo; } + +export function realTimeCountdown(secondsLeft: number): string { + let countdown = ""; + const intervalId = setInterval(() => { + secondsLeft--; + const hours = Math.floor(secondsLeft / 3600); + const minutes = Math.floor((secondsLeft % 3600) / 60); + const seconds = secondsLeft % 60; + countdown = `${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + if (secondsLeft <= 0) { + clearInterval(intervalId); + } + }, 1000); + return countdown; +} diff --git a/utils/imageUtils.js b/utils/imageUtils.ts index 8025d5b..6220134 100644 --- a/utils/imageUtils.js +++ b/utils/imageUtils.ts @@ -1,4 +1,4 @@ -export function getHeaders(providerId) { +export function getHeaders(providerId: string) { switch (providerId) { case "mangahere": return { Referer: "https://mangahere.org" }; @@ -21,12 +21,14 @@ export function getRandomId() { return Math.random().toString(36).substr(2, 9); } -export function truncateImgUrl(url) { +export function truncateImgUrl(url: string | undefined) { + if (!url) return null; + // Find the index of .png if not found find the index of .jpg let index = url?.indexOf(".png") !== -1 ? url?.indexOf(".png") : url?.indexOf(".jpg"); - if (index !== -1) { + if (index && index !== -1) { // If .png or .jpg is found url = url?.slice(0, index + 4); // Slice the string from the start to the index of .png or .jpg plus 4 (the length of .png or .jpg) } else { @@ -36,3 +38,18 @@ export function truncateImgUrl(url) { return url; } + +export function parseImageProxy( + url: string | undefined | null, + providerId: string | undefined +) { + if (!url) return; + + return providerId + ? `https://aoi.moopa.live/utils/image-proxy?url=${truncateImgUrl( + url + )}${`&headers=${encodeURIComponent( + JSON.stringify({ Referer: providerId }) + )}`}` + : url; +} diff --git a/utils/parseMetaData.ts b/utils/parseMetaData.ts new file mode 100644 index 0000000..597c21c --- /dev/null +++ b/utils/parseMetaData.ts @@ -0,0 +1,36 @@ +type Episode = { + id: string; + description: string | null; + hasDub: boolean; + img: string | null; + isFiller: boolean; + number: number; + rating: number | null; + title: string; + updatedAt: number; +}; + +type Provider = { + providerId: string; + data: Episode[]; +}; + +export function getProviderWithMostEpisodesAndImage( + data: Provider[] +): Provider | null { + let maxEpisodesProvider: Provider | null = null; + + for (const provider of data) { + if ( + !maxEpisodesProvider || + provider.data.length > maxEpisodesProvider.data.length + ) { + const hasImage = provider.data.some((episode) => episode.img !== null); + if (hasImage) { + maxEpisodesProvider = provider; + } + } + } + + return maxEpisodesProvider; +} diff --git a/utils/request/index.ts b/utils/request/index.ts new file mode 100644 index 0000000..854ef2b --- /dev/null +++ b/utils/request/index.ts @@ -0,0 +1,111 @@ +import axios, { AxiosRequestConfig } from "axios"; +import { getSession } from "next-auth/react"; +import { toast } from "sonner"; + +function isAnilist(url: string | undefined): boolean { + return url?.includes("anilist.co") ?? false; +} + +interface RequestOption extends RequestInit { + headers?: { + "Content-Type"?: string; + Authorization?: string; + }; +} + +const pls = { + // GET request handler + async get( + url: string, + options?: AxiosRequestConfig, + ctx?: any + ): Promise<any> { + try { + const session: any | null = isAnilist(url) ? await getSession(ctx) : null; + const controller = new AbortController(); + const signal = controller.signal; + + const response = await axios.get(url, { ...options, signal }); + return response.data; + } catch (error: any) { + handleError(error); + // throw error; + } + }, + + // POST request handler + async post(url: string, options: RequestOption, ctx?: any): Promise<any> { + try { + const session: any | null = await getSession(ctx); + const accessToken: string | undefined = session?.user?.token; + + const controller = new AbortController(); + const signal = controller.signal; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(accessToken && + isAnilist(url) && { Authorization: `Bearer ${accessToken}` }), + }, + ...options, + signal, + }); + + const data = await response.json(); + return [data, session]; + } catch (error: any) { + handleError(error); + // throw error; + } + }, +}; + +function handleError(error: { + response: { status: any; data: any }; + message: any; +}) { + console.log(error); + if (error.response) { + const { status, data } = error.response; + switch (status) { + case 400: + toast.error("400 Bad request", { + description: data?.message || error.message, + }); + break; + case 401: + toast.error("401 Unauthorized", { + description: data?.message || error.message, + }); + break; + case 403: + toast.error("403 Forbidden", { + description: data?.message || error.message, + }); + break; + case 404: + toast.error(`Resource not found - 404`, { + description: data?.message || error.message, + }); + break; + case 500: + toast.error("500 Internal server error", { + description: data?.message || error.message, + }); + break; + default: + toast.error("An error occurred", { + description: data?.message || error.message, + }); + break; + } + + if (data && data.message) { + console.error("Error message:", data.message); + } + } +} + +export default pls; diff --git a/utils/schedulesUtils.js b/utils/schedulesUtils.ts index cb8c474..606e3fa 100644 --- a/utils/schedulesUtils.js +++ b/utils/schedulesUtils.ts @@ -1,6 +1,20 @@ -// Function to transform the schedule data into the desired format -export const transformSchedule = (schedule) => { - const formattedSchedule = {}; +interface ScheduleItem { + airingAt: string; + // Add other properties of ScheduleItem if available +} + +interface Schedule { + [day: string]: ScheduleItem[]; +} + +interface FormattedSchedule { + [day: string]: { + [time: string]: ScheduleItem[]; + }; +} + +export const transformSchedule = (schedule: Schedule): FormattedSchedule => { + const formattedSchedule: FormattedSchedule = {}; for (const day of Object.keys(schedule)) { formattedSchedule[day] = {}; @@ -19,8 +33,10 @@ export const transformSchedule = (schedule) => { return formattedSchedule; }; -export const sortScheduleByDay = (schedule) => { - const daysOfWeek = [ +export const sortScheduleByDay = ( + schedule: FormattedSchedule +): FormattedSchedule => { + const daysOfWeek: string[] = [ "Saturday", "Sunday", "Monday", @@ -30,17 +46,14 @@ export const sortScheduleByDay = (schedule) => { "Friday", ]; - // Get the current day of the week (0 = Sunday, 1 = Monday, ...) - const currentDay = new Date().getDay(); + const currentDay: number = new Date().getDay(); - // Reorder days of the week to start with today - const orderedDays = [ + const orderedDays: string[] = [ ...daysOfWeek.slice(currentDay), ...daysOfWeek.slice(0, currentDay), ]; - // Create a new object with sorted days - const sortedSchedule = {}; + const sortedSchedule: FormattedSchedule = {}; orderedDays.forEach((day) => { if (schedule[day]) { sortedSchedule[day] = schedule[day]; @@ -50,34 +63,34 @@ export const sortScheduleByDay = (schedule) => { return sortedSchedule; }; -export const filterScheduleByDay = (sortedSchedule, filterDay) => { +export const filterScheduleByDay = ( + sortedSchedule: FormattedSchedule, + filterDay: string +): FormattedSchedule => { if (filterDay === "All") return sortedSchedule; - // Create a new object to store the filtered schedules - const filteredSchedule = {}; - // Iterate through the keys (days) in sortedSchedule + const filteredSchedule: FormattedSchedule = {}; + for (const day in sortedSchedule) { - // Check if the current day matches the filterDay if (day === filterDay) { - // If it matches, add the schedules for that day to the filteredSchedule object filteredSchedule[day] = sortedSchedule[day]; } } - // Return the filtered schedule return filteredSchedule; }; -export const filterFormattedSchedule = (formattedSchedule, filterDay) => { +export const filterFormattedSchedule = ( + formattedSchedule: FormattedSchedule, + filterDay: string +): FormattedSchedule => { if (filterDay === "All") return formattedSchedule; - // Check if the selected day exists in the formattedSchedule if (formattedSchedule.hasOwnProperty(filterDay)) { return { [filterDay]: formattedSchedule[filterDay], }; } - // If the selected day does not exist, return an empty object return {}; }; |