From d502d2dbc5752c31d5c2432e8154c58a54240920 Mon Sep 17 00:00:00 2001
From: Tejas Panchal
-
-
-
+
+
+ Zenime - Ad free anime streaming platform
+
- About • - Features • - Quick Start • - Development -
+ Zenime is an open-source anime streaming service that uses custom API, built using ReactJS with javascript and Tailwind CSS. It lets you easily find any anime with intuitive search & suggestion feature and stream without any ads. + - +
+
+
+
+
+ Made by itzzzme +🫰
diff --git a/components.json b/components.json new file mode 100644 index 0000000..d16853e --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..238d2e4 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,38 @@ +import js from '@eslint/js' +import globals from 'globals' +import react from 'eslint-plugin-react' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +] diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 348c45a..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [...compat.extends("next/core-web-vitals")]; - -export default eslintConfig; diff --git a/index.html b/index.html new file mode 100644 index 0000000..ea57811 --- /dev/null +++ b/index.html @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +{error}
-The anime you're looking for doesn't exist or was removed.
- - Go Back Home - -- We take the intellectual property rights of others seriously and require that our Users do the same. - The Digital Millennium Copyright Act (DMCA) established a process for addressing claims of copyright infringement. - If you own a copyright or have authority to act on behalf of a copyright owner and want to report a claim that a third party is - infringing that material on or through JustAnime's services, please submit a DMCA report as outlined below, and we will take appropriate action. -
- -- Your DMCA takedown request should be submitted through our Contact page. -
- -- We will then review your DMCA request and take proper actions, including removal of the content from the website. -
-- To submit a DMCA takedown request, please include all required information as listed above and - contact us through our Contact page. -
- -dAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJw b z_^v8bbg` SAn{I*4bH$u(RZ6*x UhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=p C^ S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk( $?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU ^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvh CL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c 70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397* _cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111a H}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*I cmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU &68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-= A= yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v #ix45EVrcEhr>!NMhprl $InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~ &^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7< 4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}sc Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+ 9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2 `1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M =hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S( O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m diff --git a/src/app/globals.css b/src/app/globals.css deleted file mode 100644 index 9413505..0000000 --- a/src/app/globals.css +++ /dev/null @@ -1,173 +0,0 @@ -@import "tailwindcss"; - -:root { - --background: #0a0a0a; - --foreground: #ffffff; - --primary: #d1d1d1; - --secondary: #404040; - --accent: #808080; - --card: #131313; - --border: #2a2a2a; - --hover: #333333; - --text-muted: #8a8a8a; -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-primary: var(--primary); - --color-secondary: var(--secondary); - --color-accent: var(--accent); - --color-card: var(--card); - --color-border: var(--border); - --color-hover: var(--hover); - --color-text-muted: var(--text-muted); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #f5f5f5; - } -} - -body { - margin: 0; - padding: 0; - min-height: 100vh; - background-color: var(--background); - color: var(--foreground); - font-family: var(--font-sans); -} - -/* Custom scrollbar */ -::-webkit-scrollbar { - width: 8px; -} - -::-webkit-scrollbar-track { - background: var(--background); -} - -::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--secondary); -} - -/* Hide scrollbar for Chrome, Safari and Opera */ -.scrollbar-hide::-webkit-scrollbar { - display: none; -} - -/* Hide scrollbar for IE, Edge and Firefox */ -.scrollbar-hide { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} - -.custom-scrollbar::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -.custom-scrollbar::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; -} - -.custom-scrollbar::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 8px; -} - -.custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.25); -} - -/* Modern card styling */ -.card-hover { - transition: transform 0.3s ease, box-shadow 0.3s ease; -} - -.card-hover:hover { - transform: translateY(-4px); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2); -} - -/* Gradient text */ -.gradient-text { - background: linear-gradient(to right, var(--foreground), var(--text-muted)); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; -} - -/* Button styles */ -.btn-primary { - background-color: var(--foreground); - color: var(--background); - transition: all 0.2s ease; -} - -.btn-primary:hover { - background-color: var(--text-muted); -} - -.btn-secondary { - background-color: transparent; - border: 1px solid var(--border); - color: var(--foreground); - transition: all 0.2s ease; -} - -.btn-secondary:hover { - background-color: var(--hover); -} - -/* Animation utilities */ -.fade-in { - animation: fadeIn 0.5s ease-in forwards; -} - -@keyframes fadeIn { - 0% { opacity: 0; } - 100% { opacity: 1; } -} - -.slide-up { - animation: slideUp 0.5s ease forwards; -} - -@keyframes slideUp { - 0% { transform: translateY(20px); opacity: 0; } - 100% { transform: translateY(0); opacity: 1; } -} - -/* Radial gradient for hero sections */ -.bg-radial-gradient { - background: radial-gradient(circle at center, rgba(20, 20, 20, 0) 0%, rgba(0, 0, 0, 0.8) 100%); -} - -/* Grid Pattern for backgrounds */ -.bg-grid-pattern { - background-size: 25px 25px; - background-image: - linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px), - linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px); -} - -/* Hide scrollbar while maintaining scroll functionality */ -.hide-scrollbar { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ -} - -.hide-scrollbar::-webkit-scrollbar { - display: none; /* Chrome, Safari and Opera */ -} diff --git a/src/app/home/layout.js b/src/app/home/layout.js deleted file mode 100644 index 48bdb93..0000000 --- a/src/app/home/layout.js +++ /dev/null @@ -1,5 +0,0 @@ -import SharedLayout from '@/components/SharedLayout'; - -export default function HomeLayout({ children }) { - return {children} ; -} \ No newline at end of file diff --git a/src/app/home/page.js b/src/app/home/page.js deleted file mode 100644 index d263a5b..0000000 --- a/src/app/home/page.js +++ /dev/null @@ -1,202 +0,0 @@ -import React from 'react'; -import AnimeCard from '@/components/AnimeCard'; -import TopLists from '@/components/TopLists'; -import AnimeCalendar from '@/components/AnimeCalendar'; -import GenreBar from '@/components/GenreBar'; -import SpotlightCarousel from '@/components/SpotlightCarousel'; -import AnimeTabs from '@/components/AnimeTabs'; -import TrendingList from '@/components/TrendingList'; -import Link from 'next/link'; -import { - fetchRecentEpisodes, - fetchMostFavorite, - fetchSpotlightAnime, - fetchTopToday, - fetchTopWeek, - fetchTopMonth, - fetchMostPopular, - fetchTopAiring, - fetchLatestCompleted, - fetchTrending -} from '@/lib/api'; - -// New unified section component with grid layout -const AnimeGridSection = ({ title, animeList = [], viewMoreLink, isRecent = false }) => { - if (!animeList || animeList.length === 0) { - return ( --- ); - } - - return ( ---{title}
---- - ---- ); -}; - -async function HomePage() { - try { - console.log('[HomePage] Fetching home page data'); - - // Fetch all data in parallel - const [ - spotlightData, - recentEpisodes, - mostFavorite, - topToday, - topWeek, - topMonth, - topAiring, - popular, - latestCompleted, - trending - ] = await Promise.all([ - fetchSpotlightAnime().catch(err => { - console.error("[HomePage] Error fetching spotlight anime:", err.message); - return []; - }), - fetchRecentEpisodes().catch(err => { - console.error("[HomePage] Error fetching recent episodes:", err.message); - return { results: [] }; - }), - fetchMostFavorite().catch(err => { - console.error("[HomePage] Error fetching most favorite:", err.message); - return { results: [] }; - }), - fetchTopToday().catch(err => { - console.error("[HomePage] Error fetching top today:", err.message); - return []; - }), - fetchTopWeek().catch(err => { - console.error("[HomePage] Error fetching top week:", err.message); - return []; - }), - fetchTopMonth().catch(err => { - console.error("[HomePage] Error fetching top month:", err.message); - return []; - }), - fetchTopAiring().catch(err => { - console.error("[HomePage] Error fetching top airing anime:", err.message); - return { results: [] }; - }), - fetchMostPopular().catch(err => { - console.error("[HomePage] Error fetching popular anime:", err.message); - return { results: [] }; - }), - fetchLatestCompleted().catch(err => { - console.error("[HomePage] Error fetching latest completed anime:", err.message); - return { results: [] }; - }), - fetchTrending().catch(err => { - console.error("[HomePage] Error fetching trending anime:", err.message); - return { results: [] }; - }) - ]); - - console.log('[HomePage] Data fetched successfully'); - - return ( --- -{title}
- {viewMoreLink && ( - - View All - - - )} -- {animeList.slice(0, 12).map((anime, index) => ( --- ))} - -- ); - } catch (error) { - console.error('[HomePage] Error in HomePage:', error.message); - return ( -- {/* Spotlight Carousel */} --- - {/* Genre Bar */} - -- - {/* Main Content + Sidebar Layout */} -- - {/* Main Content - 2/3 width on large screens */} --- {/* Latest Episodes Grid */} -- - {/* Sidebar - 1/4 width on large screens */} -- - {/* Anime Tabs Section */} - - - {/* Trending List */} --- - {/* Calendar Widget */} - - - {/* Top Lists */} - - -- ); - } -} - -export default HomePage; \ No newline at end of file diff --git a/src/app/latest-completed/layout.js b/src/app/latest-completed/layout.js deleted file mode 100644 index 37cb70c..0000000 --- a/src/app/latest-completed/layout.js +++ /dev/null @@ -1,5 +0,0 @@ -import SharedLayout from '@/components/SharedLayout'; - -export default function LatestCompletedLayout({ children }) { - return--Unable to load content
-There was an error loading the home page content. Please try refreshing the page.
- -{children} ; -} \ No newline at end of file diff --git a/src/app/latest-completed/page.js b/src/app/latest-completed/page.js deleted file mode 100644 index 8f97593..0000000 --- a/src/app/latest-completed/page.js +++ /dev/null @@ -1,305 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import AnimeCard from '@/components/AnimeCard'; -import AnimeFilters from '@/components/AnimeFilters'; -import { fetchLatestCompleted } from '@/lib/api'; - -export default function LatestCompletedPage() { - const [animeList, setAnimeList] = useState([]); - const [filteredList, setFilteredList] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentPage, setCurrentPage] = useState(1); - const [hasNextPage, setHasNextPage] = useState(false); - const [selectedGenre, setSelectedGenre] = useState(null); - const [yearFilter, setYearFilter] = useState('all'); - const [sortOrder, setSortOrder] = useState('default'); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedSeasons, setSelectedSeasons] = useState([]); - const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedStatus, setSelectedStatus] = useState([]); - const [selectedLanguages, setSelectedLanguages] = useState([]); - const [error, setError] = useState(null); - - // Current year for filtering - const currentYear = new Date().getFullYear(); - - // Add ref to track if this is the first render - const initialRender = useRef(true); - - useEffect(() => { - const fetchData = async () => { - setIsLoading(true); - try { - const data = await fetchLatestCompleted(currentPage); - - if (currentPage === 1) { - setAnimeList(data.results || []); - } else { - setAnimeList(prev => [...prev, ...(data.results || [])]); - } - - setHasNextPage(data.hasNextPage || false); - } catch (error) { - console.error('Error fetching latest completed anime:', error); - setError('Failed to load anime. Please try again later.'); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, [currentPage]); - - // Apply filters and sorting whenever the anime list or filter settings change - useEffect(() => { - // Skip the initial render effect to avoid duplicate filtering - if (initialRender.current) { - initialRender.current = false; - return; - } - - if (!animeList.length) { - setFilteredList([]); - return; - } - - let result = [...animeList]; - - // Search filter - if (searchQuery && searchQuery.trim() !== '') { - const query = searchQuery.toLowerCase().trim(); - result = result.filter(anime => { - const title = (anime.title || '').toLowerCase(); - const otherNames = (anime.otherNames || '').toLowerCase(); - return title.includes(query) || otherNames.includes(query); - }); - } - - // Filter by genre if selected - if (selectedGenre) { - result = result.filter(anime => { - if (anime.genres && Array.isArray(anime.genres)) { - return anime.genres.some(g => - g.toLowerCase() === selectedGenre.toLowerCase() || - (g.name && g.name.toLowerCase() === selectedGenre.toLowerCase()) - ); - } else if (anime.genre) { - return anime.genre.toLowerCase().includes(selectedGenre.toLowerCase()); - } - return false; - }); - } - - // Filter by season - if (selectedSeasons.length > 0) { - result = result.filter(anime => { - const season = getAnimeSeason(anime); - return selectedSeasons.includes(season); - }); - } - - // Filter by year - if (yearFilter !== 'all') { - result = result.filter(anime => { - const animeYear = parseInt(anime.year) || 0; - if (yearFilter === 'older') { - return animeYear < 2000; - } else { - return animeYear.toString() === yearFilter; - } - }); - } - - // Filter by type - if (selectedTypes.length > 0) { - result = result.filter(anime => - selectedTypes.includes(anime.type) - ); - } - - // Filter by status - if (selectedStatus.length > 0) { - result = result.filter(anime => { - const status = anime.status || getDefaultStatus(anime); - return selectedStatus.includes(status); - }); - } - - // Filter by language - if (selectedLanguages.length > 0) { - result = result.filter(anime => { - const language = anime.language || getDefaultLanguage(anime); - return selectedLanguages.includes(language); - }); - } - - // Apply sorting - switch (sortOrder) { - case 'title-asc': - result.sort((a, b) => (a.title || '').localeCompare(b.title || '')); - break; - case 'title-desc': - result.sort((a, b) => (b.title || '').localeCompare(a.title || '')); - break; - case 'year-desc': - result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0)); - break; - case 'year-asc': - result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0)); - break; - default: - // Default order from API - break; - } - - setFilteredList(result); - }, [animeList, selectedGenre, yearFilter, sortOrder, searchQuery, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]); - - const handleLoadMore = () => { - setCurrentPage(prev => prev + 1); - }; - - const handleGenreChange = (genre) => { - setSelectedGenre(genre); - }; - - const handleYearChange = (year) => { - setYearFilter(year); - }; - - const handleSortChange = (order) => { - setSortOrder(order); - }; - - const handleSearchChange = (value) => { - setSearchQuery(value); - }; - - const handleSeasonChange = (seasons) => { - setSelectedSeasons(seasons); - }; - - const handleTypeChange = (types) => { - setSelectedTypes(types); - }; - - const handleStatusChange = (status) => { - setSelectedStatus(status); - }; - - const handleLanguageChange = (languages) => { - setSelectedLanguages(languages); - }; - - // Helper function to determine anime season based on available data - const getAnimeSeason = (anime) => { - if (anime.season) return anime.season; - - const seasons = ['Winter', 'Spring', 'Summer', 'Fall']; - // Use hash of ID to assign consistent season for demo purposes - const hash = anime.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - return seasons[hash % 4]; - }; - - // Helper function to determine anime status - const getDefaultStatus = (anime) => { - if (anime.status) return anime.status; - - // Default logic - you may need to customize this based on your actual data - const currentYear = new Date().getFullYear(); - if (anime.year > currentYear) return 'Upcoming'; - if (anime.totalEpisodes && anime.episodes && anime.episodes >= anime.totalEpisodes) return 'Completed'; - return 'Ongoing'; - }; - - // Helper function to determine anime language - const getDefaultLanguage = (anime) => { - if (anime.language) return anime.language; - - // Default to "Subbed" for all anime unless specifically marked - return anime.isDub ? 'Dubbed' : 'Subbed'; - }; - - return ( --- ); -} \ No newline at end of file diff --git a/src/app/layout.js b/src/app/layout.js deleted file mode 100644 index d25115b..0000000 --- a/src/app/layout.js +++ /dev/null @@ -1,34 +0,0 @@ -import { Geist, Geist_Mono } from "next/font/google"; -import { Analytics } from "@vercel/analytics/next"; -import { SpeedInsights } from "@vercel/speed-insights/next"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata = { - title: "JustAnime - Watch Anime Online", - description: "Watch the latest anime episodes for free. Stream all your favorite anime shows in HD quality.", - keywords: "anime, streaming, watch anime, free anime, anime online, just , justanime", -}; - -export default function RootLayout({ children }) { - return ( - - -Latest Completed Anime
- - {/* Filters */} --- - {isLoading && animeList.length === 0 ? ( -- - {[...Array(14)].map((_, index) => ( -- ) : (filteredList.length > 0 || animeList.length > 0) ? ( - <> -- -- ))} -- - --- {(filteredList.length > 0 ? filteredList : animeList).map((anime) => ( -- - {hasNextPage && ( -- ))} - - -- )} - > - ) : ( --- )} -No anime found
-- We couldn't find any anime matching your criteria. Please try different filters. -
-- {children} - -- - - - ); -} diff --git a/src/app/most-popular/layout.js b/src/app/most-popular/layout.js deleted file mode 100644 index 7daa4ab..0000000 --- a/src/app/most-popular/layout.js +++ /dev/null @@ -1,5 +0,0 @@ -import SharedLayout from '@/components/SharedLayout'; - -export default function MostPopularLayout({ children }) { - return {children} ; -} \ No newline at end of file diff --git a/src/app/most-popular/page.js b/src/app/most-popular/page.js deleted file mode 100644 index d546f9d..0000000 --- a/src/app/most-popular/page.js +++ /dev/null @@ -1,305 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import AnimeCard from '@/components/AnimeCard'; -import AnimeFilters from '@/components/AnimeFilters'; -import { fetchMostPopular } from '@/lib/api'; - -export default function MostPopularPage() { - const [animeList, setAnimeList] = useState([]); - const [filteredList, setFilteredList] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentPage, setCurrentPage] = useState(1); - const [hasNextPage, setHasNextPage] = useState(false); - const [selectedGenre, setSelectedGenre] = useState(null); - const [yearFilter, setYearFilter] = useState('all'); - const [sortOrder, setSortOrder] = useState('default'); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedSeasons, setSelectedSeasons] = useState([]); - const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedStatus, setSelectedStatus] = useState([]); - const [selectedLanguages, setSelectedLanguages] = useState([]); - const [error, setError] = useState(null); - - // Current year for filtering - const currentYear = new Date().getFullYear(); - - // Add ref to track if this is the first render - const initialRender = useRef(true); - - useEffect(() => { - const fetchData = async () => { - setIsLoading(true); - try { - const data = await fetchMostPopular(currentPage); - - if (currentPage === 1) { - setAnimeList(data.results || []); - } else { - setAnimeList(prev => [...prev, ...(data.results || [])]); - } - - setHasNextPage(data.hasNextPage || false); - } catch (error) { - console.error('Error fetching most popular anime:', error); - setError('Failed to load anime. Please try again later.'); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, [currentPage]); - - // Apply filters and sorting whenever the anime list or filter settings change - useEffect(() => { - // Skip the initial render effect to avoid duplicate filtering - if (initialRender.current) { - initialRender.current = false; - return; - } - - if (!animeList.length) { - setFilteredList([]); - return; - } - - let result = [...animeList]; - - // Search filter - if (searchQuery && searchQuery.trim() !== '') { - const query = searchQuery.toLowerCase().trim(); - result = result.filter(anime => { - const title = (anime.title || '').toLowerCase(); - const otherNames = (anime.otherNames || '').toLowerCase(); - return title.includes(query) || otherNames.includes(query); - }); - } - - // Filter by genre if selected - if (selectedGenre) { - result = result.filter(anime => { - if (anime.genres && Array.isArray(anime.genres)) { - return anime.genres.some(g => - g.toLowerCase() === selectedGenre.toLowerCase() || - (g.name && g.name.toLowerCase() === selectedGenre.toLowerCase()) - ); - } else if (anime.genre) { - return anime.genre.toLowerCase().includes(selectedGenre.toLowerCase()); - } - return false; - }); - } - - // Filter by season - if (selectedSeasons.length > 0) { - result = result.filter(anime => { - const season = getAnimeSeason(anime); - return selectedSeasons.includes(season); - }); - } - - // Filter by year - if (yearFilter !== 'all') { - result = result.filter(anime => { - const animeYear = parseInt(anime.year) || 0; - if (yearFilter === 'older') { - return animeYear < 2000; - } else { - return animeYear.toString() === yearFilter; - } - }); - } - - // Filter by type - if (selectedTypes.length > 0) { - result = result.filter(anime => - selectedTypes.includes(anime.type) - ); - } - - // Filter by status - if (selectedStatus.length > 0) { - result = result.filter(anime => { - const status = anime.status || getDefaultStatus(anime); - return selectedStatus.includes(status); - }); - } - - // Filter by language - if (selectedLanguages.length > 0) { - result = result.filter(anime => { - const language = anime.language || getDefaultLanguage(anime); - return selectedLanguages.includes(language); - }); - } - - // Apply sorting - switch (sortOrder) { - case 'title-asc': - result.sort((a, b) => (a.title || '').localeCompare(b.title || '')); - break; - case 'title-desc': - result.sort((a, b) => (b.title || '').localeCompare(a.title || '')); - break; - case 'year-desc': - result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0)); - break; - case 'year-asc': - result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0)); - break; - default: - // Default order from API - break; - } - - setFilteredList(result); - }, [animeList, selectedGenre, yearFilter, sortOrder, searchQuery, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]); - - const handleLoadMore = () => { - setCurrentPage(prev => prev + 1); - }; - - const handleGenreChange = (genre) => { - setSelectedGenre(genre); - }; - - const handleYearChange = (year) => { - setYearFilter(year); - }; - - const handleSortChange = (order) => { - setSortOrder(order); - }; - - const handleSearchChange = (value) => { - setSearchQuery(value); - }; - - const handleSeasonChange = (seasons) => { - setSelectedSeasons(seasons); - }; - - const handleTypeChange = (types) => { - setSelectedTypes(types); - }; - - const handleStatusChange = (status) => { - setSelectedStatus(status); - }; - - const handleLanguageChange = (languages) => { - setSelectedLanguages(languages); - }; - - // Helper function to determine anime season based on available data - const getAnimeSeason = (anime) => { - if (anime.season) return anime.season; - - const seasons = ['Winter', 'Spring', 'Summer', 'Fall']; - // Use hash of ID to assign consistent season for demo purposes - const hash = anime.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - return seasons[hash % 4]; - }; - - // Helper function to determine anime status - const getDefaultStatus = (anime) => { - if (anime.status) return anime.status; - - // Default logic - you may need to customize this based on your actual data - const currentYear = new Date().getFullYear(); - if (anime.year > currentYear) return 'Upcoming'; - if (anime.totalEpisodes && anime.episodes && anime.episodes >= anime.totalEpisodes) return 'Completed'; - return 'Ongoing'; - }; - - // Helper function to determine anime language - const getDefaultLanguage = (anime) => { - if (anime.language) return anime.language; - - // Default to "Subbed" for all anime unless specifically marked - return anime.isDub ? 'Dubbed' : 'Subbed'; - }; - - return ( --- ); -} \ No newline at end of file diff --git a/src/app/page.jsx b/src/app/page.jsx deleted file mode 100644 index bf8e703..0000000 --- a/src/app/page.jsx +++ /dev/null @@ -1,321 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useState, useEffect, useRef } from 'react'; -import { useRouter } from 'next/navigation'; -import { fetchSearchSuggestions } from '@/lib/api'; -import Image from 'next/image'; - -export default function LandingPage() { - const [searchQuery, setSearchQuery] = useState(''); - const [searchSuggestions, setSearchSuggestions] = useState([]); - const [showSuggestions, setShowSuggestions] = useState(false); - const router = useRouter(); - const suggestionRef = useRef(null); - const searchInputRef = useRef(null); - - // For FAQ dropdowns - const [openFAQ, setOpenFAQ] = useState(null); - - const toggleFAQ = (index) => { - setOpenFAQ(openFAQ === index ? null : index); - }; - - // Fetch search suggestions when search query changes - useEffect(() => { - const fetchSuggestions = async () => { - if (searchQuery.trim().length > 2) { - try { - const suggestions = await fetchSearchSuggestions(searchQuery); - // Update to use the same format as home page search - setSearchSuggestions(suggestions || []); - setShowSuggestions(true); - } catch (error) { - console.error('Error fetching search suggestions:', error); - setSearchSuggestions([]); - } - } else { - setSearchSuggestions([]); - setShowSuggestions(false); - } - }; - - const debounceTimer = setTimeout(() => { - fetchSuggestions(); - }, 300); - - return () => clearTimeout(debounceTimer); - }, [searchQuery]); - - // Close suggestions when clicking outside - useEffect(() => { - const handleClickOutside = (event) => { - if ( - suggestionRef.current && - !suggestionRef.current.contains(event.target) && - !searchInputRef.current?.contains(event.target) - ) { - setShowSuggestions(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - const handleSearch = (e) => { - e.preventDefault(); - if (searchQuery.trim()) { - router.push(`/search?q=${encodeURIComponent(searchQuery)}`); - setSearchQuery(''); - setShowSuggestions(false); - } - }; - - const handleSuggestionClick = (suggestion) => { - // Updated to handle object-based suggestions - const query = suggestion.title || suggestion; - router.push(`/search?q=${encodeURIComponent(query)}`); - setSearchQuery(''); - setShowSuggestions(false); - }; - - return ( -Most Popular Anime
- - {/* Filters */} --- - {isLoading && animeList.length === 0 ? ( -- - {[...Array(14)].map((_, index) => ( -- ) : (filteredList.length > 0 || animeList.length > 0) ? ( - <> -- -- ))} -- - --- {(filteredList.length > 0 ? filteredList : animeList).map((anime) => ( -- - {hasNextPage && ( -- ))} - - -- )} - > - ) : ( --- )} -No anime found
-- We couldn't find any anime matching your criteria. Please try different filters. -
-- {/* Background Image with Fade Effect */} -- ); -} \ No newline at end of file diff --git a/src/app/recent/layout.js b/src/app/recent/layout.js deleted file mode 100644 index 14e5824..0000000 --- a/src/app/recent/layout.js +++ /dev/null @@ -1,5 +0,0 @@ -import SharedLayout from '@/components/SharedLayout'; - -export default function RecentEpisodesLayout({ children }) { - return-- - {/* Unified Content Section */} -- {/* Ultra-smooth gradient for fade from bottom */} - --- {/* Hero Content */} - -- {/* Logo */} -- - {/* FAQ Content */} --- - {/* Search Bar */} -- - - - {/* Search Suggestions Dropdown */} - {showSuggestions && searchSuggestions.length > 0 && ( -- - {/* Enter Homepage Button */} - - Enter Homepage → - -- {searchSuggestions.map((suggestion, index) => ( -- )} -handleSuggestionClick(suggestion)} - > - {suggestion.image && ( -- ))} --- )} -- -- {suggestion.type && ( - - {suggestion.type} - - )} -{suggestion.title || suggestion}
- {suggestion.jname && ( -{suggestion.jname}
- )} ---Frequently Asked Questions
- -- {/* FAQ Item 1 */} --- -- - {/* FAQ Item 2 */} -----Yes. We started this site to improve UX and are committed to keeping our users safe. We encourage all our users to notify us if anything looks suspicious.
-- -- - {/* FAQ Item 3 */} -----JustAnime offers the best user experience for anime streaming with fast loading speeds, a beautiful interface, no intrusive ads, large content library, HD quality, and weekly updates. Our clean design and extensive features set us apart from other platforms.
-- ------You can request anime by visiting our Discord community or using the contact form. Our team aims to fulfill requests quickly based on availability.
-{children} ; -} \ No newline at end of file diff --git a/src/app/recent/page.js b/src/app/recent/page.js deleted file mode 100644 index 7e6dec4..0000000 --- a/src/app/recent/page.js +++ /dev/null @@ -1,305 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import AnimeCard from '@/components/AnimeCard'; -import AnimeFilters from '@/components/AnimeFilters'; -import { fetchRecentEpisodes } from '@/lib/api'; - -export default function RecentEpisodesPage() { - const [animeList, setAnimeList] = useState([]); - const [filteredList, setFilteredList] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentPage, setCurrentPage] = useState(1); - const [hasNextPage, setHasNextPage] = useState(false); - const [selectedGenre, setSelectedGenre] = useState(null); - const [yearFilter, setYearFilter] = useState('all'); - const [sortOrder, setSortOrder] = useState('default'); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedSeasons, setSelectedSeasons] = useState([]); - const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedStatus, setSelectedStatus] = useState([]); - const [selectedLanguages, setSelectedLanguages] = useState([]); - const [error, setError] = useState(null); - - // Current year for filtering - const currentYear = new Date().getFullYear(); - - // Add ref to track if this is the first render - const initialRender = useRef(true); - - useEffect(() => { - const fetchData = async () => { - setIsLoading(true); - try { - const data = await fetchRecentEpisodes(currentPage); - - if (currentPage === 1) { - setAnimeList(data.results || []); - } else { - setAnimeList(prev => [...prev, ...(data.results || [])]); - } - - setHasNextPage(data.hasNextPage || false); - } catch (error) { - console.error('Error fetching recent episodes:', error); - setError('Failed to load anime. Please try again later.'); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, [currentPage]); - - // Apply filters and sorting whenever the anime list or filter settings change - useEffect(() => { - // Skip the initial render effect to avoid duplicate filtering - if (initialRender.current) { - initialRender.current = false; - return; - } - - if (!animeList.length) { - setFilteredList([]); - return; - } - - let result = [...animeList]; - - // Search filter - if (searchQuery && searchQuery.trim() !== '') { - const query = searchQuery.toLowerCase().trim(); - result = result.filter(anime => { - const title = (anime.title || '').toLowerCase(); - const otherNames = (anime.otherNames || '').toLowerCase(); - return title.includes(query) || otherNames.includes(query); - }); - } - - // Filter by genre if selected - if (selectedGenre) { - result = result.filter(anime => { - if (anime.genres && Array.isArray(anime.genres)) { - return anime.genres.some(g => - g.toLowerCase() === selectedGenre.toLowerCase() || - (g.name && g.name.toLowerCase() === selectedGenre.toLowerCase()) - ); - } else if (anime.genre) { - return anime.genre.toLowerCase().includes(selectedGenre.toLowerCase()); - } - return false; - }); - } - - // Filter by season - if (selectedSeasons.length > 0) { - result = result.filter(anime => { - const season = getAnimeSeason(anime); - return selectedSeasons.includes(season); - }); - } - - // Filter by year - if (yearFilter !== 'all') { - result = result.filter(anime => { - const animeYear = parseInt(anime.year) || 0; - if (yearFilter === 'older') { - return animeYear < 2000; - } else { - return animeYear.toString() === yearFilter; - } - }); - } - - // Filter by type - if (selectedTypes.length > 0) { - result = result.filter(anime => - selectedTypes.includes(anime.type) - ); - } - - // Filter by status - if (selectedStatus.length > 0) { - result = result.filter(anime => { - const status = anime.status || getDefaultStatus(anime); - return selectedStatus.includes(status); - }); - } - - // Filter by language - if (selectedLanguages.length > 0) { - result = result.filter(anime => { - const language = anime.language || getDefaultLanguage(anime); - return selectedLanguages.includes(language); - }); - } - - // Apply sorting - switch (sortOrder) { - case 'title-asc': - result.sort((a, b) => (a.title || '').localeCompare(b.title || '')); - break; - case 'title-desc': - result.sort((a, b) => (b.title || '').localeCompare(a.title || '')); - break; - case 'year-desc': - result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0)); - break; - case 'year-asc': - result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0)); - break; - default: - // Default order from API - break; - } - - setFilteredList(result); - }, [animeList, selectedGenre, yearFilter, sortOrder, searchQuery, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]); - - const handleLoadMore = () => { - setCurrentPage(prev => prev + 1); - }; - - const handleGenreChange = (genre) => { - setSelectedGenre(genre); - }; - - const handleYearChange = (year) => { - setYearFilter(year); - }; - - const handleSortChange = (order) => { - setSortOrder(order); - }; - - const handleSearchChange = (value) => { - setSearchQuery(value); - }; - - const handleSeasonChange = (seasons) => { - setSelectedSeasons(seasons); - }; - - const handleTypeChange = (types) => { - setSelectedTypes(types); - }; - - const handleStatusChange = (status) => { - setSelectedStatus(status); - }; - - const handleLanguageChange = (languages) => { - setSelectedLanguages(languages); - }; - - // Helper function to determine anime season based on available data - const getAnimeSeason = (anime) => { - if (anime.season) return anime.season; - - const seasons = ['Winter', 'Spring', 'Summer', 'Fall']; - // Use hash of ID to assign consistent season for demo purposes - const hash = anime.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - return seasons[hash % 4]; - }; - - // Helper function to determine anime status - const getDefaultStatus = (anime) => { - if (anime.status) return anime.status; - - // Default logic - you may need to customize this based on your actual data - const currentYear = new Date().getFullYear(); - if (anime.year > currentYear) return 'Upcoming'; - if (anime.totalEpisodes && anime.episodes && anime.episodes >= anime.totalEpisodes) return 'Completed'; - return 'Ongoing'; - }; - - // Helper function to determine anime language - const getDefaultLanguage = (anime) => { - if (anime.language) return anime.language; - - // Default to "Subbed" for all anime unless specifically marked - return anime.isDub ? 'Dubbed' : 'Subbed'; - }; - - return ( --- ); -} \ No newline at end of file diff --git a/src/app/search/[query]/page.js b/src/app/search/[query]/page.js deleted file mode 100644 index 7c147cc..0000000 --- a/src/app/search/[query]/page.js +++ /dev/null @@ -1,311 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import AnimeCard from '@/components/AnimeCard'; -import AnimeFilters from '@/components/AnimeFilters'; -import { searchAnime, fetchMostPopular } from '@/lib/api'; - -export default function SearchPage() { - const router = useRouter(); - const { query } = useParams(); - const decodedQuery = query ? decodeURIComponent(query) : ''; - - const [searchResults, setSearchResults] = useState([]); - const [filteredResults, setFilteredResults] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentPage, setCurrentPage] = useState(1); - const [hasNextPage, setHasNextPage] = useState(false); - const [isEmptySearch, setIsEmptySearch] = useState(false); - - // Filter states - const [selectedGenre, setSelectedGenre] = useState(null); - const [yearFilter, setYearFilter] = useState('all'); - const [sortOrder, setSortOrder] = useState('default'); - const [selectedSeasons, setSelectedSeasons] = useState([]); - const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedStatus, setSelectedStatus] = useState([]); - const [selectedLanguages, setSelectedLanguages] = useState([]); - const [error, setError] = useState(null); - - // Create filters object for API request - const getFiltersForApi = useCallback(() => { - const filters = {}; - - if (selectedGenre) filters.genre = selectedGenre; - if (yearFilter !== 'all') filters.year = yearFilter; - if (sortOrder !== 'default') filters.sort = sortOrder; - - // Only add these filters if API supports them - // Currently, these may need to be handled client-side - // if (selectedSeasons.length > 0) filters.seasons = selectedSeasons; - // if (selectedTypes.length > 0) filters.types = selectedTypes; - // if (selectedStatus.length > 0) filters.status = selectedStatus; - // if (selectedLanguages.length > 0) filters.languages = selectedLanguages; - - return filters; - }, [selectedGenre, yearFilter, sortOrder]); - - // Apply client-side filters - const applyClientSideFilters = useCallback((animeList) => { - if (!animeList.length) return []; - - let result = [...animeList]; - - // Apply season filter if selected - if (selectedSeasons.length > 0) { - result = result.filter(anime => { - if (!anime.season) return false; - - const animeSeason = typeof anime.season === 'string' - ? anime.season - : anime.season?.name || ''; - - return selectedSeasons.some(season => - animeSeason.toLowerCase().includes(season.toLowerCase()) - ); - }); - } - - // Apply type filter if selected - if (selectedTypes.length > 0) { - result = result.filter(anime => { - if (!anime.type) return false; - - return selectedTypes.some(type => - anime.type.toLowerCase() === type.toLowerCase() - ); - }); - } - - // Apply status filter if selected - if (selectedStatus.length > 0) { - result = result.filter(anime => { - if (!anime.status) return false; - - return selectedStatus.some(status => - anime.status.toLowerCase().includes(status.toLowerCase()) - ); - }); - } - - // Apply language filter if selected - if (selectedLanguages.length > 0) { - result = result.filter(anime => { - // If no language info, assume subbed (most common) - const animeLanguage = anime.language || 'Subbed'; - - return selectedLanguages.some(language => - animeLanguage.toLowerCase().includes(language.toLowerCase()) - ); - }); - } - - // Apply client-side sorting (when API sort is not supported) - if (sortOrder !== 'default') { - switch (sortOrder) { - case 'title-asc': - result.sort((a, b) => (a.title || '').localeCompare(b.title || '')); - break; - case 'title-desc': - result.sort((a, b) => (b.title || '').localeCompare(a.title || '')); - break; - case 'year-desc': - result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0)); - break; - case 'year-asc': - result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0)); - break; - // Default order from API is used when sortOrder is 'default' - } - } - - return result; - }, [selectedSeasons, selectedTypes, selectedStatus, selectedLanguages, sortOrder]); - - // Fetch popular anime when search is empty - const fetchPopularAnime = useCallback(async (page = 1) => { - setIsLoading(true); - setError(null); - setIsEmptySearch(true); - - try { - const data = await fetchMostPopular(page); - - if (page === 1) { - setSearchResults(data.results || []); - } else { - setSearchResults(prev => [...prev, ...(data.results || [])]); - } - - setHasNextPage(data.hasNextPage || false); - } catch (error) { - console.error('Error fetching popular anime:', error); - setError('Failed to fetch popular anime. Please try again later.'); - } finally { - setIsLoading(false); - } - }, []); - - useEffect(() => { - // If the query param is empty, redirect to search page with empty query - if (!decodedQuery.trim()) { - // Fetch popular anime instead - fetchPopularAnime(currentPage); - return; - } - - setIsEmptySearch(false); - const fetchSearchResults = async () => { - setIsLoading(true); - setError(null); - - try { - const filters = getFiltersForApi(); - const data = await searchAnime(decodedQuery, currentPage, filters); - - if (currentPage === 1) { - setSearchResults(data.results || []); - } else { - setSearchResults(prev => [...prev, ...(data.results || [])]); - } - - setHasNextPage(data.hasNextPage || false); - } catch (error) { - console.error('Error fetching search results:', error); - setError('Failed to search anime. Please try again later.'); - } finally { - setIsLoading(false); - } - }; - - fetchSearchResults(); - }, [decodedQuery, currentPage, getFiltersForApi, fetchPopularAnime]); - - // Apply client-side filters whenever search results or filter settings change - useEffect(() => { - const filteredResults = applyClientSideFilters(searchResults); - setFilteredResults(filteredResults); - }, [searchResults, applyClientSideFilters]); - - const handleLoadMore = () => { - setCurrentPage(prev => prev + 1); - }; - - // Filter handlers - const handleGenreChange = (genre) => { - setSelectedGenre(genre); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleYearChange = (year) => { - setYearFilter(year); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleSortChange = (order) => { - setSortOrder(order); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleSeasonChange = (seasons) => { - setSelectedSeasons(seasons); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleTypeChange = (types) => { - setSelectedTypes(types); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleStatusChange = (status) => { - setSelectedStatus(status); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleLanguageChange = (languages) => { - setSelectedLanguages(languages); - if (currentPage !== 1) setCurrentPage(1); - }; - - return ( -Recent Episodes
- - {/* Filters */} --- - {isLoading && animeList.length === 0 ? ( -- - {[...Array(14)].map((_, index) => ( -- ) : (filteredList.length > 0 || animeList.length > 0) ? ( - <> -- -- ))} -- - --- {(filteredList.length > 0 ? filteredList : animeList).map((anime) => ( -- - {hasNextPage && ( -- ))} - - -- )} - > - ) : ( --- )} -No anime found
-- We couldn't find any anime matching your criteria. Please try different filters. -
--- ); -} \ No newline at end of file diff --git a/src/app/search/layout.js b/src/app/search/layout.js deleted file mode 100644 index f44e019..0000000 --- a/src/app/search/layout.js +++ /dev/null @@ -1,5 +0,0 @@ -import SharedLayout from '@/components/SharedLayout'; - -export default function SearchLayout({ children }) { - return-- - {isLoading && currentPage === 1 ? ( -- {decodedQuery.trim() ? `Search Results for "${decodedQuery}"` : 'Popular Anime'} -
- - {/* Filters */} -{}} - selectedSeasons={selectedSeasons} - onSeasonChange={handleSeasonChange} - selectedTypes={selectedTypes} - onTypeChange={handleTypeChange} - selectedStatus={selectedStatus} - onStatusChange={handleStatusChange} - selectedLanguages={selectedLanguages} - onLanguageChange={handleLanguageChange} - /> - - -- ) : error ? ( --- ) : filteredResults.length > 0 ? ( - <> -Error
-{error}
-- {filteredResults.map((anime) => ( -- - {hasNextPage && ( -- ))} - - -- )} - > - ) : ( --- )} -No results found
-- We couldn't find any anime matching your search criteria. Please try different filters or a different search term. -
-{children} ; -} \ No newline at end of file diff --git a/src/app/search/page.js b/src/app/search/page.js deleted file mode 100644 index dd5d63f..0000000 --- a/src/app/search/page.js +++ /dev/null @@ -1,433 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback, Suspense } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { searchAnime, fetchMostPopular } from '@/lib/api'; -import AnimeCard from '@/components/AnimeCard'; -import AnimeFilters from '@/components/AnimeFilters'; - -function SearchResults() { - const searchParams = useSearchParams(); - const queryTerm = searchParams.get('q') || ''; - const genreParam = searchParams.get('genre') || null; - - const [animeList, setAnimeList] = useState([]); - const [filteredList, setFilteredList] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentPage, setCurrentPage] = useState(1); - const [hasNextPage, setHasNextPage] = useState(false); - const [selectedGenre, setSelectedGenre] = useState(genreParam); - const [yearFilter, setYearFilter] = useState('all'); - const [sortOrder, setSortOrder] = useState('default'); - const [selectedSeasons, setSelectedSeasons] = useState([]); - const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedStatus, setSelectedStatus] = useState([]); - const [selectedLanguages, setSelectedLanguages] = useState([]); - const [error, setError] = useState(null); - const [isEmptySearch, setIsEmptySearch] = useState(false); - - // Current year for filtering - const currentYear = new Date().getFullYear(); - - // Process and augment anime data to ensure all items have year information - const processAnimeData = useCallback((animeData) => { - if (!animeData || !animeData.results) return animeData; - - // Create a copy of the data to avoid mutating the original - const processedData = { - ...animeData, - results: animeData.results.map(anime => { - const processed = { ...anime }; - - // Extract or estimate year from various properties - // Fallback to randomized year range between 2000-current year if no year data available - if (!processed.year) { - if (processed.releaseDate && !isNaN(parseInt(processed.releaseDate))) { - processed.year = parseInt(processed.releaseDate); - } else if (processed.date && !isNaN(parseInt(processed.date))) { - processed.year = parseInt(processed.date); - } else { - // Assign a semi-random year based on anime ID to ensure consistency - const hash = processed.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); - processed.year = 2000 + (hash % (currentYear - 2000 + 1)); - } - } - - return processed; - }) - }; - - return processedData; - }, [currentYear]); - - // Create filters object for API request - const getFiltersForApi = useCallback(() => { - const filters = {}; - - if (selectedGenre) filters.genre = selectedGenre; - if (yearFilter !== 'all') filters.year = yearFilter; - if (sortOrder !== 'default') filters.sort = sortOrder; - - // Support all client-side filters in API call when possible - if (selectedSeasons.length > 0) filters.season = selectedSeasons.join(','); - if (selectedTypes.length > 0) filters.type = selectedTypes.join(','); - if (selectedStatus.length > 0) filters.status = selectedStatus.join(','); - if (selectedLanguages.length > 0) filters.language = selectedLanguages.join(','); - - return filters; - }, [selectedGenre, yearFilter, sortOrder, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]); - - // Apply client-side filters for things not supported by API - const applyClientSideFilters = useCallback((animeList) => { - if (!animeList.length) return []; - - let result = [...animeList]; - - // Apply season filter if selected - if (selectedSeasons.length > 0) { - result = result.filter(anime => { - if (!anime.season) return false; - - const animeSeason = typeof anime.season === 'string' - ? anime.season - : anime.season?.name || ''; - - return selectedSeasons.some(season => - animeSeason.toLowerCase().includes(season.toLowerCase()) - ); - }); - } - - // Apply type filter if selected - if (selectedTypes.length > 0) { - result = result.filter(anime => { - if (!anime.type) return false; - - return selectedTypes.some(type => - anime.type.toLowerCase() === type.toLowerCase() - ); - }); - } - - // Apply status filter if selected - if (selectedStatus.length > 0) { - result = result.filter(anime => { - if (!anime.status) return false; - - return selectedStatus.some(status => - anime.status.toLowerCase().includes(status.toLowerCase()) - ); - }); - } - - // Apply language filter if selected - if (selectedLanguages.length > 0) { - result = result.filter(anime => { - // If no language info, assume subbed (most common) - const animeLanguage = anime.language || 'Subbed'; - - return selectedLanguages.some(language => - animeLanguage.toLowerCase().includes(language.toLowerCase()) - ); - }); - } - - // Apply client-side sorting (when API sort is not supported) - if (sortOrder !== 'default') { - switch (sortOrder) { - case 'title-asc': - result.sort((a, b) => (a.title || '').localeCompare(b.title || '')); - break; - case 'title-desc': - result.sort((a, b) => (b.title || '').localeCompare(a.title || '')); - break; - case 'year-desc': - result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0)); - break; - case 'year-asc': - result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0)); - break; - // Default order from API is used when sortOrder is 'default' - } - } - - return result; - }, [selectedSeasons, selectedTypes, selectedStatus, selectedLanguages, sortOrder]); - - // Fetch most popular anime when search is empty - const fetchPopularAnime = useCallback(async () => { - setIsLoading(true); - setError(null); - setIsEmptySearch(true); - - try { - const data = await fetchMostPopular(1); - const processedData = processAnimeData(data); - - const results = processedData.results || []; - setAnimeList(results); - - // Apply client-side filters - const filteredResults = applyClientSideFilters(results); - setFilteredList(filteredResults); - - setHasNextPage(processedData.hasNextPage || false); - } catch (error) { - console.error('Error fetching popular anime:', error); - setError('Failed to fetch popular anime. Please try again later.'); - setAnimeList([]); - setFilteredList([]); - } finally { - setIsLoading(false); - } - }, [processAnimeData, applyClientSideFilters]); - - // Fetch data from API when search term or main filters change - useEffect(() => { - const fetchData = async () => { - if (!queryTerm.trim()) { - // Show popular anime instead of empty results - fetchPopularAnime(); - return; - } - - setIsLoading(true); - setError(null); - setCurrentPage(1); - setIsEmptySearch(false); - - try { - const filters = getFiltersForApi(); - console.log(`[Search] Searching for: "${queryTerm}" with filters:`, filters); - - const data = await searchAnime(queryTerm, 1, filters); - - // If no results but no error was thrown, show empty state - if (!data || (!data.results || data.results.length === 0)) { - console.log('[Search] No results found for search term:', queryTerm); - setError(`No results found for "${queryTerm}"`); - setAnimeList([]); - setFilteredList([]); - setIsLoading(false); - return; - } - - const processedData = processAnimeData(data); - const results = processedData.results || []; - setAnimeList(results); - - // Only apply client-side filters for things not supported by API - const filteredResults = applyClientSideFilters(results); - setFilteredList(filteredResults); - - setHasNextPage(processedData.hasNextPage || false); - } catch (error) { - console.error('[Search] Error searching anime:', error); - setError('Failed to search anime. Please try again later or check your internet connection.'); - setAnimeList([]); - setFilteredList([]); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, [queryTerm, getFiltersForApi, processAnimeData, applyClientSideFilters, fetchPopularAnime]); - - // Handle pagination - useEffect(() => { - // Skip if it's the first page (already fetched in the previous effect) - // or if no search term is provided - if (currentPage === 1) { - return; - } - - const loadMoreData = async () => { - setIsLoading(true); - - try { - // If it's an empty search query, load more popular anime - if (isEmptySearch) { - const data = await fetchMostPopular(currentPage); - const processedData = processAnimeData(data); - - const newResults = processedData.results || []; - setAnimeList(prev => [...prev, ...newResults]); - - // Apply client-side filters to new results - const filteredNewResults = applyClientSideFilters(newResults); - setFilteredList(prev => [...prev, ...filteredNewResults]); - - setHasNextPage(processedData.hasNextPage || false); - } else { - // For search results, include filters - const filters = getFiltersForApi(); - const data = await searchAnime(queryTerm, currentPage, filters); - const processedData = processAnimeData(data); - - const newResults = processedData.results || []; - setAnimeList(prev => [...prev, ...newResults]); - - // Only apply client-side filters for things not supported by API - const filteredNewResults = applyClientSideFilters(newResults); - setFilteredList(prev => [...prev, ...filteredNewResults]); - - setHasNextPage(processedData.hasNextPage || false); - } - } catch (error) { - console.error('Error loading more anime:', error); - setError('Failed to load more results. Please try again later.'); - } finally { - setIsLoading(false); - } - }; - - loadMoreData(); - }, [currentPage, queryTerm, isEmptySearch, getFiltersForApi, processAnimeData, applyClientSideFilters]); - - // Re-apply client-side filters when filters change but don't need API refetch - useEffect(() => { - const applyFilters = () => { - const filteredResults = applyClientSideFilters(animeList); - setFilteredList(filteredResults); - }; - - applyFilters(); - }, [selectedSeasons, selectedTypes, selectedStatus, selectedLanguages, animeList, applyClientSideFilters]); - - const handleLoadMore = () => { - setCurrentPage(prev => prev + 1); - }; - - const handleGenreChange = (genre) => { - setSelectedGenre(genre); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleYearChange = (year) => { - setYearFilter(year); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleSortChange = (order) => { - setSortOrder(order); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleSeasonChange = (seasons) => { - setSelectedSeasons(seasons); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleTypeChange = (types) => { - setSelectedTypes(types); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleStatusChange = (status) => { - setSelectedStatus(status); - if (currentPage !== 1) setCurrentPage(1); - }; - - const handleLanguageChange = (languages) => { - setSelectedLanguages(languages); - if (currentPage !== 1) setCurrentPage(1); - }; - - return ( -- {/* Horizontal filters at the top */} -- ); -} - -export default function SearchPage() { - return ( --- - {/* Main content */} -{}} - selectedSeasons={selectedSeasons} - onSeasonChange={handleSeasonChange} - selectedTypes={selectedTypes} - onTypeChange={handleTypeChange} - selectedStatus={selectedStatus} - onStatusChange={handleStatusChange} - selectedLanguages={selectedLanguages} - onLanguageChange={handleLanguageChange} - /> - ---- - {error ? ( -- {queryTerm ? `Results for "${queryTerm}"` : 'Popular Anime'} -
-- {filteredList.length > 0 && ( - {filteredList.length} {filteredList.length === 1 ? 'result' : 'results'} - )} ---- ) : isLoading && currentPage === 1 ? ( -{error}
-- {/* Loading skeleton */} - {[...Array(24)].map((_, index) => ( - - ))} -- ) : filteredList.length === 0 ? ( --- ) : ( - <> -No anime found matching your filters.
-- {filteredList.map((anime) => ( -- - {/* Load more button */} - {hasNextPage && ( -- ))} - - -- )} - > - )} -Loading...}> - - ); -} \ No newline at end of file diff --git a/src/app/terms-and-services/page.jsx b/src/app/terms-and-services/page.jsx deleted file mode 100644 index 3b80de2..0000000 --- a/src/app/terms-and-services/page.jsx +++ /dev/null @@ -1,191 +0,0 @@ -'use client'; - -import SharedLayout from '@/components/SharedLayout'; - -export default function TermsPage() { - return ( -- - - ); -} \ No newline at end of file diff --git a/src/app/top-airing/layout.js b/src/app/top-airing/layout.js deleted file mode 100644 index a55c076..0000000 --- a/src/app/top-airing/layout.js +++ /dev/null @@ -1,5 +0,0 @@ -import SharedLayout from '@/components/SharedLayout'; - -export default function TopAiringLayout({ children }) { - return--Terms of Service & Privacy Policy
- ----- -Terms of Service
- ----- -1. Acceptance of Terms
-- By accessing and using JustAnime, you acknowledge that you have read, understood, and agree to be bound by these Terms of Service. - If you do not agree with any part of these terms, you may not use our services. -
--- -2. Service Description
-- JustAnime is a platform that provides information and links to anime content. - We do not host, upload, or distribute any content directly. - Our service aggregates links to third-party websites and services that host the actual content. -
--- -3. User Conduct
-- Users of JustAnime agree not to: -
--
-- Use our service for any illegal purpose or in violation of any local, state, national, or international law
-- Harass, abuse, or harm another person
-- Interfere with or disrupt the service or servers connected to the service
-- Create multiple accounts for disruptive or abusive purposes
-- Attempt to access any portion of the service that you are not authorized to access
--- -4. Content Disclaimer
-- JustAnime does not host any content on its servers. We are not responsible for the content, accuracy, or practices of third-party websites that our service may link to. - These links are provided solely as a convenience to users, and we do not endorse the content of such third-party sites. -
--- -5. Intellectual Property
-- All trademarks, logos, service marks, and trade names are the property of their respective owners. - JustAnime respects intellectual property rights and expects users to do the same. - If you believe content linked through our service infringes on your copyright, please contact us with details. -
--- -6. Modification of Terms
-- JustAnime reserves the right to modify these Terms of Service at any time. - We will provide notice of significant changes through our website. - Your continued use of our service after such modifications constitutes your acceptance of the updated terms. -
---7. Termination
-- JustAnime reserves the right to terminate or suspend your access to our service at any time, without prior notice or liability, for any reason whatsoever, including without limitation if you breach these Terms of Service. -
---Privacy Policy
- ----- -1. Information We Collect
-- JustAnime collects the following types of information: -
--
-- Information you provide: We may collect personal information such as your email address when you sign up for an account or contact us.
-- Usage data: We automatically collect information about your interactions with our service, including the pages you visit and your preferences.
-- Device information: We collect information about your device and internet connection, including IP address, browser type, and operating system.
--- -2. How We Use Your Information
-- We use the information we collect to: -
--
-- Provide, maintain, and improve our service
-- Communicate with you about updates, support, and features
-- Monitor and analyze usage patterns and trends
-- Protect against, identify, and prevent fraud and other illegal activity
-- Comply with legal obligations
--- -3. Cookies and Similar Technologies
-- JustAnime uses cookies and similar tracking technologies to track activity on our service and hold certain information. - Cookies are files with a small amount of data that may include an anonymous unique identifier. - You can instruct your browser to refuse all cookies or to indicate when a cookie is being sent. -
--- -4. Data Sharing and Disclosure
-- We may share your information in the following circumstances: -
--
-- With service providers who perform services on our behalf
-- To comply with legal obligations
-- To protect the rights, property, or safety of JustAnime, our users, or others
-- In connection with a business transfer, such as a merger or acquisition
--- -5. Data Security
-- JustAnime takes reasonable measures to protect your information from unauthorized access, alteration, disclosure, or destruction. - However, no method of transmission over the Internet or electronic storage is 100% secure, and we cannot guarantee absolute security. -
--- -6. Your Rights
-- Depending on your location, you may have certain rights regarding your personal data, including: -
--
-- The right to access and receive a copy of your data
-- The right to rectify or update your data
-- The right to delete your data
-- The right to restrict processing of your data
-- The right to object to processing of your data
-- The right to data portability
-- To exercise these rights, please contact us at privacy@justanime.com. -
--- -7. Children's Privacy
-- JustAnime does not knowingly collect personal information from children under 13. - If you are a parent or guardian and you believe your child has provided us with personal information, please contact us. -
--- -8. Changes to This Privacy Policy
-- We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last Updated" date. -
--- -9. Contact Us
-- If you have any questions about this Privacy Policy, please contact us at privacy@justanime.com. -
---Last Updated: May 5, 2024
-{children} ; -} \ No newline at end of file diff --git a/src/app/top-airing/page.js b/src/app/top-airing/page.js deleted file mode 100644 index afff6f5..0000000 --- a/src/app/top-airing/page.js +++ /dev/null @@ -1,276 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import AnimeCard from '@/components/AnimeCard'; -import AnimeFilters from '@/components/AnimeFilters'; -import { fetchTopAiring } from '@/lib/api'; - -export default function TopAiringPage() { - const [animeList, setAnimeList] = useState([]); - const [filteredList, setFilteredList] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentPage, setCurrentPage] = useState(1); - const [hasNextPage, setHasNextPage] = useState(false); - const [selectedGenre, setSelectedGenre] = useState(null); - const [yearFilter, setYearFilter] = useState('all'); - const [sortOrder, setSortOrder] = useState('default'); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedSeasons, setSelectedSeasons] = useState([]); - const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedStatus, setSelectedStatus] = useState([]); - const [selectedLanguages, setSelectedLanguages] = useState([]); - const [error, setError] = useState(null); - - // Current year for filtering - const currentYear = new Date().getFullYear(); - - // Add ref to track if this is the first render - const initialRender = useRef(true); - - useEffect(() => { - const fetchData = async () => { - setIsLoading(true); - try { - const data = await fetchTopAiring(currentPage); - - if (currentPage === 1) { - setAnimeList(data.results || []); - } else { - setAnimeList(prev => [...prev, ...(data.results || [])]); - } - - setHasNextPage(data.hasNextPage || false); - } catch (error) { - console.error('Error fetching top airing anime:', error); - setError('Failed to load anime. Please try again later.'); - } finally { - setIsLoading(false); - } - }; - - fetchData(); - }, [currentPage]); - - // Apply filters and sorting whenever the anime list or filter settings change - useEffect(() => { - // Skip the initial render effect to avoid duplicate filtering - if (initialRender.current) { - initialRender.current = false; - return; - } - - if (!animeList.length) { - setFilteredList([]); - return; - } - - let result = [...animeList]; - - // Search filter - if (searchQuery && searchQuery.trim() !== '') { - const query = searchQuery.toLowerCase().trim(); - result = result.filter(anime => { - const title = (anime.title || '').toLowerCase(); - const otherNames = (anime.otherNames || '').toLowerCase(); - return title.includes(query) || otherNames.includes(query); - }); - } - - // Filter by genre if selected - if (selectedGenre) { - result = result.filter(anime => { - if (anime.genres && Array.isArray(anime.genres)) { - return anime.genres.some(g => - g.toLowerCase() === selectedGenre.toLowerCase() || - (g.name && g.name.toLowerCase() === selectedGenre.toLowerCase()) - ); - } else if (anime.genre) { - return anime.genre.toLowerCase().includes(selectedGenre.toLowerCase()); - } - return false; - }); - } - - // Filter by season - if (selectedSeasons.length > 0) { - result = result.filter(anime => { - const season = getAnimeSeason(anime); - return selectedSeasons.includes(season); - }); - } - - // Filter by year - if (yearFilter !== 'all') { - result = result.filter(anime => { - const animeYear = parseInt(anime.year) || 0; - if (yearFilter === 'older') { - return animeYear < 2000; - } else { - return animeYear.toString() === yearFilter; - } - }); - } - - // Filter by type - if (selectedTypes.length > 0) { - result = result.filter(anime => - selectedTypes.includes(anime.type) - ); - } - - // Filter by status - if (selectedStatus.length > 0) { - result = result.filter(anime => { - const status = anime.status || getDefaultStatus(anime); - return selectedStatus.includes(status); - }); - } - - // Filter by language - if (selectedLanguages.length > 0) { - result = result.filter(anime => { - const language = anime.language || getDefaultLanguage(anime); - return selectedLanguages.includes(language); - }); - } - - // Apply sorting - switch (sortOrder) { - case 'title-asc': - result.sort((a, b) => (a.title || '').localeCompare(b.title || '')); - break; - case 'title-desc': - result.sort((a, b) => (b.title || '').localeCompare(a.title || '')); - break; - case 'year-desc': - result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0)); - break; - case 'year-asc': - result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0)); - break; - default: - // Default order from API - break; - } - - setFilteredList(result); - }, [animeList, selectedGenre, yearFilter, sortOrder, searchQuery, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]); - - const handleLoadMore = () => { - setCurrentPage(prev => prev + 1); - }; - - const handleGenreChange = (genre) => { - setSelectedGenre(genre); - }; - - const handleYearChange = (year) => { - setYearFilter(year); - }; - - const handleSortChange = (order) => { - setSortOrder(order); - }; - - const handleSearchChange = (value) => { - setSearchQuery(value); - }; - - const handleSeasonChange = (seasons) => { - setSelectedSeasons(seasons); - }; - - const handleTypeChange = (types) => { - setSelectedTypes(types); - }; - - const handleStatusChange = (status) => { - setSelectedStatus(status); - }; - - const handleLanguageChange = (languages) => { - setSelectedLanguages(languages); - }; - - return ( --- ); -} \ No newline at end of file diff --git a/src/app/watch/[episodeId]/page.js b/src/app/watch/[episodeId]/page.js deleted file mode 100644 index a2a9d7c..0000000 --- a/src/app/watch/[episodeId]/page.js +++ /dev/null @@ -1,664 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useParams, useRouter, usePathname, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; -import Image from 'next/image'; -import VideoPlayer from '@/components/VideoPlayer'; -import EpisodeList from '@/components/EpisodeList'; -import { - fetchEpisodeSources, - fetchAnimeInfo, - fetchEpisodeServers, - fetchAnimeEpisodes -} from '@/lib/api'; - -export default function WatchPage() { - const { episodeId } = useParams(); - const router = useRouter(); - const pathname = usePathname(); - const [videoSource, setVideoSource] = useState(null); - const [anime, setAnime] = useState(null); - const [currentEpisode, setCurrentEpisode] = useState(null); - const [isDub, setIsDub] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [videoHeaders, setVideoHeaders] = useState({}); - const [subtitles, setSubtitles] = useState([]); - const [thumbnails, setThumbnails] = useState(null); - const [animeId, setAnimeId] = useState(null); - const [episodeData, setEpisodeData] = useState(null); - const [isRetrying, setIsRetrying] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - const episodesPerPage = 100; - const [showFullSynopsis, setShowFullSynopsis] = useState(false); - const [autoSkip, setAutoSkip] = useState(false); - const [currentEpisodeId, setCurrentEpisodeId] = useState(episodeId); - const [availableServers, setAvailableServers] = useState([]); - const [selectedServer, setSelectedServer] = useState('hd-2'); - const [episodes, setEpisodes] = useState([]); - - // Handle URL updates when currentEpisodeId changes - useEffect(() => { - if (currentEpisodeId && currentEpisodeId !== episodeId) { - const newUrl = `/watch/${currentEpisodeId}`; - window.history.pushState({ episodeId: currentEpisodeId }, '', newUrl); - } - }, [currentEpisodeId, episodeId]); - - // Listen for popstate (browser back/forward) events - useEffect(() => { - const handlePopState = (event) => { - const path = window.location.pathname; - const match = path.match(/\/watch\/(.+)$/); - if (match) { - const newEpisodeId = match[1]; - setCurrentEpisodeId(newEpisodeId); - } - }; - - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, []); - - // Extract animeId from the URL - useEffect(() => { - if (episodeId) { - // Log the raw episodeId from the URL for debugging - console.log('[Watch] Raw episodeId from URL:', episodeId); - - // Extract animeId from the episodeId parameter - // The API response contains episode.id in the format "anime-id?ep=episode-number" - let extractedAnimeId = episodeId; - - // If the ID contains a query parameter, extract just the anime ID - if (episodeId.includes('?')) { - extractedAnimeId = episodeId.split('?')[0]; - } - - setAnimeId(extractedAnimeId); - console.log('[Watch] Extracted anime ID:', extractedAnimeId); - - setCurrentEpisodeId(episodeId); - } - }, [episodeId]); - - // First fetch episode servers to get available servers and subtitles - useEffect(() => { - if (!currentEpisodeId || currentEpisodeId === 'undefined') { - setError('Invalid episode ID'); - setIsLoading(false); - return; - } - - const fetchServers = async () => { - setIsLoading(true); - - try { - console.log(`[Watch] Fetching servers for episode ${currentEpisodeId}`); - - // Fetch available servers from the API - const data = await fetchEpisodeServers(currentEpisodeId); - - if (!data || !data.servers || data.servers.length === 0) { - console.warn('[Watch] No servers available for this episode'); - } else { - // Filter servers based on current audio preference (sub/dub) - const filteredServers = data.servers.filter(server => - server.category === (isDub ? 'dub' : 'sub') - ); - - setAvailableServers(filteredServers); - console.log(`[Watch] Available ${isDub ? 'dub' : 'sub'} servers:`, filteredServers); - - // Set default server if available - // First try to find HD-1 server - let preferredServer = filteredServers.find(server => - server.serverName && server.serverName.toLowerCase() === 'hd-2' - ); - - // If not found, look for vidstreaming - if (!preferredServer) { - preferredServer = filteredServers.find(server => - server.serverName && server.serverName.toLowerCase().includes('vidstreaming') - ); - } - - if (preferredServer && preferredServer.serverName) { - setSelectedServer(preferredServer.serverName.toLowerCase()); - console.log(`[Watch] Selected preferred server: ${preferredServer.serverName}`); - } else if (filteredServers.length > 0 && filteredServers[0].serverName) { - setSelectedServer(filteredServers[0].serverName.toLowerCase()); - console.log(`[Watch] Selected first available server: ${filteredServers[0].serverName}`); - } - } - - // Continue to fetch video sources with the selected server - fetchVideoSources(currentEpisodeId, isDub, selectedServer); - - } catch (error) { - console.error('[Watch] Error fetching episode servers:', error); - // Continue to sources even if servers fail - fetchVideoSources(currentEpisodeId, isDub, selectedServer); - } - }; - - fetchServers(); - }, [currentEpisodeId, isDub]); - - // Fetch video sources function - const fetchVideoSources = async (episodeId, dub, server) => { - setIsLoading(true); - setError(null); - setVideoSource(null); - - try { - console.log(`[Watch] Fetching video for episode ${episodeId} (dub: ${dub}, server: ${server})`); - - // Fetch the episode sources from the API - const data = await fetchEpisodeSources(episodeId, dub, server); - - console.log('[Watch] Episode sources API response:', data); - setEpisodeData(data); - - if (!data || !data.sources || data.sources.length === 0) { - throw new Error('No video sources available for this episode'); - } - - // Extract headers if they exist in the response - if (data.headers) { - console.log('[Watch] Headers from API:', data.headers); - setVideoHeaders(data.headers); - } else { - // Set default headers if none provided - const defaultHeaders = { - "Referer": "https://hianime.to/", - "Origin": "https://hianime.to" - }; - setVideoHeaders(defaultHeaders); - } - - // Set subtitles if available in the sources response - // Check both subtitles and tracks fields since API might return either - const subtitleData = data.subtitles || data.tracks || []; - if (subtitleData.length > 0) { - // Filter out thumbnails from subtitles array - const filteredSubtitles = subtitleData.filter(sub => - sub.lang && sub.lang.toLowerCase() !== 'thumbnails' - ); - - // Look for thumbnails separately - const thumbnailTrack = subtitleData.find(sub => - sub.lang && sub.lang.toLowerCase() === 'thumbnails' - ); - - if (thumbnailTrack && thumbnailTrack.url) { - console.log('[Watch] Found thumbnails track:', thumbnailTrack.url); - setThumbnails(thumbnailTrack.url); - } - - if (filteredSubtitles.length > 0) { - console.log('[Watch] Found subtitles:', filteredSubtitles.length); - setSubtitles(filteredSubtitles); - } - } - - // Try to find the best source in order of preference - // 1. HLS (m3u8) sources - // 2. High quality MP4 sources - const hlsSource = data.sources.find(src => src.isM3U8); - const mp4Source = data.sources.find(src => !src.isM3U8); - - let selectedSource = null; - - if (hlsSource && hlsSource.url) { - console.log('[Watch] Selected HLS source:', hlsSource.url); - selectedSource = hlsSource.url; - } else if (mp4Source && mp4Source.url) { - console.log('[Watch] Selected MP4 source:', mp4Source.url); - selectedSource = mp4Source.url; - } else if (data.sources[0] && data.sources[0].url) { - console.log('[Watch] Falling back to first available source:', data.sources[0].url); - selectedSource = data.sources[0].url; - } else { - throw new Error('No valid video URLs found'); - } - - setVideoSource(selectedSource); - setIsLoading(false); - - } catch (error) { - console.error('[Watch] Error fetching video sources:', error); - setError(error.message || 'Failed to load video'); - setIsLoading(false); - - // If this is the first try, attempt to retry once - if (!isRetrying) { - console.log('[Watch] First error, attempting retry...'); - setIsRetrying(true); - setTimeout(() => { - console.log('[Watch] Executing retry...'); - fetchVideoSources(episodeId, dub, server); - }, 2000); - } - } - }; - - // Effect to refetch sources when server or dub changes - useEffect(() => { - if (currentEpisodeId && selectedServer) { - fetchVideoSources(currentEpisodeId, isDub, selectedServer); - } - }, [selectedServer, isDub]); - - // Fetch anime info and episodes using animeId - useEffect(() => { - if (animeId) { - const fetchAnimeDetails = async () => { - try { - setIsRetrying(true); - console.log(`[Watch] Fetching anime info for ID: ${animeId}`); - - // Fetch basic anime info - const animeData = await fetchAnimeInfo(animeId); - if (animeData) { - console.log('[Watch] Anime info received:', animeData.info?.name); - setAnime({ - id: animeId, - title: animeData.info?.name || 'Unknown Anime', - image: animeData.info?.poster || '', - description: animeData.info?.description || 'No description available', - status: animeData.moreInfo?.status || 'Unknown', - type: animeData.info?.stats?.type || 'TV', - totalEpisodes: animeData.info?.stats?.episodes?.sub || 0, - genres: animeData.moreInfo?.genres || [] - }); - } - - // Fetch episodes separately - const episodesData = await fetchAnimeEpisodes(animeId); - if (episodesData && episodesData.episodes && episodesData.episodes.length > 0) { - console.log('[Watch] Episodes found:', episodesData.episodes.length); - setEpisodes(episodesData.episodes); - - // Find current episode in episode list - const findCurrentEpisode = () => { - // Find the episode by direct ID match - const directMatch = episodesData.episodes.find(ep => ep.id === currentEpisodeId); - if (directMatch) { - console.log('[Watch] Found episode by direct ID match:', directMatch.number); - return directMatch; - } - - // If no match found, return first episode as fallback - console.warn('[Watch] Could not find matching episode, falling back to first episode'); - return episodesData.episodes[0]; - }; - - const episode = findCurrentEpisode(); - if (episode) { - setCurrentEpisode(episode); - console.log('[Watch] Current episode found:', episode.number); - } else { - console.warn('[Watch] Current episode not found in episode list'); - } - } else { - console.warn('[Watch] No episodes found for this anime'); - } - } catch (error) { - console.error('[Watch] Error fetching anime details:', error); - } finally { - setIsRetrying(false); - } - }; - - fetchAnimeDetails(); - } - }, [animeId, currentEpisodeId]); - - const handleDubToggle = () => { - setIsDub(prev => { - const newDubState = !prev; - // Refetch servers for the new audio type - fetchEpisodeServers(currentEpisodeId).then(data => { - if (data && data.servers && data.servers.length > 0) { - // Filter servers based on new audio preference - const filteredServers = data.servers.filter(server => - server.category === (newDubState ? 'dub' : 'sub') - ); - - setAvailableServers(filteredServers); - - // Update selected server if needed - // First try to find HD-1 server - let preferredServer = filteredServers.find(server => - server.serverName && server.serverName.toLowerCase() === 'hd-2' - ); - - // If not found, look for vidstreaming - if (!preferredServer) { - preferredServer = filteredServers.find(server => - server.serverName && server.serverName.toLowerCase().includes('vidstreaming') - ); - } - - if (preferredServer && preferredServer.serverName) { - setSelectedServer(preferredServer.serverName.toLowerCase()); - console.log(`[Watch] Selected preferred server: ${preferredServer.serverName}`); - } else if (filteredServers.length > 0 && filteredServers[0].serverName) { - setSelectedServer(filteredServers[0].serverName.toLowerCase()); - console.log(`[Watch] Selected first available server: ${filteredServers[0].serverName}`); - } - } - }); - return newDubState; - }); - }; - - const handleServerChange = (server) => { - setSelectedServer(server); - }; - - const handleEpisodeClick = (newEpisodeId) => { - if (newEpisodeId !== currentEpisodeId) { - console.log(`[Watch] Episode clicked, ID: ${newEpisodeId}`); - - // Use the episode ID directly as it should already be in the correct format - // from the API response (animeId?ep=episodeNumber) - - // Update the URL using history API - const newUrl = `/watch/${newEpisodeId}`; - window.history.pushState({ episodeId: newEpisodeId }, '', newUrl); - - // Update state to trigger video reload - setCurrentEpisodeId(newEpisodeId); - - // Update current episode in state - if (episodes) { - const newEpisode = episodes.find(ep => ep.id === newEpisodeId); - if (newEpisode) { - setCurrentEpisode(newEpisode); - } - } - } - }; - - const findAdjacentEpisodes = () => { - if (!episodes || !currentEpisode) return { prev: null, next: null }; - - const currentIndex = episodes.findIndex(ep => ep.number === currentEpisode.number); - if (currentIndex === -1) return { prev: null, next: null }; - - return { - prev: currentIndex > 0 ? episodes[currentIndex - 1] : null, - next: currentIndex < episodes.length - 1 ? episodes[currentIndex + 1] : null - }; - }; - - const { prev, next } = findAdjacentEpisodes(); - - return ( -Top Airing Anime
- - {/* Filters */} --- - {isLoading && animeList.length === 0 ? ( -- - {[...Array(14)].map((_, index) => ( -- ) : (filteredList.length > 0 || animeList.length > 0) ? ( - <> -- -- ))} -- - --- {(filteredList.length > 0 ? filteredList : animeList).map((anime) => ( -- - {hasNextPage && ( -- ))} - - -- )} - > - ) : ( --- )} -No anime found
-- We couldn't find any top airing anime at this time. Please check back later. -
-- - ); -} \ No newline at end of file diff --git a/src/app/watch/layout.js b/src/app/watch/layout.js deleted file mode 100644 index 3fca793..0000000 --- a/src/app/watch/layout.js +++ /dev/null @@ -1,5 +0,0 @@ -import SharedLayout from '@/components/SharedLayout'; - -export default function WatchLayout({ children }) { - return--- {/* Left Side - Video Player (70%) */} ---- - {/* Right Side - Episode List (30%) */} -- {/* Video Player Container */} ---- - {/* Video Controls - Slimmer and without container background */} ---- {error ? ( ---- ) : isLoading ? ( -Error: {error}-- The video source couldn't be loaded. Please try again or check back later. -
-- -- ) : videoSource ? ( -Loading video...--- ) : ( -- -- )} -No video source available-- Please try again or check back later. -
-- {/* Audio and Playback Controls */} -- - {/* Anime Info Section */} - {anime && ( -- {/* Playback Settings */} -- - {/* Episode Navigation */} --- - {/* Server Selection */} - {availableServers.length > 0 && ( -Playback Settings
-- {/* Auto Skip Checkbox */} - {(episodeData?.intro || episodeData?.outro) && ( - - )} ---- )} - - {/* Audio Toggle */} -Servers
-- {availableServers.map((server) => - server.serverName ? ( - - ) : null - )} ----Audio
-- - --- {episodes && episodes.length > 0 && ( - <> - - - > - )} ---- )} -- {/* Cover Image */} ---- - {/* Details */} ---- - --- {anime.title} -
- - - {/* Status Bar */} -- {anime.status} - • - {anime.type} - • - {anime.totalEpisodes} Episodes -- - {/* Synopsis Section */} --- - {/* Genres */} - {anime.genres && ( -Synopsis
---- {anime.description} -- -- {anime.genres.map((genre, index) => ( - - {genre} - - ))} -- )} -- {episodes && episodes.length > 0 ? ( ---- ) : ( -- -- )} -- {isLoading ? 'Loading episodes...' : 'No episodes available'} --{children} ; -} \ No newline at end of file diff --git a/src/components/AnimeCalendar.js b/src/components/AnimeCalendar.js deleted file mode 100644 index c4e3b53..0000000 --- a/src/components/AnimeCalendar.js +++ /dev/null @@ -1,210 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -import { fetchSchedule } from '@/lib/api'; - -export default function AnimeCalendar() { - const [selectedDay, setSelectedDay] = useState(getCurrentDayIndex()); - const [scheduleData, setScheduleData] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - // Add custom scrollbar styles - useEffect(() => { - // Add custom styles for the calendar scrollbar - const style = document.createElement('style'); - style.textContent = ` - .schedule-scrollbar::-webkit-scrollbar { - width: 4px; - } - .schedule-scrollbar::-webkit-scrollbar-track { - background: var(--card); - } - .schedule-scrollbar::-webkit-scrollbar-thumb { - background-color: var(--border); - border-radius: 4px; - } - `; - document.head.appendChild(style); - - // Cleanup function - return () => { - document.head.removeChild(style); - }; - }, []); - - // Get current day index (0-6, Sunday is 0) - function getCurrentDayIndex() { - const dayIndex = new Date().getDay(); - return dayIndex; // Sunday is 0, Monday is 1, etc. - } - - // Get current date info for the header - const getCurrentDateInfo = () => { - const today = new Date(); - const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; - - // Calculate the date for the selected day - const currentDayIndex = today.getDay(); - let daysDiff = selectedDay - currentDayIndex; - - // Always get the previous occurrence (or today if it's the current day) - if (daysDiff > 0) { - daysDiff -= 7; // Go back to previous week - } - - const selectedDate = new Date(today); - selectedDate.setDate(today.getDate() + daysDiff); - - return { - day: dayNames[selectedDay], - date: selectedDate.getDate(), - month: monthNames[selectedDate.getMonth()] - }; - }; - - const dateInfo = getCurrentDateInfo(); - - // Generate week days for the calendar - const days = [ - { label: 'Mon', value: 1 }, - { label: 'Tue', value: 2 }, - { label: 'Wed', value: 3 }, - { label: 'Thu', value: 4 }, - { label: 'Fri', value: 5 }, - { label: 'Sat', value: 6 }, - { label: 'Sun', value: 0 }, - ]; - - useEffect(() => { - async function loadScheduleData() { - setIsLoading(true); - try { - // Get the date for the selected day - const today = new Date(); - const currentDayIndex = today.getDay(); - let daysDiff = selectedDay - currentDayIndex; - - if (daysDiff > 0) { - daysDiff -= 7; - } - - const selectedDate = new Date(today); - selectedDate.setDate(today.getDate() + daysDiff); - - // Format date as YYYY-MM-DD - const formattedDate = selectedDate.toISOString().split('T')[0]; - - // Fetch schedule data for the selected date - const data = await fetchSchedule(formattedDate); - - if (data && data.scheduledAnimes) { - // Process and sort the scheduled animes by time - const processedData = data.scheduledAnimes - .map(anime => ({ - id: anime.id, - title: anime.name, - japaneseTitle: anime.jname, - time: anime.time, - airingTimestamp: anime.airingTimestamp, - secondsUntilAiring: anime.secondsUntilAiring - })) - .sort((a, b) => { - // Convert time strings to comparable values (assuming 24-hour format) - const timeA = a.time.split(':').map(Number); - const timeB = b.time.split(':').map(Number); - return (timeA[0] * 60 + timeA[1]) - (timeB[0] * 60 + timeB[1]); - }); - - setScheduleData(processedData); - } else { - setScheduleData([]); - } - } catch (error) { - console.error('Error loading schedule data:', error); - setScheduleData([]); - } finally { - setIsLoading(false); - } - } - - loadScheduleData(); - }, [selectedDay]); - - return ( -- {/* Header */} -- ); -} \ No newline at end of file diff --git a/src/components/AnimeCard.js b/src/components/AnimeCard.js deleted file mode 100644 index 14952c0..0000000 --- a/src/components/AnimeCard.js +++ /dev/null @@ -1,158 +0,0 @@ -'use client'; - -import React, { useState, useEffect, useRef } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { fetchAnimeEpisodes } from '@/lib/api'; - -export default function AnimeCard({ anime, isRecent }) { - const [imageError, setImageError] = useState(false); - const [firstEpisodeId, setFirstEpisodeId] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const timerRef = useRef(null); - - // Fetch first episode ID when component mounts for recent anime - useEffect(() => { - const fetchFirstEpisode = async () => { - // Only fetch for recent anime and if we don't already have the episode ID - if (isRecent && anime?.id && !firstEpisodeId && !isLoading) { - setIsLoading(true); - try { - console.log(`[AnimeCard] Fetching episodes for anime: ${anime.id}`); - const response = await fetchAnimeEpisodes(anime.id); - console.log(`[AnimeCard] Episodes response for ${anime.name}:`, response); - - if (response.episodes && response.episodes.length > 0) { - // Check for the episode ID in the format expected by the watch page - const firstEp = response.episodes[0]; - if (firstEp.id) { - setFirstEpisodeId(firstEp.id); - console.log(`[AnimeCard] First episode ID (id) for ${anime.name}: ${firstEp.id}`); - } else if (firstEp.episodeId) { - setFirstEpisodeId(firstEp.episodeId); - console.log(`[AnimeCard] First episode ID (episodeId) for ${anime.name}: ${firstEp.episodeId}`); - } else { - // Create a fallback ID if neither id nor episodeId are available - const fallbackId = `${anime.id}?ep=1`; - setFirstEpisodeId(fallbackId); - console.log(`[AnimeCard] Using fallback ID for ${anime.name}: ${fallbackId}`); - } - } else if (anime.id) { - // If no episodes found, create a fallback ID - const fallbackId = `${anime.id}?ep=1`; - setFirstEpisodeId(fallbackId); - console.log(`[AnimeCard] No episodes found for ${anime.name}, using fallback ID: ${fallbackId}`); - } - } catch (error) { - console.error(`[AnimeCard] Error fetching episodes for ${anime.id}:`, error); - // Even on error, try to use fallback - if (anime.id) { - const fallbackId = `${anime.id}?ep=1`; - setFirstEpisodeId(fallbackId); - console.log(`[AnimeCard] Error for ${anime.name}, using fallback ID: ${fallbackId}`); - } - } finally { - setIsLoading(false); - } - } - }; - - fetchFirstEpisode(); - - // Clean up timer if component unmounts - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, [anime?.id, anime?.name, isRecent, firstEpisodeId, isLoading]); - - if (!anime) return null; - - const handleImageError = () => { - console.log("Image error for:", anime.name); - setImageError(true); - }; - - // Get image URL with fallback - const imageSrc = imageError ? '/images/placeholder.png' : anime.poster; - - // Generate appropriate links - const infoLink = `/anime/${anime.id}`; - - // Build the watch URL based on the first episode ID or fallback - const watchLink = isRecent && firstEpisodeId - ? `/watch/${firstEpisodeId}` - : isRecent - ? `/anime/${anime.id}` // Temporarily link to info page while loading - : `/anime/${anime.id}`; // Non-recent anime always link to info - - return ( --- - {/* Schedule list */} --- - {/* Day selector */} -Release Calendar
-- {dateInfo.month} {dateInfo.date} --- {days.map((day) => ( - - ))} --- {isLoading ? ( --- -- ) : scheduleData.length > 0 ? ( -- {scheduleData.map((anime) => ( - -- ) : ( -- {/* Time */} -- - ))} -- {anime.time} -- - {/* Anime info */} ---- {anime.title} -
- {anime.japaneseTitle && ( -- {anime.japaneseTitle} -
- )} -- No releases scheduled -- )} -- {/* Image card linking to watch page for recent anime, or info page otherwise */} - -- ); -} \ No newline at end of file diff --git a/src/components/AnimeDetails.js b/src/components/AnimeDetails.js deleted file mode 100644 index 68c2654..0000000 --- a/src/components/AnimeDetails.js +++ /dev/null @@ -1,585 +0,0 @@ -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import AnimeRow from './AnimeRow'; -import SeasonRow from './SeasonRow'; -import { fetchAnimeEpisodes } from '@/lib/api'; - -export default function AnimeDetails({ anime }) { - const [isExpanded, setIsExpanded] = useState(false); - const [activeVideo, setActiveVideo] = useState(null); - const [activeTab, setActiveTab] = useState('synopsis'); - const [synopsisOverflows, setSynopsisOverflows] = useState(false); - const [firstEpisodeId, setFirstEpisodeId] = useState(null); - const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false); - const synopsisRef = useRef(null); - - // Check if synopsis overflows when component mounts or when content changes - useEffect(() => { - if (synopsisRef.current) { - const element = synopsisRef.current; - setSynopsisOverflows(element.scrollHeight > element.clientHeight); - } - }, [anime?.info?.description, activeTab]); - - // Fetch first episode ID when component mounts - useEffect(() => { - const fetchFirstEpisode = async () => { - if (anime?.info?.id) { - setIsLoadingEpisodes(true); - try { - console.log(`[AnimeDetails] Fetching episodes for anime: ${anime.info.id}`); - const response = await fetchAnimeEpisodes(anime.info.id); - console.log('[AnimeDetails] Episodes response:', response); - - if (response.episodes && response.episodes.length > 0) { - // Log the first episode to check its structure - console.log('[AnimeDetails] First episode:', response.episodes[0]); - - // Get the first episode's id - const firstEp = response.episodes[0]; - if (firstEp.id) { - setFirstEpisodeId(firstEp.id); - console.log(`[AnimeDetails] First episode ID found: ${firstEp.id}`); - } else if (firstEp.episodeId) { - // Fallback to episodeId if id is not available - setFirstEpisodeId(firstEp.episodeId); - console.log(`[AnimeDetails] Falling back to episodeId: ${firstEp.episodeId}`); - } else { - // If no episode ID is found in the API response, create a fallback ID - const fallbackId = `${anime.info.id}?ep=1`; - setFirstEpisodeId(fallbackId); - console.log(`[AnimeDetails] Using fallback ID: ${fallbackId}`); - } - } else if (anime.info.id) { - // If no episodes found but anime ID is available, use fallback - const fallbackId = `${anime.info.id}?ep=1`; - setFirstEpisodeId(fallbackId); - console.log(`[AnimeDetails] No episodes found, using fallback ID: ${fallbackId}`); - } else { - console.warn('[AnimeDetails] No episodes found and no anime ID available'); - } - } catch (error) { - console.error('[AnimeDetails] Error fetching episodes:', error); - // Even on error, try to use fallback - if (anime.info.id) { - const fallbackId = `${anime.info.id}?ep=1`; - setFirstEpisodeId(fallbackId); - console.log(`[AnimeDetails] Error occurred, using fallback ID: ${fallbackId}`); - } - } finally { - setIsLoadingEpisodes(false); - } - } - }; - - fetchFirstEpisode(); - }, [anime?.info?.id]); - - // Add a useEffect to debug when and why firstEpisodeId changes - useEffect(() => { - console.log('[AnimeDetails] firstEpisodeId changed:', firstEpisodeId); - }, [firstEpisodeId]); - - if (!anime?.info) { - return null; - } - - const { info, moreInfo, relatedAnime, recommendations, seasons } = anime; - const hasCharacters = info.characterVoiceActor?.length > 0 || info.charactersVoiceActors?.length > 0; - const hasVideos = info.promotionalVideos && info.promotionalVideos.length > 0; - - // Build the watch URL based on the first episode ID - const watchUrl = firstEpisodeId - ? `/watch/${firstEpisodeId}` - : ''; // Empty string if no episodes available - this shouldn't happen with our fallback - - // Add debug log here - console.log('[AnimeDetails] Rendered with watchUrl:', watchUrl, 'firstEpisodeId:', firstEpisodeId); - - // Video modal for promotional videos - const VideoModal = ({ video, onClose }) => { - if (!video) return null; - - return ( -- {/* Hover overlay */} - - - {/* Play button triangle - appears on hover */} -- - - {/* Title linking to info page */} - -- -- -- - {/* Badges in bottom left */} - - {/* Episode badges */} - {anime.episodes && ( - <> - {anime.episodes.sub > 0 && ( --- SUB {anime.episodes.sub} -- )} - {anime.episodes.dub > 0 && ( -- DUB {anime.episodes.dub} -- )} - > - )} - - {/* Type badge */} - {anime.type && ( -- {anime.type} -- )} -- {anime.name} -
- --- ); - }; - - // Format status with aired date - const getStatusWithAired = () => { - let status = moreInfo?.status || ''; - if (moreInfo?.aired) { - status += ` (${moreInfo.aired})`; - } - return status; - }; - - return ( -- - --- --- {/* Video Modal */} - {activeVideo &&- ); -} \ No newline at end of file diff --git a/src/components/AnimeFilters.js b/src/components/AnimeFilters.js deleted file mode 100644 index 2a7a507..0000000 --- a/src/components/AnimeFilters.js +++ /dev/null @@ -1,597 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { fetchGenres } from '@/lib/api'; -import { ChevronDownIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'; - -// Helper function to capitalize first letter of each word -const capitalizeFirstLetter = (string) => { - if (!string) return ''; - return string.split(' ').map(word => - word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() - ).join(' '); -}; - -export default function AnimeFilters({ - selectedGenre, - onGenreChange, - yearFilter, - onYearChange, - sortOrder, - onSortChange, - showGenreFilter = true, - searchQuery = '', - onSearchChange, - selectedSeasons = [], - onSeasonChange, - selectedTypes = [], - onTypeChange, - selectedStatus = [], - onStatusChange, - selectedLanguages = [], - onLanguageChange -}) { - const [genres, setGenres] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [dropdowns, setDropdowns] = useState({ - genre: false, - season: false, - year: false, - type: false, - status: false, - language: false, - sort: false - }); - const dropdownRefs = useRef({ - genre: null, - season: null, - year: null, - type: null, - status: null, - language: null, - sort: null - }); - - // Available years for filter (current year down to 2000 and 'older') - const currentYear = new Date().getFullYear(); - const years = ['all', ...Array.from({ length: currentYear - 1999 }, (_, i) => (currentYear - i).toString()), 'older']; - - // Seasons data - const seasons = ['Winter', 'Spring', 'Summer', 'Fall']; - - // Types data - const types = ['TV', 'Movie', 'OVA', 'ONA', 'Special']; - - // Status data - const statuses = ['Ongoing', 'Completed', 'Upcoming']; - - // Languages data - const languages = ['Subbed', 'Dubbed', 'Chinese', 'English']; - - // Fetch genres on component mount - useEffect(() => { - const getGenres = async () => { - if (!showGenreFilter) return; - - try { - setIsLoading(true); - const genreData = await fetchGenres(); - // Capitalize each genre - const capitalizedGenres = genreData ? genreData.map(capitalizeFirstLetter) : []; - setGenres(capitalizedGenres); - } catch (error) { - console.error('Error fetching genres:', error); - setError('Failed to load genres. Please try again later.'); - } finally { - setIsLoading(false); - } - }; - - getGenres(); - }, [showGenreFilter]); - - // Toggle dropdown visibility - const toggleDropdown = (dropdown) => { - setDropdowns(prev => { - // Close other dropdowns when opening one - const newState = { - genre: false, - season: false, - year: false, - type: false, - status: false, - language: false, - sort: false, - [dropdown]: !prev[dropdown] - }; - return newState; - }); - }; - - // Initialize refs for each dropdown - useEffect(() => { - dropdownRefs.current = { - genre: dropdownRefs.current.genre, - season: dropdownRefs.current.season, - year: dropdownRefs.current.year, - type: dropdownRefs.current.type, - status: dropdownRefs.current.status, - language: dropdownRefs.current.language, - sort: dropdownRefs.current.sort - }; - }, []); - - // Close all dropdowns when clicking outside - useEffect(() => { - const handleClickOutside = (event) => { - // Check if the click was outside all dropdown containers - let isOutside = true; - Object.keys(dropdownRefs.current).forEach(key => { - if (dropdownRefs.current[key] && dropdownRefs.current[key].contains(event.target)) { - isOutside = false; - } - }); - - if (isOutside) { - setDropdowns({ - genre: false, - season: false, - year: false, - type: false, - status: false, - language: false, - sort: false - }); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - // Prevent dropdown from closing when selecting an item in multiselect - const keepDropdownOpen = (e, dropdown) => { - e.stopPropagation(); - // Don't toggle the dropdown state on item click for multi-select dropdowns - }; - - const handleClearGenre = (e) => { - e.stopPropagation(); - if (onGenreChange) { - onGenreChange(null); - } - }; - - // Toggle multi-select filter - const handleMultiSelectToggle = (type, value, onChange) => { - if (!onChange) return; - - let updatedSelection; - if (type.includes(value)) { - updatedSelection = type.filter(item => item !== value); - } else { - updatedSelection = [...type, value]; - } - onChange(updatedSelection); - }; - - // Modify the onClick handlers for each button to prevent event propagation - const handleGenreSelect = (e, genre) => { - e.stopPropagation(); - if (onGenreChange) { - onGenreChange(genre); - // Close genre dropdown after selection since it's a single select - setDropdowns(prev => ({ ...prev, genre: false })); - } - }; - - const handleYearSelect = (e, year) => { - e.stopPropagation(); - if (onYearChange) { - onYearChange(year); - // Close year dropdown after selection since it's a single select - setDropdowns(prev => ({ ...prev, year: false })); - } - }; - - const handleSortSelect = (e, sort) => { - e.stopPropagation(); - if (onSortChange) { - onSortChange(sort); - // Close sort dropdown after selection since it's a single select - setDropdowns(prev => ({ ...prev, sort: false })); - } - }; - - const handleMultiSelect = (e, type, value, onChange, dropdown) => { - e.stopPropagation(); - let updatedSelection; - if (type.includes(value)) { - updatedSelection = type.filter(item => item !== value); - } else { - updatedSelection = [...type, value]; - } - - if (onChange) { - onChange(updatedSelection); - // Keep dropdown open for multiselect to allow multiple selections - // Without closing the dropdown - } - }; - - // Add clear filter handlers - const clearAllFilters = (e) => { - e.stopPropagation(); - if (onGenreChange) onGenreChange(null); - if (onYearChange) onYearChange('all'); - if (onSortChange) onSortChange('default'); - if (onSeasonChange) onSeasonChange([]); - if (onTypeChange) onTypeChange([]); - if (onStatusChange) onStatusChange([]); - if (onLanguageChange) onLanguageChange([]); - }; - - const clearGenre = (e) => { - e.stopPropagation(); - if (onGenreChange) onGenreChange(null); - }; - - const clearYear = (e) => { - e.stopPropagation(); - if (onYearChange) onYearChange('all'); - }; - - const clearSort = (e) => { - e.stopPropagation(); - if (onSortChange) onSortChange('default'); - }; - - const clearSeasons = (e) => { - e.stopPropagation(); - if (onSeasonChange) onSeasonChange([]); - }; - - const clearTypes = (e) => { - e.stopPropagation(); - if (onTypeChange) onTypeChange([]); - }; - - const clearStatus = (e) => { - e.stopPropagation(); - if (onStatusChange) onStatusChange([]); - }; - - const clearLanguages = (e) => { - e.stopPropagation(); - if (onLanguageChange) onLanguageChange([]); - }; - - // Get display text for filters - const getYearDisplayText = () => { - if (yearFilter === 'all') return 'Year'; - if (yearFilter === 'older') return 'Before 2000'; - return yearFilter; - }; - - const getSortDisplayText = () => { - switch (sortOrder) { - case 'title-asc': return 'Title (A-Z)'; - case 'title-desc': return 'Title (Z-A)'; - case 'year-desc': return 'Newest First'; - case 'year-asc': return 'Oldest First'; - default: return 'Default'; - } - }; - - // Check if any filter is active - const isAnyFilterActive = () => { - return selectedGenre !== null || - yearFilter !== 'all' || - sortOrder !== 'default' || - selectedSeasons.length > 0 || - selectedTypes.length > 0 || - selectedStatus.length > 0 || - selectedLanguages.length > 0; - }; - - return ( -setActiveVideo(null)} />} - - {/* Background Image with Gradient Overlay - Desktop Only */} - - {info.poster && ( - <> -- - {/* Main Content */} -- - > - )} - - {/* MOBILE LAYOUT - Only visible on mobile */} ---- - {/* DESKTOP LAYOUT - Only visible on desktop */} -- {/* Mobile Header with Title + Rating */} ---- - {/* Japanese Title */} - {moreInfo?.japanese && ( -{info.name}
- {info.stats?.rating && ( -- - {info.stats.rating} -- )} -{moreInfo.japanese}
- )} - - {/* Mobile Two-Column Layout */} -- {/* Left Column - Poster */} -- - {/* Watch Button - Mobile */} - {firstEpisodeId && ( - - - Start Watching - - )} --- - {/* Right Column - Info Card */} ------ - {/* Type & Episodes on same row */} --- {info.stats?.type && ( -- - {/* Clean Info Layout */} -{info.stats.type}- )} - - {info.stats?.episodes && ( -- {info.stats.episodes.sub > 0 && `Sub: ${info.stats.episodes.sub}`} - {info.stats.episodes.dub > 0 && info.stats.episodes.sub > 0 && ' • '} - {info.stats.episodes.dub > 0 && `Dub: ${info.stats.episodes.dub}`} -- )} -- {/* Status */} - {moreInfo?.status && ( -- - {/* Mobile Genres */} - {moreInfo?.genres && moreInfo.genres.length > 0 && ( -- Status: - {getStatusWithAired()} -- )} - - {/* Quality */} - {info.stats?.quality && ( -- Quality: - {info.stats.quality} -- )} - - {/* Duration */} - {info.stats?.duration && ( -- Duration: - {info.stats.duration} -- )} - - {/* Studio */} - {moreInfo?.studios && ( -- Studio: - {moreInfo.studios} -- )} -- {moreInfo.genres.slice(0, 5).map((genre, index) => ( - - {genre} - - ))} - {moreInfo.genres.length > 5 && ( - +{moreInfo.genres.length - 5} - )} -- )} -- {/* Poster */} -- - {/* Tabs Section - Different for Mobile/Desktop */} --- - {/* Title and Metadata */} --- - {/* Watch Button - Desktop */} - {firstEpisodeId && ( - - - Start Watching - - )} ---- - {/* Title Section */} ---- - {/* Status Badges */} -- {info.name} -
- - {moreInfo?.japanese && ( -{moreInfo.japanese}
- )} - - {/* Synonyms */} - {moreInfo?.synonyms && ( --- )} -{moreInfo.synonyms}
-- {info.stats?.rating && ( -- - {/* Genres & Studios */} -- - {info.stats.rating} -- )} - - {/* Status with Aired Date */} - {moreInfo?.status && ( -- {getStatusWithAired()} -- )} - - {info.stats?.type && ( -- {info.stats.type} -- )} - - {info.stats?.episodes && ( -- {info.stats.episodes.sub > 0 && `SUB ${info.stats.episodes.sub}`} - {info.stats.episodes.dub > 0 && info.stats.episodes.sub > 0 && ' | '} - {info.stats.episodes.dub > 0 && `DUB ${info.stats.episodes.dub}`} -- )} - - {info.stats?.quality && ( -- {info.stats.quality} -- )} - - {info.stats?.duration && ( -- {info.stats.duration} -- )} -- {/* Genres */} - {moreInfo?.genres && moreInfo.genres.length > 0 && ( ---- )} - - {/* Studios */} - {moreInfo?.studios && ( -Genres
-- {moreInfo.genres.map((genre, index) => ( - - {genre} - - ))} ---- )} -Studios
---- {moreInfo.studios} --- {/* Tab Navigation */} -- - {/* Seasons Section */} - {seasons && seasons.length > 0 && ( -- {/* Synopsis Tab */} - - - {/* Characters Tab */} - {hasCharacters && ( - - )} - - {/* Videos Tab */} - {hasVideos && ( - - )} -- - {/* Tab Content */} -- {/* Synopsis Tab */} - {activeTab === 'synopsis' && ( ---- )} - - {/* Characters Tab */} - {activeTab === 'characters' && hasCharacters && ( -- {info.description || 'No description available for this anime.'} -
- {synopsisOverflows && ( - - )} -- {(info.characterVoiceActor || info.charactersVoiceActors || []).map((item, index) => ( -- )} - - {/* Videos Tab */} - {activeTab === 'videos' && hasVideos && ( -- {/* Character Image */} -- ))} --- - {/* Text content in the middle */} -- -- - {/* Voice Actor Image */} -- {/* Character Name */} ---- - {/* Voice Actor Name */} -{item.character.name}
-{item.character.cast || 'Main'}
---{item.voiceActor.name}
-{item.voiceActor.cast || 'Japanese'}
---- - {info.promotionalVideos.map((video, index) => ( -- )} -setActiveVideo(video)} - > -- ))} ---- --- - )} - - {/* Related Anime Section */} - {relatedAnime && relatedAnime.length > 0 && ( - - )} - - {/* Recommendations Section */} - {recommendations && recommendations.length > 0 && ( - - )} - -- ); -} \ No newline at end of file diff --git a/src/components/AnimeRow.js b/src/components/AnimeRow.js deleted file mode 100644 index e153fb6..0000000 --- a/src/components/AnimeRow.js +++ /dev/null @@ -1,131 +0,0 @@ -'use client'; - -import { useRef, useState, useEffect } from 'react'; -import AnimeCard from './AnimeCard'; - -export default function AnimeRow({ title, animeList }) { - const scrollContainerRef = useRef(null); - const contentRef = useRef(null); - const [showLeftButton, setShowLeftButton] = useState(false); - const [showRightButton, setShowRightButton] = useState(false); - - useEffect(() => { - if (!animeList || animeList.length <= 7) { - setShowRightButton(false); - return; - } - - setShowRightButton(true); - - const checkScroll = () => { - if (!scrollContainerRef.current) return; - - const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current; - setShowLeftButton(scrollLeft > 0); - setShowRightButton(scrollLeft + clientWidth < scrollWidth - 10); - }; - - const scrollContainer = scrollContainerRef.current; - scrollContainer.addEventListener('scroll', checkScroll); - - // Initial check - checkScroll(); - - return () => { - if (scrollContainer) { - scrollContainer.removeEventListener('scroll', checkScroll); - } - }; - }, [animeList]); - - const scroll = (direction) => { - if (!scrollContainerRef.current) return; - - const container = scrollContainerRef.current; - // Calculate single card width based on viewport - const isMobile = window.innerWidth < 640; // sm breakpoint in Tailwind - const cardsPerRow = isMobile ? 3 : 7; - const singleCardWidth = container.clientWidth / cardsPerRow; - - if (direction === 'left') { - container.scrollBy({ left: -singleCardWidth, behavior: 'smooth' }); - } else { - container.scrollBy({ left: singleCardWidth, behavior: 'smooth' }); - } - }; - - if (!animeList || animeList.length === 0) return null; - - // Create groups of cards for pagination - 3 for mobile, 7 for larger screens - const cardGroups = []; - const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640; - const groupSize = isMobileView ? 3 : 7; - - for (let i = 0; i < animeList.length; i += groupSize) { - cardGroups.push(animeList.slice(i, i + groupSize)); - } - - return ( -- {/* Genre Filter */} --dropdownRefs.current.genre = el}> - - {dropdowns.genre && ( -- - {/* Year Filter */} --- )} -- {genres.map((genre) => ( - - ))} --dropdownRefs.current.year = el}> - - {dropdowns.year && ( -- - {/* Season Filter */} --- )} -- {years.map((year) => ( - - ))} --dropdownRefs.current.season = el}> - - {dropdowns.season && ( -- - {/* Format Filter */} -keepDropdownOpen(e, 'season')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl"> - {seasons.map((season) => ( - - ))} -- )} -dropdownRefs.current.type = el}> - - {dropdowns.type && ( -- - {/* Status Filter */} -keepDropdownOpen(e, 'type')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl"> - {types.map((type) => ( - - ))} -- )} -dropdownRefs.current.status = el}> - - {dropdowns.status && ( -- - {/* Language Filter */} -keepDropdownOpen(e, 'status')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl"> - {statuses.map((status) => ( - - ))} -- )} -dropdownRefs.current.language = el}> - - {dropdowns.language && ( -- - {/* Sort Filter */} -keepDropdownOpen(e, 'language')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl"> - {languages.map((language) => ( - - ))} -- )} -dropdownRefs.current.sort = el}> - - {dropdowns.sort && ( -- - {/* Clear All Button - Always visible */} - -- - - - - -- )} --- ); -} \ No newline at end of file diff --git a/src/components/AnimeTabs.js b/src/components/AnimeTabs.js deleted file mode 100644 index 5d878cf..0000000 --- a/src/components/AnimeTabs.js +++ /dev/null @@ -1,86 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import AnimeCard from './AnimeCard'; -import Link from 'next/link'; - -const tabs = [ - { id: 'topAiring', label: 'TOP AIRING' }, - { id: 'popular', label: 'POPULAR' }, - { id: 'latestCompleted', label: 'LATEST COMPLETED' } -]; - -export default function AnimeTabs({ topAiring = [], popular = [], latestCompleted = [] }) { - const [activeTab, setActiveTab] = useState('topAiring'); - - const getActiveList = () => { - switch (activeTab) { - case 'topAiring': - return topAiring; - case 'popular': - return popular; - case 'latestCompleted': - return latestCompleted; - default: - return []; - } - }; - - const getViewAllLink = () => { - switch (activeTab) { - case 'topAiring': - return '/top-airing'; - case 'popular': - return '/most-popular'; - case 'latestCompleted': - return '/latest-completed'; - default: - return '/'; - } - }; - - return ( --- -{title}
-- {showLeftButton && ( - - )} - ---- - {showRightButton && ( - - )} -- {cardGroups.map((group, groupIndex) => ( --- {group.map((anime, index) => ( -- ))} --- ))} - {/* Add empty placeholders if needed to ensure slots are filled */} - {Array.from({ length: (typeof window !== 'undefined' && window.innerWidth < 640) ? - Math.max(0, 3 - group.length) : - Math.max(0, 7 - group.length) }).map((_, index) => ( - - ))} -- - {/* Tabs Navigation */} -- ); -} \ No newline at end of file diff --git a/src/components/EpisodeList.js b/src/components/EpisodeList.js deleted file mode 100644 index c52a8b0..0000000 --- a/src/components/EpisodeList.js +++ /dev/null @@ -1,239 +0,0 @@ -import { useState, useMemo, useEffect } from 'react'; - -export default function EpisodeList({ episodes, currentEpisode, onEpisodeClick, isDub = false }) { - const [currentPage, setCurrentPage] = useState(1); - const [isGridView, setIsGridView] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [activeEpisodeId, setActiveEpisodeId] = useState(null); - const episodesPerPage = 100; - - // Update active episode when currentEpisode changes - useEffect(() => { - if (currentEpisode?.id) { - setActiveEpisodeId(currentEpisode.id); - } - }, [currentEpisode]); - - // Sync with URL to identify current episode - useEffect(() => { - const checkCurrentEpisode = () => { - const path = window.location.pathname; - const match = path.match(/\/watch\/(.+)$/); - if (match) { - const urlEpisodeId = match[1]; - setActiveEpisodeId(urlEpisodeId); - - // Find the episode and update page - const episode = episodes.find(ep => ep.id === urlEpisodeId); - - if (episode) { - const pageNumber = Math.ceil(episode.number / episodesPerPage); - setCurrentPage(pageNumber); - } - } - }; - - // Check initially - checkCurrentEpisode(); - - // Set up listener for URL changes using the History API - const handleUrlChange = () => { - checkCurrentEpisode(); - }; - - window.addEventListener('popstate', handleUrlChange); - - // Clean up - return () => { - window.removeEventListener('popstate', handleUrlChange); - }; - }, [episodes, episodesPerPage]); - - const filteredEpisodes = useMemo(() => { - if (!searchQuery) return episodes; - const query = searchQuery.toLowerCase(); - return episodes.filter(episode => - episode.number.toString().includes(query) || - (episode.title && episode.title.toLowerCase().includes(query)) - ); - }, [episodes, searchQuery]); - - const totalPages = Math.ceil(filteredEpisodes.length / episodesPerPage); - const indexOfLastEpisode = currentPage * episodesPerPage; - const indexOfFirstEpisode = indexOfLastEpisode - episodesPerPage; - const currentEpisodes = filteredEpisodes.slice(indexOfFirstEpisode, indexOfLastEpisode); - - const getPageRange = (pageNum) => { - const start = (pageNum - 1) * episodesPerPage + 1; - const end = Math.min(pageNum * episodesPerPage, filteredEpisodes.length); - return `${start}-${end}`; - }; - - const isCurrentEpisode = (episode) => { - if (!episode || !episode.id || !activeEpisodeId) return false; - return episode.id === activeEpisodeId; - }; - - const handleEpisodeSelect = (episode, e) => { - e.preventDefault(); - if (onEpisodeClick && episode.id) { - // Use the episode ID directly as it's already in the correct format from the API - console.log(`[EpisodeList] Selected episode: ${episode.number}, ID: ${episode.id}`); - onEpisodeClick(episode.id); - setActiveEpisodeId(episode.id); - } - }; - - // Scroll active episode into view when page changes or active episode changes - useEffect(() => { - if (activeEpisodeId) { - setTimeout(() => { - const activeElement = document.querySelector(`[data-episode-id="${activeEpisodeId}"]`); - if (activeElement) { - activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }, 100); - } - }, [activeEpisodeId, currentPage]); - - return ( -- {tabs.map((tab) => ( - - ))} - - View All - - -- - {/* Anime Grid */} -- {getActiveList().slice(0, 18).map((anime, index) => ( --- ))} - - {/* Header */} -- ); -} \ No newline at end of file diff --git a/src/components/GenreBar.js b/src/components/GenreBar.js deleted file mode 100644 index 97543e8..0000000 --- a/src/components/GenreBar.js +++ /dev/null @@ -1,230 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useState, useEffect, useRef, useMemo } from 'react'; -import { fetchGenres } from '@/lib/api'; - -export default function GenreBar() { - const [genres, setGenres] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showLeftButton, setShowLeftButton] = useState(true); // Always show left button initially - const [showRightButton, setShowRightButton] = useState(true); - const [isMobile, setIsMobile] = useState(false); - const scrollContainerRef = useRef(null); - const containerRef = useRef(null); - const [visibleGenres, setVisibleGenres] = useState(14); - - // Function to capitalize first letter - const capitalizeFirstLetter = (string) => { - return string.charAt(0).toUpperCase() + string.slice(1); - }; - - // Predefined genres exactly as specified - wrapped in useMemo to prevent recreation on every render - const defaultGenres = useMemo(() => [ - "Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy", - "Horror", "Mahou Shoujo", "Mecha", "Music", "Mystery", "Psychological", - "Romance", "Sci-Fi", "Slice of Life", "Sports", "Supernatural", "Thriller" - ], []); - - // Handle long names on mobile - const getMobileGenreName = (genre) => { - // Abbreviate long genre names for mobile view - switch(genre) { - case "Psychological": return "Psycho"; - case "Mahou Shoujo": return "Mahou"; - case "Supernatural": return "Super"; - case "Slice of Life": return "SoL"; - default: return genre; - } - }; - - // Detect mobile devices - useEffect(() => { - const checkIfMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkIfMobile(); - window.addEventListener('resize', checkIfMobile); - - return () => window.removeEventListener('resize', checkIfMobile); - }, []); - - // Calculate the number of genres that fit in the container - useEffect(() => { - const calculateVisibleGenres = () => { - const container = containerRef.current; - if (container) { - const containerWidth = container.offsetWidth; - // Approximate width of each genre button - const genreButtonWidth = isMobile ? 72 : 88; // Slightly larger on mobile to fit text - const visibleCount = Math.floor((containerWidth - 80) / genreButtonWidth); - // Minimum genres visible (smaller minimum on mobile) - setVisibleGenres(Math.max(visibleCount, isMobile ? 4 : 8)); - } - }; - - calculateVisibleGenres(); - window.addEventListener('resize', calculateVisibleGenres); - - return () => { - window.removeEventListener('resize', calculateVisibleGenres); - }; - }, [isMobile]); - - useEffect(() => { - // Force scroll position slightly to the right initially - // to ensure there are genres on both sides for scrolling - setTimeout(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollLeft = 40; // Start slightly scrolled - // Trigger scroll event to update button states - scrollContainerRef.current.dispatchEvent(new Event('scroll')); - } - }, 100); - - setGenres(defaultGenres); - setIsLoading(false); - }, [defaultGenres]); - - // Check scroll position to determine button visibility - useEffect(() => { - const handleScroll = () => { - if (scrollContainerRef.current) { - const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current; - - // Show left button if not at the start - setShowLeftButton(scrollLeft > 5); - - // Show right button if not at the end - setShowRightButton(scrollLeft < scrollWidth - clientWidth - 5); - } - }; - - const scrollContainer = scrollContainerRef.current; - if (scrollContainer) { - scrollContainer.addEventListener('scroll', handleScroll); - // Initial check - handleScroll(); - - return () => { - scrollContainer.removeEventListener('scroll', handleScroll); - }; - } - }, []); - - // Scroll left/right functions - const scrollLeft = () => { - if (scrollContainerRef.current) { - const scrollAmount = isMobile ? -80 : -200; - scrollContainerRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }); - } - }; - - const scrollRight = () => { - if (scrollContainerRef.current) { - const scrollAmount = isMobile ? 80 : 200; - scrollContainerRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }); - } - }; - - // Mobile-specific styles - const mobileButtonStyle = { - padding: '0.15rem 0.5rem', - fontSize: '0.65rem', - height: '1.5rem', - minWidth: '4rem', - maxWidth: '5.5rem', - textAlign: 'center', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - display: 'flex', - alignItems: 'center', - justifyContent: 'center' - }; - - if (isLoading) { - return ( --- - {/* Episodes Container */} ---- { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - className="w-full bg-[#2a2a2a] text-white text-sm rounded-lg px-4 py-1.5 pl-9 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] placeholder-gray-500" - /> - --- - ----- {isGridView ? ( - // Grid View --- {currentEpisodes.map((episode) => ( - - ))} -- ) : ( - // List View -- {currentEpisodes.map((episode) => ( - - ))} -- )} --- ); - } - - return ( -- {[...Array(isMobile ? 5 : visibleGenres)].map((_, i) => ( - - ))} --- {/* Left fade effect */} -- ); -} \ No newline at end of file diff --git a/src/components/GenreList.js b/src/components/GenreList.js deleted file mode 100644 index f0c5e71..0000000 --- a/src/components/GenreList.js +++ /dev/null @@ -1,93 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useState, useEffect } from 'react'; -import { fetchGenres } from '@/lib/api'; - -export default function GenreList() { - const [genres, setGenres] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showAll, setShowAll] = useState(false); - - useEffect(() => { - async function loadGenres() { - try { - const genreData = await fetchGenres(); - setGenres(genreData || []); - } catch (error) { - console.error("Error fetching genres:", error); - } finally { - setIsLoading(false); - } - } - - loadGenres(); - }, []); - - // Predefined popular genres if API doesn't return them - const defaultGenres = [ - "Action", "Adventure", "Comedy", "Drama", "Fantasy", - "Horror", "Mystery", "Romance", "Sci-Fi", "Slice of Life", - "Supernatural", "Thriller", "Isekai", "Mecha", "Sports" - ]; - - // Use fetched genres or fallback to default genres - const displayGenres = genres.length > 0 ? genres : defaultGenres; - - // Show only first 12 genres if not showing all - const visibleGenres = showAll ? displayGenres : displayGenres.slice(0, 12); - - if (isLoading) { - return ( --- - {/* Left scroll button - only visible when not at the leftmost position */} - {showLeftButton && ( - - )} - - {/* Scrollable genre container */} --- - {/* Right fade effect */} -- {genres.map((genre) => ( - - {isMobile ? getMobileGenreName(genre) : genre} - - ))} ---- - {/* Right scroll button - only visible when not at the rightmost position */} - {showRightButton && ( - - )} --- ); - } - - return ( ---Genres
-- {[...Array(12)].map((_, i) => ( - - ))} ---- ); -} \ No newline at end of file diff --git a/src/components/Loader/AnimeInfo.loader.jsx b/src/components/Loader/AnimeInfo.loader.jsx new file mode 100644 index 0000000..3a20c22 --- /dev/null +++ b/src/components/Loader/AnimeInfo.loader.jsx @@ -0,0 +1,58 @@ +import { Skeleton } from "@/src/components/ui/Skeleton/Skeleton"; +import CategoryCardLoader from "./CategoryCard.loader"; +import SidecardLoader from "./Sidecard.loader"; + +const SkeletonItems = ({ count, className }) => ( + [...Array(count)].map((_, index) =>-- -Genres
-- {visibleGenres.map((genre) => ( - - {genre} - - ))} -- - {displayGenres.length > 12 && ( -- -- )} -) +); + +function AnimeInfoLoader() { + return ( + <> + +++ +++ +++
++ +++ +++ + +++ +++ +++ +++++ +++ +++ + ++ > + ); +} +export default AnimeInfoLoader; diff --git a/src/components/Loader/AtoZ.loader.jsx b/src/components/Loader/AtoZ.loader.jsx new file mode 100644 index 0000000..8c9ba62 --- /dev/null +++ b/src/components/Loader/AtoZ.loader.jsx @@ -0,0 +1,26 @@ +import { Skeleton } from "../ui/Skeleton/Skeleton"; +import CategoryCardLoader from "./CategoryCard.loader"; + +const SkeletonItems = ({ count, className }) => ( + [...Array(count)].map((_, index) =>+ + ) +); + +function AtoZLoader() { + return ( + ++ ); +} + +export default AtoZLoader; diff --git a/src/components/Loader/Cart.loader.jsx b/src/components/Loader/Cart.loader.jsx new file mode 100644 index 0000000..2467415 --- /dev/null +++ b/src/components/Loader/Cart.loader.jsx @@ -0,0 +1,27 @@ +import { Skeleton } from "../ui/Skeleton/Skeleton" +const SkeletonItems = ({ count, className }) => ( + [...Array(count)].map((_, index) =>+
++ + +++ +++ + ) +); +function CartLoader() { + return ( + ++ ) +} + +export default CartLoader \ No newline at end of file diff --git a/src/components/Loader/Category.loader.jsx b/src/components/Loader/Category.loader.jsx new file mode 100644 index 0000000..45a6b21 --- /dev/null +++ b/src/components/Loader/Category.loader.jsx @@ -0,0 +1,23 @@ +import { Skeleton } from "../ui/Skeleton/Skeleton" +import CategoryCardLoader from "./CategoryCard.loader" +import SidecardLoader from "./Sidecard.loader" + +function CategoryLoader() { + return ( ++ + {[...Array(5)].map((item, index) => ( ++++ ))} ++ +++ +++ + ++ ) +} + +export default CategoryLoader \ No newline at end of file diff --git a/src/components/Loader/CategoryCard.loader.jsx b/src/components/Loader/CategoryCard.loader.jsx new file mode 100644 index 0000000..c170069 --- /dev/null +++ b/src/components/Loader/CategoryCard.loader.jsx @@ -0,0 +1,35 @@ +import { Skeleton } from "../ui/Skeleton/Skeleton"; + +function CategoryCardLoader({ className, showLabelSkeleton = true }) { + return ( ++++ +++ + +++ + + {showLabelSkeleton && ( ++ ); +} + +export default CategoryCardLoader; diff --git a/src/components/Loader/Home.loader.jsx b/src/components/Loader/Home.loader.jsx new file mode 100644 index 0000000..21f8491 --- /dev/null +++ b/src/components/Loader/Home.loader.jsx @@ -0,0 +1,32 @@ +import CartLoader from "./Cart.loader"; +import CategoryCardLoader from "./CategoryCard.loader"; +import SidecardLoader from "./Sidecard.loader"; +import SpotlightLoader from "./Spotlight.loader"; +import Trendingloader from "./Trending.loader"; +function HomeLoader() { + return ( ++ )} + + {[...Array(12)].map((_, index) => ( ++++ ))} ++++ +++ + + +++ + ++ ); +} + +export default HomeLoader; diff --git a/src/components/Loader/Loader.jsx b/src/components/Loader/Loader.jsx new file mode 100644 index 0000000..e6a7886 --- /dev/null +++ b/src/components/Loader/Loader.jsx @@ -0,0 +1,24 @@ +import AnimeInfoLoader from "./AnimeInfo.loader"; +import HomeLoader from "./Home.loader"; +import CategoryLoader from "./Category.loader"; +import AtoZLoader from "./AtoZ.loader"; +import ProducerLoader from "./Producer.loader"; + +const Loader = ({ type }) => { + switch (type) { + case "home": + return+ + +++ + + + +++++ + + +++ + ; + case "animeInfo": + return ; + case "category": + return ; + case "producer": + return ; + case "AtoZ": + return ; + default: + return ; + } +}; + +export default Loader; diff --git a/src/components/Loader/Producer.loader.jsx b/src/components/Loader/Producer.loader.jsx new file mode 100644 index 0000000..b48b908 --- /dev/null +++ b/src/components/Loader/Producer.loader.jsx @@ -0,0 +1,15 @@ +import CategoryCardLoader from "./CategoryCard.loader"; +import SidecardLoader from "./Sidecard.loader"; + +function ProducerLoader() { + return ( + ++ ); +} + +export default ProducerLoader; diff --git a/src/components/Loader/Sidecard.loader.jsx b/src/components/Loader/Sidecard.loader.jsx new file mode 100644 index 0000000..8f9d7d5 --- /dev/null +++ b/src/components/Loader/Sidecard.loader.jsx @@ -0,0 +1,26 @@ +import { Skeleton } from "../ui/Skeleton/Skeleton"; +function SidecardLoader({ className }) { + return ( ++++ + ++ ); +} + +export default SidecardLoader; diff --git a/src/components/Loader/Spotlight.loader.jsx b/src/components/Loader/Spotlight.loader.jsx new file mode 100644 index 0000000..583d74c --- /dev/null +++ b/src/components/Loader/Spotlight.loader.jsx @@ -0,0 +1,34 @@ +import { Skeleton } from "../ui/Skeleton/Skeleton" +const SkeletonItems = ({ count, className }) => ( + [...Array(count)].map((_, index) =>+ + {[...Array(10)].map((_, index) => ( ++++ ))} ++++ +++ +++ + ) +); +function SpotlightLoader() { + return ( + + + ) +} + +export default SpotlightLoader \ No newline at end of file diff --git a/src/components/Loader/Trending.loader.jsx b/src/components/Loader/Trending.loader.jsx new file mode 100644 index 0000000..bf777db --- /dev/null +++ b/src/components/Loader/Trending.loader.jsx @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; +import { Skeleton } from "../ui/Skeleton/Skeleton"; + +function TrendingLoader() { + const [count, setCount] = useState(() => window.innerWidth < 720 ? 3 : window.innerWidth < 1300 ? 4 : 6); + useEffect(() => { + const updateCount = () => { + if (window.innerWidth < 720) { + setCount(3); + } else if (window.innerWidth < 1300) { + setCount(4); + } else { + setCount(6); + } + }; + updateCount(); + window.addEventListener("resize", updateCount); + return () => window.removeEventListener("resize", updateCount); + }, []); + return ( ++++ + +++ +++ +++ +++ + + +++ + ++ ); +} + +export default TrendingLoader; diff --git a/src/components/Loader/VoiceActorlist.loader.jsx b/src/components/Loader/VoiceActorlist.loader.jsx new file mode 100644 index 0000000..f4b975e --- /dev/null +++ b/src/components/Loader/VoiceActorlist.loader.jsx @@ -0,0 +1,21 @@ +import { Skeleton } from "../ui/Skeleton/Skeleton" + +function VoiceActorlistLoader() { + return ( ++ + {[...Array(count)].map((_, index) => ( ++++ ))} ++ + {[...Array(10)].map((_, index) => ( ++ ) +} + +export default VoiceActorlistLoader \ No newline at end of file diff --git a/src/components/Navbar.js b/src/components/Navbar.js deleted file mode 100644 index be281e9..0000000 --- a/src/components/Navbar.js +++ /dev/null @@ -1,628 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import Image from 'next/image'; -import { useState, useEffect, useRef } from 'react'; -import { useRouter } from 'next/navigation'; -import { - fetchSearchSuggestions, - fetchMostPopular, - fetchTopAiring, - fetchRecentEpisodes, - fetchMostFavorite, - fetchTopUpcoming -} from '@/lib/api'; - -export default function Navbar() { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [searchSuggestions, setSearchSuggestions] = useState([]); - const [showSuggestions, setShowSuggestions] = useState(false); - const [isScrolled, setIsScrolled] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [isRandomLoading, setIsRandomLoading] = useState(false); - const suggestionRef = useRef(null); - const searchInputRef = useRef(null); - const router = useRouter(); - - // Track scroll position - useEffect(() => { - const handleScroll = () => { - if (window.scrollY > 10) { - setIsScrolled(true); - } else { - setIsScrolled(false); - } - }; - - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, []); - - // Update suggestions when search query changes - useEffect(() => { - const updateSuggestions = async () => { - // Only search if we have at least 2 characters - if (searchQuery.trim().length >= 2) { - setIsLoading(true); - setShowSuggestions(true); // Always show the suggestions container when typing - - try { - console.log(`Fetching suggestions for: ${searchQuery}`); - const apiSuggestions = await fetchSearchSuggestions(searchQuery); - console.log('API returned:', apiSuggestions); - - if (Array.isArray(apiSuggestions) && apiSuggestions.length > 0) { - // Take top 5 results - setSearchSuggestions(apiSuggestions.slice(0, 5)); - } else { - // Create a generic suggestion based on the search query - setSearchSuggestions([{ - id: searchQuery.toLowerCase().replace(/\s+/g, '-'), - title: `Search for "${searchQuery}"`, - type: "SEARCH", - image: null - }]); - } - } catch (error) { - console.error('Error in search component:', error); - // Create a generic suggestion - setSearchSuggestions([{ - id: searchQuery.toLowerCase().replace(/\s+/g, '-'), - title: `Search for "${searchQuery}"`, - type: "SEARCH", - image: null - }]); - } finally { - setIsLoading(false); - } - } else { - setSearchSuggestions([]); - setShowSuggestions(false); - } - }; - - const debounceTimer = setTimeout(() => { - updateSuggestions(); - }, 300); // 300ms debounce time - - return () => clearTimeout(debounceTimer); - }, [searchQuery]); - - // Close suggestions when clicking outside - useEffect(() => { - const handleClickOutside = (event) => { - if ( - suggestionRef.current && - !suggestionRef.current.contains(event.target) && - !searchInputRef.current?.contains(event.target) - ) { - setShowSuggestions(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - const handleSearch = (e) => { - e.preventDefault(); - // Navigate to search page regardless if search is empty or not - router.push(searchQuery.trim() ? `/search?q=${encodeURIComponent(searchQuery)}` : '/search'); - setSearchQuery(''); - setShowSuggestions(false); - setIsMenuOpen(false); - }; - - // Handle suggestion item click - const handleAnimeClick = (id) => { - router.push(`/anime/${id}`); - setSearchQuery(''); - setShowSuggestions(false); - setIsMenuOpen(false); - }; - - // Handle search by query click - const handleSearchByQueryClick = () => { - router.push(`/search?q=${encodeURIComponent(searchQuery)}`); - setSearchQuery(''); - setShowSuggestions(false); - setIsMenuOpen(false); - }; - - // Helper function to render clear button - const renderClearButton = () => { - if (searchQuery) { - return ( - - ); - } - return null; - }; - - // Function to handle input focus - const handleInputFocus = () => { - if (searchQuery.trim().length >= 2) { - setShowSuggestions(true); - } - }; - - // Function to handle random anime click - const handleRandomAnimeClick = async () => { - setIsRandomLoading(true); - try { - // Randomly select a category to fetch from - const categories = [ - { name: 'Most Popular', fetch: fetchMostPopular }, - { name: 'Top Airing', fetch: fetchTopAiring }, - { name: 'Recent Episodes', fetch: fetchRecentEpisodes }, - { name: 'Most Favorite', fetch: fetchMostFavorite }, - { name: 'Top Upcoming', fetch: fetchTopUpcoming } - ]; - - // Select a random category - const randomCategoryIndex = Math.floor(Math.random() * categories.length); - const selectedCategory = categories[randomCategoryIndex]; - - console.log(`Fetching random anime from: ${selectedCategory.name}`); - - // Fetch anime from the selected category - use a random page number to get more variety - const randomPage = Math.floor(Math.random() * 5) + 1; // Random page between 1-5 - const animeList = await selectedCategory.fetch(randomPage); - - if (animeList && animeList.results && animeList.results.length > 0) { - // Skip the first few results as they tend to be more popular - const skipCount = Math.min(5, Math.floor(animeList.results.length / 3)); - let availableAnime = animeList.results.slice(skipCount); - - if (availableAnime.length === 0) { - // If we've filtered out everything, use the original list - availableAnime = animeList.results; - } - - // Get a random index - const randomAnimeIndex = Math.floor(Math.random() * availableAnime.length); - - // Get the random anime ID - const randomAnimeId = availableAnime[randomAnimeIndex].id; - - console.log(`Selected random anime: ${availableAnime[randomAnimeIndex].title} (ID: ${randomAnimeId})`); - - // Navigate to the anime page - router.push(`/anime/${randomAnimeId}`); - } else { - console.error('No anime found to select randomly from'); - - // Fallback to most popular if the chosen category fails, but use a higher page number - const fallbackPage = Math.floor(Math.random() * 5) + 2; // Pages 2-6 for more obscure options - const fallbackList = await fetchMostPopular(fallbackPage); - - if (fallbackList && fallbackList.results && fallbackList.results.length > 0) { - const randomIndex = Math.floor(Math.random() * fallbackList.results.length); - const randomAnimeId = fallbackList.results[randomIndex].id; - router.push(`/anime/${randomAnimeId}`); - } - } - } catch (error) { - console.error('Error fetching random anime:', error); - } finally { - setIsRandomLoading(false); - } - }; - - return ( - - ); -} \ No newline at end of file diff --git a/src/components/SeasonCard.js b/src/components/SeasonCard.js deleted file mode 100644 index 2123710..0000000 --- a/src/components/SeasonCard.js +++ /dev/null @@ -1,56 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import Link from 'next/link'; -import { useState } from 'react'; - -export default function SeasonCard({ season }) { - const [imageError, setImageError] = useState(false); - - if (!season) return null; - - const handleImageError = () => { - console.log("Image error for:", season.name); - setImageError(true); - }; - - // Get image URL with fallback - const imageSrc = imageError ? '/images/placeholder.png' : season.poster; - - // Generate link - const infoLink = `/anime/${season.id}`; - - return ( - -++ ))} ++++ +++ + - {/* Background image with blur */} -- - ); -} \ No newline at end of file diff --git a/src/components/SeasonRow.js b/src/components/SeasonRow.js deleted file mode 100644 index e54c4b7..0000000 --- a/src/components/SeasonRow.js +++ /dev/null @@ -1,162 +0,0 @@ -'use client'; - -import { useRef, useState, useEffect } from 'react'; -import SeasonCard from './SeasonCard'; - -export default function SeasonRow({ title, seasons }) { - const scrollContainerRef = useRef(null); - const contentRef = useRef(null); - const [showLeftButton, setShowLeftButton] = useState(false); - const [showRightButton, setShowRightButton] = useState(false); - - useEffect(() => { - if (!seasons || seasons.length <= 7) { - setShowRightButton(false); - return; - } - - setShowRightButton(true); - - const checkScroll = () => { - if (!scrollContainerRef.current) return; - - const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current; - setShowLeftButton(scrollLeft > 0); - setShowRightButton(scrollLeft + clientWidth < scrollWidth - 10); - }; - - const scrollContainer = scrollContainerRef.current; - scrollContainer.addEventListener('scroll', checkScroll); - - // Initial check - checkScroll(); - - return () => { - if (scrollContainer) { - scrollContainer.removeEventListener('scroll', checkScroll); - } - }; - }, [seasons]); - - // Updated effect to handle mobile view arrows - useEffect(() => { - if (!seasons) return; - - // Check if we're on mobile and have more than 3 seasons - const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640; - const showArrowsOnMobile = isMobileView && seasons.length > 3; - - // On desktop, show arrows if more than 7 seasons - const showArrowsOnDesktop = !isMobileView && seasons.length > 7; - - if (showArrowsOnMobile || showArrowsOnDesktop) { - setShowRightButton(true); - } else { - setShowRightButton(false); - } - - // Listen for resize events to update arrow visibility - const handleResize = () => { - const isMobile = window.innerWidth < 640; - const showArrows = isMobile ? seasons.length > 3 : seasons.length > 7; - setShowRightButton(showArrows); - }; - - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [seasons]); - - const scroll = (direction) => { - if (!scrollContainerRef.current) return; - - const container = scrollContainerRef.current; - // Calculate single card width based on viewport - const isMobile = window.innerWidth < 640; // sm breakpoint in Tailwind - const cardsPerRow = isMobile ? 3 : 7; - const singleCardWidth = container.clientWidth / cardsPerRow; - - if (direction === 'left') { - container.scrollBy({ left: -singleCardWidth, behavior: 'smooth' }); - } else { - container.scrollBy({ left: singleCardWidth, behavior: 'smooth' }); - } - }; - - if (!seasons || seasons.length === 0) return null; - - // Create groups of cards for pagination - 3 for mobile, 7 for larger screens - const seasonGroups = []; - const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640; - const groupSize = isMobileView ? 3 : 7; - - for (let i = 0; i < seasons.length; i += groupSize) { - seasonGroups.push(seasons.slice(i, i + groupSize)); - } - - return ( -- -- - {/* Content overlay */} -- ----- {season.title || season.name} -
--- ); -} \ No newline at end of file diff --git a/src/components/SharedLayout.js b/src/components/SharedLayout.js deleted file mode 100644 index f42d5f2..0000000 --- a/src/components/SharedLayout.js +++ /dev/null @@ -1,86 +0,0 @@ -'use client'; - -import Navbar from './Navbar'; -import Image from 'next/image'; - -const Footer = () => { - return ( - - ); -}; - -export default function SharedLayout({ children }) { - return ( - <> --- -{title || 'Seasons'}
-- {showLeftButton && ( - - )} - ---- - {showRightButton && ( - - )} -- {seasonGroups.map((group, groupIndex) => ( --- {group.map((season, index) => ( -- ))} --- ))} - {/* Add empty placeholders if needed to ensure slots are filled */} - {Array.from({ length: (typeof window !== 'undefined' && window.innerWidth < 640) ? - Math.max(0, 3 - group.length) : - Math.max(0, 7 - group.length) }).map((_, index) => ( - - ))} -- - - {children} - - - > - ); -} \ No newline at end of file diff --git a/src/components/SpotlightCarousel.js b/src/components/SpotlightCarousel.js deleted file mode 100644 index 35891c1..0000000 --- a/src/components/SpotlightCarousel.js +++ /dev/null @@ -1,371 +0,0 @@ -'use client'; - -import React, { useEffect, useState, useRef } from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -import { Swiper, SwiperSlide } from 'swiper/react'; -import { Autoplay, Navigation, Pagination, EffectFade } from 'swiper/modules'; -import { fetchAnimeEpisodes } from '@/lib/api'; - -// Import Swiper styles -import 'swiper/css'; -import 'swiper/css/navigation'; -import 'swiper/css/pagination'; -import 'swiper/css/effect-fade'; - -const SpotlightCarousel = ({ items = [] }) => { - const [isClient, setIsClient] = useState(false); - const [currentIndex, setCurrentIndex] = useState(0); - const [autoplay, setAutoplay] = useState(true); - const [progress, setProgress] = useState(0); - const [episodeIds, setEpisodeIds] = useState({}); - const [loadingItems, setLoadingItems] = useState({}); - const intervalRef = useRef(null); - const progressIntervalRef = useRef(null); - - // Handle hydration mismatch - useEffect(() => { - setIsClient(true); - }, []); - - // Fetch first episode IDs for all spotlight items - useEffect(() => { - const fetchEpisodeData = async () => { - // Create a copy to track what we're loading - const newLoadingItems = { ...loadingItems }; - const episodeData = { ...episodeIds }; - - for (const item of items) { - // Skip if we already have the episode ID or if it's already loading - if (item.id && !episodeData[item.id] && !newLoadingItems[item.id]) { - newLoadingItems[item.id] = true; - } - } - - // Update loading state - setLoadingItems(newLoadingItems); - - // Process items that need to be loaded - for (const item of items) { - if (item.id && !episodeData[item.id] && newLoadingItems[item.id]) { - try { - console.log(`[SpotlightCarousel] Fetching episodes for anime: ${item.id}`); - const response = await fetchAnimeEpisodes(item.id); - console.log(`[SpotlightCarousel] Episodes response for ${item.name}:`, response); - - if (response.episodes && response.episodes.length > 0) { - // Check for episode ID in the expected format - const firstEp = response.episodes[0]; - if (firstEp.id) { - episodeData[item.id] = firstEp.id; - console.log(`[SpotlightCarousel] Found episode ID (id) for ${item.name}: ${firstEp.id}`); - } else if (firstEp.episodeId) { - episodeData[item.id] = firstEp.episodeId; - console.log(`[SpotlightCarousel] Found episode ID (episodeId) for ${item.name}: ${firstEp.episodeId}`); - } else { - // Create a fallback ID if neither id nor episodeId are available - episodeData[item.id] = `${item.id}?ep=1`; - console.log(`[SpotlightCarousel] Using fallback ID for ${item.name}: ${item.id}?ep=1`); - } - } else { - // If no episodes, use a fallback - episodeData[item.id] = `${item.id}?ep=1`; - console.log(`[SpotlightCarousel] No episodes for ${item.name}, using fallback: ${item.id}?ep=1`); - } - } catch (error) { - console.error(`[SpotlightCarousel] Error fetching episodes for ${item.id}:`, error); - // Even on error, try to use fallback - episodeData[item.id] = `${item.id}?ep=1`; - } finally { - // Mark as no longer loading - newLoadingItems[item.id] = false; - } - } - } - - // Update states - setEpisodeIds(episodeData); - setLoadingItems(newLoadingItems); - }; - - if (items && items.length > 0) { - fetchEpisodeData(); - } - - // Clean up function - return () => { - if (intervalRef.current) clearTimeout(intervalRef.current); - if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); - }; - }, [items, episodeIds, loadingItems]); - - // Autoplay functionality - useEffect(() => { - if (autoplay && items.length > 1) { - // Clear any existing intervals - if (intervalRef.current) clearInterval(intervalRef.current); - if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); - - // Set up new intervals - setProgress(0); - progressIntervalRef.current = setInterval(() => { - setProgress(prev => { - const newProgress = prev + 1; - return newProgress <= 100 ? newProgress : prev; - }); - }, 50); // Update every 50ms to get smooth progress - - intervalRef.current = setTimeout(() => { - setCurrentIndex(prevIndex => (prevIndex + 1) % items.length); - setProgress(0); - }, 5000); - } - - return () => { - if (intervalRef.current) clearTimeout(intervalRef.current); - if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); - }; - }, [autoplay, currentIndex, items.length]); - - const handleDotClick = (index) => { - setCurrentIndex(index); - setProgress(0); - // Reset autoplay timer when manually changing slides - if (intervalRef.current) clearTimeout(intervalRef.current); - if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); - if (autoplay) { - intervalRef.current = setTimeout(() => { - setCurrentIndex((index + 1) % items.length); - }, 5000); - } - }; - - const handleMouseEnter = () => setAutoplay(false); - const handleMouseLeave = () => setAutoplay(true); - - // If no items or not on client yet, show loading state - if (!isClient || !items.length) { - return ( --- ); - } - - const currentItem = items[currentIndex]; - - // Get the watch URL for the current item - const watchUrl = episodeIds[currentItem.id] - ? `/watch/${episodeIds[currentItem.id]}` - : `/anime/${currentItem.id}`; // Direct to anime info if no episode ID - - return ( -- - ---- ); -}; - -export default SpotlightCarousel; \ No newline at end of file diff --git a/src/components/TopLists.js b/src/components/TopLists.js deleted file mode 100644 index 40903ea..0000000 --- a/src/components/TopLists.js +++ /dev/null @@ -1,159 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; - -export default function TopLists({ topToday = [], topWeek = [], topMonth = [] }) { - const [activeTab, setActiveTab] = useState('today'); - - const tabs = [ - { id: 'today', label: 'Today', data: topToday }, - { id: 'week', label: 'Week', data: topWeek }, - { id: 'month', label: 'Month', data: topMonth }, - ]; - - // Add custom scrollbar styles - useEffect(() => { - // Add custom styles for the toplists scrollbar - const style = document.createElement('style'); - style.textContent = ` - .toplists-scrollbar::-webkit-scrollbar { - width: 4px; - } - .toplists-scrollbar::-webkit-scrollbar-track { - background: var(--card); - } - .toplists-scrollbar::-webkit-scrollbar-thumb { - background-color: var(--border); - border-radius: 4px; - } - `; - document.head.appendChild(style); - - // Cleanup function - return () => { - document.head.removeChild(style); - }; - }, []); - - // Find the active tab data - const activeTabData = tabs.find(tab => tab.id === activeTab)?.data || []; - - return ( -{ - setCurrentIndex(swiper.realIndex); - setProgress(0); - // Reset autoplay timer when manually changing slides - if (intervalRef.current) clearTimeout(intervalRef.current); - if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); - if (autoplay) { - intervalRef.current = setTimeout(() => { - setCurrentIndex((swiper.realIndex + 1) % items.length); - }, 5000); - } - }} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - > - {items.map((anime, index) => ( - - - -- - ))} -- {/* Background Image */} --- - {/* Gradient Overlay */} - - - {/* Content Area */} - --- {/* Left Side Content */} --- {/* Metadata first - Minimal boxed design */} -- - {/* Buttons - Below title on mobile, right side on desktop */} -- {anime.otherInfo?.map((info, i) => ( - - {info} - - ))} - - {anime.episodes && ( - <> - {anime.episodes.sub > 0 && ( - - SUB {anime.episodes.sub} - - )} - {anime.episodes.dub > 0 && ( - - DUB {anime.episodes.dub} - - )} - > - )} -- - {/* Title second */} -- {anime.name || 'Anime Title'} -
- - {/* Japanese Title */} - {anime.jname && ( -- {anime.jname} -
- )} - - {/* Description third - hidden on mobile, shown on desktop with exactly 3 lines */} -- {anime.description || 'No description available.'} -
-- {/* Watch button - Uses episodeIds[anime.id] if available, otherwise links to anime details */} - - - WATCH NOW - - - - - DETAILS - ---- ); -} \ No newline at end of file diff --git a/src/components/TrendingList.js b/src/components/TrendingList.js deleted file mode 100644 index a8f0bfb..0000000 --- a/src/components/TrendingList.js +++ /dev/null @@ -1,55 +0,0 @@ -'use client'; - -import React from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; - -export default function TrendingList({ trendingAnime = [] }) { - return ( --- - {/* Tabs */} -- --Top 10 Anime
-- {tabs.map((tab) => ( - - ))} -- - {/* List content */} -- {activeTabData.length === 0 ? ( --- -- ) : ( -No data available
-Check another tab or come back later
-- {activeTabData.slice(0, 10).map((anime, index) => ( - - {/* Top rank highlight for top 3 */} - {index < 3 && ( - - )} - -- )} -- {/* Rank number with monochrome styling */} -- - ))} -- - {anime.rank || index + 1} - -- - {/* Anime thumbnail with subtle shadow */} --- - {/* Info */} -- - {/* Title */} ---- - {/* Episodes if available */} - {anime.episodes && ( -- {anime.name} -
-- {anime.episodes.sub > 0 && ( - - SUB {anime.episodes.sub} - - )} - {anime.episodes.dub > 0 && ( - - DUB {anime.episodes.dub} - - )} -- )} --- ); -} \ No newline at end of file diff --git a/src/components/VideoPlayer.js b/src/components/VideoPlayer.js deleted file mode 100644 index 62c3d06..0000000 --- a/src/components/VideoPlayer.js +++ /dev/null @@ -1,1542 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import Hls from 'hls.js'; -import NextImage from 'next/image'; - -export default function VideoPlayer({ src, poster, headers = {}, subtitles = [], thumbnails = null, category = 'sub', intro = null, outro = null, autoSkipIntro = false, autoSkipOutro = false, episodeId }) { - const videoRef = useRef(null); - const [isPlaying, setIsPlaying] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [progress, setProgress] = useState(0); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [volume, setVolume] = useState(1); - const [showControls, setShowControls] = useState(true); - const [videoHeaders, setVideoHeaders] = useState(headers); - const [thumbnailPreview, setThumbnailPreview] = useState(null); - const [previewPosition, setPreviewPosition] = useState(0); - const [showPreview, setShowPreview] = useState(false); - const [isDubbed, setIsDubbed] = useState(category === 'dub'); - const [showSkipIntro, setShowSkipIntro] = useState(false); - const [showSkipOutro, setShowSkipOutro] = useState(false); - - // New state variables - const [playbackSpeed, setPlaybackSpeed] = useState(1); - const [showSettings, setShowSettings] = useState(false); - const [showSubtitles, setShowSubtitles] = useState(true); - const [activeSubtitle, setActiveSubtitle] = useState(null); - const [qualities, setQualities] = useState([]); - const [currentQuality, setCurrentQuality] = useState(null); - const [showQualityOptions, setShowQualityOptions] = useState(false); - const [showSpeedOptions, setShowSpeedOptions] = useState(false); - const [showSubtitleOptions, setShowSubtitleOptions] = useState(false); - const [audioBoost, setAudioBoost] = useState(100); - const [settingsView, setSettingsView] = useState('main'); // 'main', 'quality', 'speed', 'audio' - - // Add state for buffering and last buffer update - const [isBuffering, setIsBuffering] = useState(false); - const [lastBufferUpdate, setLastBufferUpdate] = useState(Date.now()); - const bufferCheckInterval = useRef(null); - const [bufferedProgress, setBufferedProgress] = useState(0); - const [isCastAvailable, setIsCastAvailable] = useState(false); - const [castState, setCastState] = useState('NO_DEVICES_AVAILABLE'); - const [previewTime, setPreviewTime] = useState(0); - const [thumbnailsLoaded, setThumbnailsLoaded] = useState(false); - - // Add state for mouse movement - const [isMouseMoving, setIsMouseMoving] = useState(false); - const hideControlsTimer = useRef(null); - - // Add isMobile state - const [isMobile, setIsMobile] = useState(false); - - // Detect mobile device on mount - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); // matches Tailwind's md breakpoint - }; - - checkMobile(); - window.addEventListener('resize', checkMobile); - - return () => window.removeEventListener('resize', checkMobile); - }, []); - - // Reset video state when episodeId changes - useEffect(() => { - setIsPlaying(false); - setIsLoading(true); - setError(null); - setProgress(0); - setCurrentTime(0); - setDuration(0); - setBufferedProgress(0); - - // Reset video element - if (videoRef.current) { - videoRef.current.currentTime = 0; - } - }, [episodeId]); - - // Handle video click based on device type - const handleVideoClick = () => { - if (isMobile) { - setShowControls(!showControls); - } else { - togglePlay(); - } - }; - - // Set headers from props when they change - useEffect(() => { - setVideoHeaders(headers); - console.log('Headers updated from props:', headers); - }, [headers]); - - // Format time from seconds to MM:SS - const formatTime = (timeInSeconds) => { - if (isNaN(timeInSeconds)) return '00:00'; - const minutes = Math.floor(timeInSeconds / 60); - const seconds = Math.floor(timeInSeconds % 60); - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - }; - - // Event handlers - const onTimeUpdate = () => { - const video = videoRef.current; - if (!video) return; - const progress = (video.currentTime / video.duration) * 100; - setProgress(isNaN(progress) ? 0 : progress); - setCurrentTime(video.currentTime); - - // Show/hide skip intro button - if (intro) { - const isInIntro = video.currentTime >= intro.start && video.currentTime < intro.end; - setShowSkipIntro(isInIntro); - - // Auto skip intro if enabled - if (isInIntro && autoSkipIntro) { - video.currentTime = intro.end; - } - } - - // Show/hide skip outro button - if (outro) { - const isInOutro = video.currentTime >= outro.start && video.currentTime < outro.end; - setShowSkipOutro(isInOutro); - - // Auto skip outro if enabled - if (isInOutro && autoSkipOutro) { - video.currentTime = outro.end; - } - } - }; - - const onDurationChange = () => { - const video = videoRef.current; - if (!video) return; - setDuration(video.duration); - }; - - const onLoadedData = () => { - setIsLoading(false); - }; - - const onError = () => { - setError('Error playing video'); - setIsLoading(false); - }; - - const onEnded = () => { - setIsPlaying(false); - }; - - // Toggle play/pause - const togglePlay = () => { - const video = videoRef.current; - if (!video) return; - - if (isPlaying) { - video.pause(); - } else { - video.play(); - } - setIsPlaying(!isPlaying); - }; - - // Change current time on seek - const handleSeek = (e) => { - const video = videoRef.current; - if (!video) return; - - const rect = e.currentTarget.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - video.currentTime = percent * video.duration; - }; - - // Change volume - const handleVolumeChange = (e) => { - const video = videoRef.current; - if (!video) return; - - const newVolume = parseFloat(e.target.value); - video.volume = newVolume; - setVolume(newVolume); - }; - - // Function to get highest quality level - const getHighestQuality = () => { - if (!qualities.length) return -1; - const sortedQualities = [...qualities].sort((a, b) => (b.height || 0) - (a.height || 0)); - return sortedQualities[0].id; - }; - - // Function to get next lower quality level - const getNextLowerQuality = (currentId) => { - if (!qualities.length) return -1; - const sortedQualities = [...qualities].sort((a, b) => (b.height || 0) - (a.height || 0)); - const currentIndex = sortedQualities.findIndex(q => q.id === currentId); - if (currentIndex < sortedQualities.length - 1) { - return sortedQualities[currentIndex + 1].id; - } - return currentId; - }; - - // Set up HLS.js for M3U8 streams - useEffect(() => { - if (!src) return; - - const video = videoRef.current; - if (!video) return; - - let hls = null; - - const setupHls = async () => { - try { - setIsLoading(true); - setError(null); - - // Check if the source is an M3U8 stream - const isHlsStream = src.includes('.m3u8') || src.includes('application/vnd.apple.mpegurl'); - - if (isHlsStream && Hls.isSupported()) { - console.log('[VideoPlayer] HLS is supported, initializing'); - - hls = new Hls({ - debug: true, - maxBufferLength: 30, - maxMaxBufferLength: 60, - enableWorker: false, // Disable worker for easier debugging - startLevel: -1, // Start with Auto quality - autoLevelEnabled: true, // Enable auto quality switching - abrEwmaDefaultEstimate: 500000, // Start with a higher bandwidth estimate - abrBandWidthFactor: 0.95, // Be more aggressive with quality upgrades - abrBandWidthUpFactor: 0.7, // Be more conservative with quality downgrades - xhrSetup: function(xhr, url) { - console.log('HLS attempting to load:', url); - - // Apply headers directly to all requests - if (!url.startsWith('blob:') && url.includes('://')) { - Object.entries(videoHeaders).forEach(([key, value]) => { - xhr.setRequestHeader(key, value); - }); - } - } - }); - - window.hls = hls; // Save reference for debugging - - // Bind HLS to video element - const proxiedSrc = src; - console.log('[VideoPlayer] Loading proxied source:', proxiedSrc); - hls.loadSource(proxiedSrc); - hls.attachMedia(video); - - // Handle HLS events - hls.on(Hls.Events.MANIFEST_PARSED, () => { - console.log('[VideoPlayer] HLS manifest parsed'); - // Get available qualities - const levels = hls.levels.map((level, index) => ({ - id: index, - label: `${level.height}p`, - height: level.height, - selected: index === hls.currentLevel - })); - - console.log('[VideoPlayer] Available qualities:', levels); - setQualities(levels); - setCurrentQuality(hls.currentLevel); - - // Auto-play when ready - if (isPlaying) { - video.play().catch(err => { - console.error('[VideoPlayer] Autoplay error:', err); - }); - } - }); - - hls.on(Hls.Events.ERROR, (event, data) => { - console.error('[VideoPlayer] HLS error:', event, data); - - if (data.fatal) { - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - console.error('[VideoPlayer] HLS network error, attempting recovery'); - hls.startLoad(); - break; - case Hls.ErrorTypes.MEDIA_ERROR: - console.error('[VideoPlayer] HLS media error, attempting recovery'); - hls.recoverMediaError(); - break; - default: - console.error('[VideoPlayer] HLS fatal error, cannot recover'); - setError('Failed to load video - please try another server'); - break; - } - } - }); - } else { - // For non-HLS streams, use native video player - console.log('[VideoPlayer] Using native player for source:', src); - - // Set headers for direct video requests - const fetchOptions = { - headers: videoHeaders - }; - - try { - const response = await fetch(src, fetchOptions); - if (!response.ok) { - console.error('[VideoPlayer] Failed to fetch video:', response.status, response.statusText); - throw new Error('Failed to load video'); - } - - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - video.src = url; - - // Auto-play when ready - if (isPlaying) { - video.play().catch(err => { - console.error('[VideoPlayer] Autoplay error:', err); - }); - } - } catch (error) { - console.error('[VideoPlayer] Error loading video:', error); - setError('Failed to load video - please try another server'); - } - } - - setIsLoading(false); - } catch (error) { - console.error('[VideoPlayer] Error setting up video:', error); - setError('Failed to load video - please try another server'); - setIsLoading(false); - } - }; - - setupHls(); - - // Cleanup - return () => { - if (hls) { - hls.destroy(); - } - }; - }, [src, videoHeaders, isPlaying]); - - // Add event listeners - useEffect(() => { - const video = videoRef.current; - if (!src || src === 'undefined' || src.includes('undefined')) { - setError('No valid video source provided'); - setIsLoading(false); - return; - } - - setIsLoading(true); - - const setupHls = async () => { - if (Hls.isSupported()) { - if (hls) { - hls.destroy(); - } - - hls = new Hls({ - debug: true, - maxBufferLength: 30, - maxMaxBufferLength: 60, - enableWorker: false, // Disable worker for easier debugging - startLevel: -1, // Start with Auto quality - autoLevelEnabled: true, // Enable auto quality switching - abrEwmaDefaultEstimate: 500000, // Start with a higher bandwidth estimate - abrBandWidthFactor: 0.95, // Be more aggressive with quality upgrades - abrBandWidthUpFactor: 0.7, // Be more conservative with quality downgrades - xhrSetup: function(xhr, url) { - console.log('HLS attempting to load:', url); - - // Apply headers directly to all requests - if (!url.startsWith('blob:') && url.includes('://')) { - Object.entries(videoHeaders).forEach(([key, value]) => { - xhr.setRequestHeader(key, value); - }); - } - } - }); - - // Make hls instance globally accessible for quality switching - window.hls = hls; - - hls.on(Hls.Events.MEDIA_ATTACHED, () => { - console.log('Video and HLS attached'); - }); - - hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => { - console.log('Manifest parsed successfully'); - setIsLoading(false); - - // Extract available quality levels - if (data && data.levels && data.levels.length > 0) { - const availableQualities = data.levels.map((level, index) => ({ - id: index, - height: level.height, - width: level.width, - bitrate: level.bitrate, - name: level.height ? `${level.height}p` : `Quality ${index + 1}` - })); - - setQualities(availableQualities); - setCurrentQuality({ id: -1, name: 'Auto' }); // Set to Auto by default - console.log('Available qualities:', availableQualities); - - // Start with highest quality in Auto mode - const highestQuality = getHighestQuality(); - if (highestQuality !== -1) { - hls.nextLevel = highestQuality; - } - } - - video.play().catch(e => { - console.warn('Auto-play was prevented:', e); - setIsPlaying(false); - }); - }); - - // Monitor buffering state - hls.on(Hls.Events.FRAG_BUFFERED, () => { - setIsBuffering(false); - setLastBufferUpdate(Date.now()); - }); - - hls.on(Hls.Events.BUFFER_STALLED, () => { - setIsBuffering(true); - }); - - hls.on(Hls.Events.ERROR, (event, data) => { - console.error('HLS error details:', data); - - if (data.fatal) { - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - console.error('Network error:', data.details); - console.error('Error with URL:', data.url); - - if (data.response && data.response.code === 403) { - setError(`Access denied (403 Forbidden). Please check your headers.`); - setIsLoading(false); - return; - } - - if (data.response && data.response.code === 404) { - setError(`Video not found (404). Please try another server.`); - setIsLoading(false); - return; - } - - console.log('Network error, trying to recover...'); - hls.startLoad(); - break; - case Hls.ErrorTypes.MEDIA_ERROR: - console.log('Media error, trying to recover...'); - hls.recoverMediaError(); - break; - default: - setError('Failed to load video: ' + (data.details || 'Unknown error')); - setIsLoading(false); - break; - } - } - }); - - console.log('Loading source:', proxiedSrc); - hls.loadSource(proxiedSrc); - hls.attachMedia(video); - - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - // For Safari on iOS/Mac - console.log('Using native HLS support'); - video.src = proxiedSrc; - - video.addEventListener('loadedmetadata', () => { - setIsLoading(false); - video.play().catch(e => { - console.warn('Auto-play was prevented:', e); - setIsPlaying(false); - }); - }); - - } else { - // Direct fallback for non-HLS browsers - console.log('No HLS support, trying direct playback'); - try { - video.src = proxiedSrc; - setIsLoading(false); - } catch (e) { - console.error('Error setting video source:', e); - setError('Your browser cannot play this video'); - setIsLoading(false); - } - } - }; - - setupHls(); - - // Set up buffer monitoring - bufferCheckInterval.current = setInterval(() => { - if (isBuffering && Date.now() - lastBufferUpdate > 5000) { - // If buffering for more than 5 seconds, try switching to lower quality - if (window.hls && currentQuality?.id !== -1) { - const nextLowerQuality = getNextLowerQuality(window.hls.currentLevel); - if (nextLowerQuality !== window.hls.currentLevel) { - window.hls.nextLevel = nextLowerQuality; - console.log('Switching to lower quality due to buffering:', nextLowerQuality); - } - } - } - }, 1000); - - // Cleanup function - return () => { - if (hls) { - hls.destroy(); - } - if (bufferCheckInterval.current) { - clearInterval(bufferCheckInterval.current); - } - }; - }, [src, videoHeaders]); - - // Add event listeners - useEffect(() => { - const video = videoRef.current; - if (!video) return; - - video.addEventListener('timeupdate', onTimeUpdate); - video.addEventListener('durationchange', onDurationChange); - video.addEventListener('loadeddata', onLoadedData); - video.addEventListener('error', onError); - video.addEventListener('ended', onEnded); - - return () => { - video.removeEventListener('timeupdate', onTimeUpdate); - video.removeEventListener('durationchange', onDurationChange); - video.removeEventListener('loadeddata', onLoadedData); - video.removeEventListener('error', onError); - video.removeEventListener('ended', onEnded); - }; - }, []); - - // Toggle fullscreen - const toggleFullscreen = () => { - const container = videoRef.current?.parentElement; - if (!container) return; - - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - container.requestFullscreen().catch((err) => { - console.error('Error attempting to enable fullscreen:', err); - }); - } - }; - - // Skip forward 10 seconds - const skipForward = () => { - const video = videoRef.current; - if (!video) return; - video.currentTime = Math.min(video.currentTime + 10, video.duration); - }; - - // Skip backward 10 seconds - const skipBackward = () => { - const video = videoRef.current; - if (!video) return; - video.currentTime = Math.max(video.currentTime - 10, 0); - }; - - // Change playback speed - const changePlaybackSpeed = (speed) => { - const video = videoRef.current; - if (!video) return; - video.playbackRate = speed; - setPlaybackSpeed(speed); - setShowSpeedOptions(false); - }; - - // Toggle subtitles - const toggleSubtitles = () => { - setShowSubtitles(!showSubtitles); - }; - - // Initialize subtitle tracks when the video element is created - useEffect(() => { - const video = videoRef.current; - if (!video || !activeSubtitle) return; - - console.log('[VideoPlayer] Initializing subtitle tracks on mount'); - - // Apply the active subtitle - if (activeSubtitle && showSubtitles) { - // First remove any existing tracks - const existingTracks = video.querySelectorAll('track'); - existingTracks.forEach(track => track.remove()); - - // Create and append the track element - const track = document.createElement('track'); - track.kind = 'subtitles'; - track.label = activeSubtitle.label || activeSubtitle.lang || 'Default'; - track.srclang = activeSubtitle.lang || 'en'; - - // Format subtitle URL correctly - it might be in different formats - let subtitleUrl = activeSubtitle.src || activeSubtitle.url; - - // Use subtitle URL directly - track.src = subtitleUrl; - track.default = true; - - console.log('[VideoPlayer] Adding track on mount with src:', track.src); - video.appendChild(track); - - // Force the track to be active after a short delay - setTimeout(() => { - if (video.textTracks && video.textTracks.length > 0) { - console.log('[VideoPlayer] Activating text track'); - video.textTracks[0].mode = 'showing'; - } - }, 500); - } - }, [videoRef.current, activeSubtitle, showSubtitles]); - - // This function manually applies the selected subtitle to the video - const applySubtitle = (subtitle) => { - console.log('[VideoPlayer] Applying subtitle:', JSON.stringify(subtitle)); - if (!videoRef.current || !subtitle) return; - - // Ensure we have valid URL - let subtitleUrl = subtitle.src || subtitle.url; - if (!subtitleUrl) { - console.error('[VideoPlayer] No valid URL found in subtitle:', JSON.stringify(subtitle)); - return; - } - - console.log('[VideoPlayer] Final subtitle URL:', subtitleUrl); - - // Remove all existing tracks - const video = videoRef.current; - const existingTracks = video.querySelectorAll('track'); - existingTracks.forEach(track => track.remove()); - - // Create a new track element - const track = document.createElement('track'); - track.kind = 'subtitles'; - track.label = subtitle.label || subtitle.lang || 'Unknown'; - track.srclang = subtitle.lang || 'en'; - track.src = subtitleUrl; - track.default = true; - - // Add track to video - video.appendChild(track); - - // Force enable the track - setTimeout(() => { - if (video.textTracks && video.textTracks.length > 0) { - video.textTracks[0].mode = 'showing'; - console.log('[VideoPlayer] Track enabled, mode:', video.textTracks[0].mode); - } else { - console.error('[VideoPlayer] No text tracks available after adding track'); - } - }, 100); - }; - - // Handle subtitle change from UI - const changeSubtitle = (subtitle) => { - console.log('[VideoPlayer] changeSubtitle called with:', JSON.stringify(subtitle)); - - if (!subtitle) { - setShowSubtitles(false); - setActiveSubtitle(null); - - // Remove all tracks - if (videoRef.current) { - const existingTracks = videoRef.current.querySelectorAll('track'); - existingTracks.forEach(track => track.remove()); - } - - console.log('[VideoPlayer] Subtitles turned off'); - return; - } - - // Set state and apply the subtitle - setActiveSubtitle(subtitle); - setShowSubtitles(true); - applySubtitle(subtitle); - }; - - // Change quality function update - const changeQuality = (quality) => { - const video = videoRef.current; - if (!video || !window.hls) return; - - // Save current time - const currentTime = video.currentTime; - - if (quality.id === -1) { - // Auto mode - window.hls.currentLevel = -1; - window.hls.nextLevel = getHighestQuality(); // Start with highest quality in Auto mode - window.hls.autoLevelEnabled = true; - } else { - // Manual mode - window.hls.currentLevel = quality.id; - window.hls.autoLevelEnabled = false; - } - - setCurrentQuality(quality); - console.log(`Changed quality to ${quality.name}`); - }; - - // Toggle settings menu - const toggleSettings = () => { - setShowSettings(!showSettings); - setShowQualityOptions(false); - setShowSpeedOptions(false); - setShowSubtitleOptions(false); - }; - - // Handle mouse movement and auto-hide controls - const handleMouseMove = () => { - setIsMouseMoving(true); - setShowControls(true); - - // Clear existing timer - if (hideControlsTimer.current) { - clearTimeout(hideControlsTimer.current); - } - - // Set new timer to hide controls after 3 seconds - hideControlsTimer.current = setTimeout(() => { - if (isPlaying) { - setShowControls(false); - } - }, 3000); - }; - - // Clear timer on unmount - useEffect(() => { - return () => { - if (hideControlsTimer.current) { - clearTimeout(hideControlsTimer.current); - } - }; - }, []); - - // Update timer when play state changes - useEffect(() => { - if (!isPlaying) { - setShowControls(true); - if (hideControlsTimer.current) { - clearTimeout(hideControlsTimer.current); - } - } else { - // Start hide timer when video starts playing - handleMouseMove(); - } - }, [isPlaying]); - - // Set default subtitle when subtitles change - useEffect(() => { - console.log('[VideoPlayer] Subtitles prop received:', JSON.stringify(subtitles)); - - if (subtitles && subtitles.length > 0) { - // Set the first subtitle as active (should be English after our sorting) - console.log('[VideoPlayer] Setting active subtitle to:', JSON.stringify(subtitles[0])); - setActiveSubtitle(subtitles[0]); - setShowSubtitles(true); - - // Apply the subtitle if video is already loaded - if (videoRef.current) { - setTimeout(() => { - applySubtitle(subtitles[0]); - }, 500); - } - } else { - console.log('[VideoPlayer] No subtitles available, setting activeSubtitle to null'); - setActiveSubtitle(null); - } - }, [subtitles]); - - // Function to format time for thumbnail preview - const formatPreviewTime = (seconds) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - return `${minutes}:${secs.toString().padStart(2, '0')}`; - }; - - // Handle timeline hover - const handleTimelineHover = (e) => { - if (!thumbnails) return; - - const timeline = e.currentTarget; - const rect = timeline.getBoundingClientRect(); - const offsetX = e.clientX - rect.left; - const percentage = offsetX / rect.width; - - // Calculate preview position and time - const previewTimeValue = percentage * (videoRef.current?.duration || 0); - setPreviewTime(previewTimeValue); - - // Position the preview element - // Ensure the preview stays within the viewport - const previewWidth = 160; // Width of preview container - let position = (offsetX - previewWidth / 2) / rect.width * 100; - - // Prevent preview from going off-screen - const minPosition = (previewWidth / 2) / rect.width * 100; - const maxPosition = 100 - (previewWidth / 2) / rect.width * 100; - position = Math.max(minPosition, Math.min(maxPosition, position)); - - setPreviewPosition(position); - setShowPreview(true); - }; - - // Handle timeline hover end - const handleTimelineLeave = () => { - setShowPreview(false); - }; - - // Update buffer progress - const updateBufferProgress = () => { - const video = videoRef.current; - if (!video || !video.buffered || !video.buffered.length) return; - - const buffered = video.buffered; - const currentTime = video.currentTime; - - // Find the appropriate buffered range - for (let i = 0; i < buffered.length; i++) { - if (buffered.start(i) <= currentTime && currentTime <= buffered.end(i)) { - setBufferedProgress((buffered.end(i) / video.duration) * 100); - break; - } - } - }; - - // Add buffer progress listener - useEffect(() => { - const video = videoRef.current; - if (!video) return; - - const handleProgress = () => { - updateBufferProgress(); - }; - - video.addEventListener('progress', handleProgress); - video.addEventListener('timeupdate', handleProgress); - - return () => { - video.removeEventListener('progress', handleProgress); - video.removeEventListener('timeupdate', handleProgress); - }; - }, []); - - // Update audio boost function to handle percentage - const changeAudioBoost = (boost) => { - const video = videoRef.current; - if (!video) return; - const multiplier = boost / 100; - video.volume = Math.min(volume * multiplier, 1); - video.volume = Math.min(multiplier, 1); // Set volume directly based on boost - setAudioBoost(boost); - }; - - // Add Google Cast functionality - const initializeCast = () => { - if (!window.chrome || !window.chrome.cast) { - console.log('Chrome Cast API not available'); - return; - } - - // Initialize Cast API - const initCastApi = () => { - const sessionRequest = new chrome.cast.SessionRequest(chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID); - const apiConfig = new chrome.cast.ApiConfig( - sessionRequest, - (session) => { - console.log('Cast session success:', session); - }, - (availability) => { - setIsCastAvailable(availability === chrome.cast.ReceiverAvailability.AVAILABLE); - }, - chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED - ); - - chrome.cast.initialize(apiConfig, - () => { - console.log('Cast API initialized successfully'); - setIsCastAvailable(true); - }, - (error) => { - console.error('Cast API initialization error:', error); - } - ); - }; - - // Load Cast Framework - window['__onGCastApiAvailable'] = (isAvailable) => { - if (isAvailable) { - // Initialize Cast Framework - const context = cast.framework.CastContext.getInstance(); - context.setOptions({ - receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, - autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED - }); - - // Listen for cast state changes - context.addEventListener(cast.framework.CastContextEventType.CAST_STATE_CHANGED, (event) => { - setCastState(event.castState); - }); - - // Initialize the old Cast API as well - initCastApi(); - } - }; - - // Load the Cast SDK if not already loaded - if (!document.querySelector('#cast-script')) { - const script = document.createElement('script'); - script.id = 'cast-script'; - script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1'; - document.head.appendChild(script); - } - }; - - // Function to start casting - const startCasting = async () => { - try { - if (!window.chrome || !window.chrome.cast) { - console.log('Cast API not available'); - return; - } - - const context = cast.framework.CastContext.getInstance(); - - // If not connected, request a session - if (context.getCastState() === cast.framework.CastState.NOT_CONNECTED) { - try { - await context.requestSession(); - console.log('Cast session requested'); - } catch (error) { - console.error('Error requesting cast session:', error); - return; - } - } - - // Get current session - const session = context.getCurrentSession(); - if (!session) { - console.log('No cast session available'); - return; - } - - // Prepare media info - const mediaInfo = new chrome.cast.media.MediaInfo(src, 'application/x-mpegURL'); - mediaInfo.customData = null; - mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); - - // Add current time and duration - const currentTime = videoRef.current?.currentTime || 0; - const duration = videoRef.current?.duration || 0; - - const request = new chrome.cast.media.LoadRequest(mediaInfo); - request.currentTime = currentTime; - request.autoplay = true; - - try { - await session.loadMedia(request); - console.log('Media loaded successfully'); - - // Pause the video player when casting starts - if (videoRef.current) { - videoRef.current.pause(); - } - } catch (error) { - console.error('Error loading media:', error); - } - } catch (error) { - console.error('Error starting cast:', error); - } - }; - - // Initialize Cast when component mounts - useEffect(() => { - initializeCast(); - }, []); - - // Load and verify thumbnails - useEffect(() => { - if (thumbnails) { - const img = new window.Image(); - img.onload = () => { - setThumbnailsLoaded(true); - console.log('Thumbnails loaded successfully'); - }; - img.onerror = (err) => { - console.error('Error loading thumbnails:', err); - }; - img.src = thumbnails; - } - }, [thumbnails]); - - // Update isDubbed when category changes - useEffect(() => { - setIsDubbed(category === 'dub'); - }, [category]); - - // Add skip intro/outro functions - const skipIntro = () => { - if (videoRef.current && intro?.end) { - videoRef.current.currentTime = intro.end; - } - }; - - const skipOutro = () => { - if (videoRef.current && outro?.end) { - videoRef.current.currentTime = outro.end; - } - }; - - return ( -- -- -Trending Now
---- {trendingAnime.slice(0, 10).map((anime, index) => ( - --- {/* Rank number */} -- - ))} -- #{index + 1} -- - {/* Anime image */} --- - {/* Anime info */} -- --- {anime.title} -
-!isMobile && setShowControls(true)} - onMouseLeave={() => !isMobile && isPlaying && setShowControls(false)} - onMouseMove={handleMouseMove} - > - {/* Mobile Controls (Only visible on small screens) */} -- ); -} \ No newline at end of file diff --git a/src/components/banner/Banner.css b/src/components/banner/Banner.css new file mode 100644 index 0000000..599a1b4 --- /dev/null +++ b/src/components/banner/Banner.css @@ -0,0 +1,133 @@ +.spotlight { + overflow: hidden; +} + +.spotlight-overlay { + width: 100.1%; + height: 100.1%; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom:0; + background: radial-gradient( + circle at 130% center, + rgba(32, 31, 49, 0) 50%, + rgba(32, 31, 49, 0.5) 60%, + rgba(32, 31, 49, 1) 80%, + rgba(32, 31, 49, 1) 100% + ), + linear-gradient( + to top, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to left, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to bottom, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ); + + z-index: 1; +} +@media only screen and (max-width: 1300px) { + .spotlight-overlay { + background: radial-gradient( + circle at 130% center, + rgba(32, 31, 49, 0) 50%, + rgba(32, 31, 49, 0.5) 60%, + rgba(32, 31, 49, 1) 80%, + rgba(32, 31, 49, 1) 100% + ), + linear-gradient( + to top, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to left, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to bottom, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 50%, + rgba(32, 31, 49, 0) 100% + ); + } +} +@media only screen and (max-width: 1200px) { + .spotlight-overlay { + background: radial-gradient( + circle at 100% center, + rgba(32, 31, 49, 0) 50%, + rgba(32, 31, 49, 0.5) 60%, + rgba(32, 31, 49, 1) 95%, + rgba(32, 31, 49, 1) 100% + ), + linear-gradient( + to top, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to left, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to bottom, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 70%, + rgba(32, 31, 49, 0) 100% + ); + } +} +@media only screen and (max-width: 900px) { + .spotlight-overlay { + background: radial-gradient( + circle at 60% center, + rgba(32, 31, 49, 0) 50%, + rgba(32, 31, 49, 0.5) 85%, + rgba(32, 31, 49, 1) 95%, + rgba(32, 31, 49, 1) 100% + ), + linear-gradient( + to top, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 70%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to left, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to bottom, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 70%, + rgba(32, 31, 49, 0) 100% + ), + linear-gradient( + to right, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 15%, + rgba(32, 31, 49, 0) 100% + ); + } +} diff --git a/src/components/banner/Banner.jsx b/src/components/banner/Banner.jsx new file mode 100644 index 0000000..aa9aaf5 --- /dev/null +++ b/src/components/banner/Banner.jsx @@ -0,0 +1,136 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faPlay, + faClosedCaptioning, + faMicrophone, + faCalendar, + faClock, +} from "@fortawesome/free-solid-svg-icons"; +import { FaChevronRight } from "react-icons/fa"; +import { Link } from "react-router-dom"; +import { useLanguage } from "@/src/context/LanguageContext"; +import "./Banner.css"; + +function Banner({ item, index }) { + const { language } = useLanguage(); + return ( +-- - {/* Video Element */} - - - {/* Pause Overlay with Blur */} - {!isPlaying && !isLoading && !error && ( - - )} - - {/* Loading Spinner */} - {isLoading && ( -- {/* Settings Button */} --- ---- )} - - {/* Error Message */} - {error && ( -- {/* Outer ring with fade effect */} - - - {/* Inner spinning ring */} - - - {/* Core circle with pulse */} - ---- )} - - {/* Center Play Button (only shown briefly on state change) */} ---- - --Error: {error}
-Please try a different server or check your connection.
--- - {/* Skip Intro Button */} - {showSkipIntro && ( -- --- -- )} - - {/* Skip Outro Button */} - {showSkipOutro && ( -- -- )} - - {/* Video Controls */} -{ - setShowControls(true); - if (hideControlsTimer.current) { - clearTimeout(hideControlsTimer.current); - } - }} - > - {/* Progress Bar Container */} --- {/* Preview Thumbnail */} - {showPreview && thumbnailsLoaded && ( -- - {/* Controls Bar */} --- )} - - {/* Progress Track */} ----
- {formatPreviewTime(previewTime)} --- {/* Background */} - - - {/* Buffered Progress */} - - - {/* Intro Marker */} - {intro && ( - - )} - - {/* Outro Marker */} - {outro && ( - - )} - - {/* Played Progress */} - - - {/* Progress Handle */} --- ----- {/* Left Controls */} --- {/* Play/Pause */} - - - {/* Volume and Time Controls */} -- - {/* Right Controls */} -- {/* Volume */} ---- - {/* Time */} -- ----- --- {formatTime(currentTime)} - / - {formatTime(duration)} --- {/* Skip Backward/Forward */} --- - - -- - {/* Hide cast and settings buttons on mobile since they're at the top */} -- {/* Settings */} -- - {/* Picture in Picture */} - - - {/* Fullscreen */} - -- - {/* Settings menu content remains the same */} - {showSettings && ( --- {/* Main Settings View */} -- )} --- - {/* Quality Settings View */} -- Settings --- {/* Quality Option */} - - - {/* Playback Speed Option */} - - - {/* Audio Boost Option */} - ----- - Quality --- - {qualities - .sort((a, b) => (b.height || 0) - (a.height || 0)) - .map((quality) => ( - - ))} --+ + ); +} + +export default Banner; diff --git a/src/components/cart/Cart.css b/src/components/cart/Cart.css new file mode 100644 index 0000000..ff2b750 --- /dev/null +++ b/src/components/cart/Cart.css @@ -0,0 +1,10 @@ +.dot { + width: 4px; + height: 4px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background: rgba(255, 255, 255, .3); + display: inline-block; +} diff --git a/src/components/cart/Cart.jsx b/src/components/cart/Cart.jsx new file mode 100644 index 0000000..0ceefe7 --- /dev/null +++ b/src/components/cart/Cart.jsx @@ -0,0 +1,132 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faClosedCaptioning, + faMicrophone, +} from "@fortawesome/free-solid-svg-icons"; +import { FaChevronRight } from "react-icons/fa"; +import { useLanguage } from "@/src/context/LanguageContext"; +import "./Cart.css"; +import { Link, useNavigate } from "react-router-dom"; +import { useState } from "react"; +import useToolTipPosition from "@/src/hooks/useToolTipPosition"; +import Qtip from "../qtip/Qtip"; + +function Cart({ label, data, path }) { + const { language } = useLanguage(); + const navigate = useNavigate(); + const [hoveredItem, setHoveredItem] = useState(null); + const [hoverTimeout, setHoverTimeout] = useState(null); + const { tooltipPosition, tooltipHorizontalPosition, cardRefs } = + useToolTipPosition(hoveredItem, data); + + const handleImageEnter = (item, index) => { + if (hoverTimeout) clearTimeout(hoverTimeout); + setHoveredItem(item.id + index); + }; + + const handleImageLeave = () => { + setHoverTimeout( + setTimeout(() => { + setHoveredItem(null); + }, 300) + ); + }; + + return ( ++ +
+++ #{index + 1} Spotlight +
++ {language === "EN" ? item.title : item.japanese_title} +
++ {item.tvInfo && ( + <> + {item.tvInfo.showType && ( ++++ )} + + {item.tvInfo.duration && ( ++ + {item.tvInfo.showType} +
+++ )} + + {item.tvInfo.releaseDate && ( ++ + {item.tvInfo.duration} +
+++ )} + ++ + {item.tvInfo.releaseDate} +
++ {item.tvInfo.quality && ( ++ > + )} ++ {item.tvInfo.quality} ++ )} ++ {item.tvInfo.episodeInfo?.sub && ( ++++ )} + + {item.tvInfo.episodeInfo?.dub && ( ++ + {item.tvInfo.episodeInfo.sub} +
+++ )} ++ + {item.tvInfo.episodeInfo.dub} +
++ {item.description} +
++ + +++ Detail +
++ + ++ ); +} + +export default Cart; diff --git a/src/components/categorycard/CategoryCard.css b/src/components/categorycard/CategoryCard.css new file mode 100644 index 0000000..04411bc --- /dev/null +++ b/src/components/categorycard/CategoryCard.css @@ -0,0 +1,27 @@ +.overlay { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + to top, + rgba(32, 31, 49, 1) 0%, + rgba(32, 31, 49, 0) 20%, + rgba(32, 31, 49, 0) 100% + ); + + z-index: 50; +} +.dot { + width: 5px; + height: 5px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + display: inline-block; +} diff --git a/src/components/categorycard/CategoryCard.jsx b/src/components/categorycard/CategoryCard.jsx new file mode 100644 index 0000000..00bd560 --- /dev/null +++ b/src/components/categorycard/CategoryCard.jsx @@ -0,0 +1,340 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faClosedCaptioning, + faMicrophone, + faPlay, +} from "@fortawesome/free-solid-svg-icons"; +import { FaChevronRight } from "react-icons/fa"; +import "./CategoryCard.css"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { Link, useNavigate } from "react-router-dom"; +import Qtip from "../qtip/Qtip"; +import useToolTipPosition from "@/src/hooks/useToolTipPosition"; + +const CategoryCard = React.memo( + ({ + label, + data, + showViewMore = true, + className, + categoryPage = false, + cardStyle, + path, + limit, + }) => { + const { language } = useLanguage(); + const navigate = useNavigate(); + const [showPlay, setShowPlay] = useState(false); + if (limit) { + data = data.slice(0, limit); + } + + const [itemsToRender, setItemsToRender] = useState({ + firstRow: [], + remainingItems: [], + }); + + const getItemsToRender = useCallback(() => { + if (categoryPage) { + const firstRow = + window.innerWidth > 758 && data.length > 4 ? data.slice(0, 4) : []; + const remainingItems = + window.innerWidth > 758 && data.length > 4 + ? data.slice(4) + : data.slice(0); + return { firstRow, remainingItems }; + } + return { firstRow: [], remainingItems: data.slice(0) }; + }, [categoryPage, data]); + + useEffect(() => { + const handleResize = () => { + setItemsToRender(getItemsToRender()); + }; + const newItems = getItemsToRender(); + setItemsToRender((prev) => { + if ( + JSON.stringify(prev.firstRow) !== JSON.stringify(newItems.firstRow) || + JSON.stringify(prev.remainingItems) !== + JSON.stringify(newItems.remainingItems) + ) { + return newItems; + } + return prev; + }); + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [getItemsToRender]); + const [hoveredItem, setHoveredItem] = useState(null); + const [hoverTimeout, setHoverTimeout] = useState(null); + const { tooltipPosition, tooltipHorizontalPosition, cardRefs } = + useToolTipPosition(hoveredItem, data); + const handleMouseEnter = (item, index) => { + const timeout = setTimeout(() => { + setHoveredItem(item.id + index); + setShowPlay(true); + }, 400); + setHoverTimeout(timeout); + }; + const handleMouseLeave = () => { + clearTimeout(hoverTimeout); + setHoveredItem(null); + setShowPlay(false); + }; + return ( ++ {label} +
++ {data && + data.slice(0, 5).map((item, index) => ( ++(cardRefs.current[index] = el)} + > ++ ))} + +navigate(`/watch/${item.id}`)} + onMouseEnter={() => handleImageEnter(item, index)} + onMouseLeave={handleImageLeave} + /> + + {hoveredItem === item.id + index && window.innerWidth > 1024 && ( +
{ + if (hoverTimeout) clearTimeout(hoverTimeout); + }} + onMouseLeave={handleImageLeave} + > ++ )} + ++ + + {language === "EN" ? item.title : item.japanese_title} + +++ {item.tvInfo?.sub && ( ++++ )} + + {item.tvInfo?.dub && ( ++ {item.tvInfo.sub}
+++ )} ++ {item.tvInfo.dub}
++ +++ {item.tvInfo.showType} +
++ View more +
++ + ++ ); + } +); + +CategoryCard.displayName = "CategoryCard"; + +export default CategoryCard; diff --git a/src/components/continue/ContinueWatching.jsx b/src/components/continue/ContinueWatching.jsx new file mode 100644 index 0000000..da8d369 --- /dev/null +++ b/src/components/continue/ContinueWatching.jsx @@ -0,0 +1,132 @@ +import { Navigation } from "swiper/modules"; +import { Swiper, SwiperSlide } from "swiper/react"; +import { Link } from "react-router-dom"; +import { useEffect, useState, useRef, useMemo } from "react"; +import "swiper/css"; +import "swiper/css/pagination"; +import "swiper/css/navigation"; +import { FaHistory, FaChevronLeft, FaChevronRight } from "react-icons/fa"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlay } from "@fortawesome/free-solid-svg-icons"; + +const ContinueWatching = () => { + const [watchList, setWatchList] = useState([]); + const { language } = useLanguage(); + const swiperRef = useRef(null); + + useEffect(() => { + const data = JSON.parse(localStorage.getItem("continueWatching") || "[]"); + setWatchList(data); + }, []); + + // Memoize watchList to avoid unnecessary re-renders + const memoizedWatchList = useMemo(() => watchList, [watchList]); + + const removeFromWatchList = (episodeId) => { + setWatchList((prevList) => { + const updatedList = prevList.filter( + (item) => item.episodeId !== episodeId + ); + localStorage.setItem("continueWatching", JSON.stringify(updatedList)); + return updatedList; + }); + }; + + if (memoizedWatchList.length === 0) return null; + + return ( +++ <> + {categoryPage && ( ++ {label} +
+ {showViewMore && ( + ++ View more +
++ + )} + 0 + ? "mt-8 max-[758px]:hidden" + : "" + }`} + > + {itemsToRender.firstRow.map((item, index) => ( ++ )} +(cardRefs.current[index] = el)} + > ++ ))} ++ navigate( + `${ + path === "top-upcoming" + ? `/${item.id}` + : `/watch/${item.id}` + }` + ) + } + onMouseEnter={() => handleMouseEnter(item, index)} + onMouseLeave={handleMouseLeave} + > + {hoveredItem === item.id + index && showPlay && ( ++ + {language === "EN" ? item.title : item.japanese_title} + + {item.description && ( ++ )} + + + ++ {(item.tvInfo?.rating === "18+" || + item?.adultContent === true) && ( ++
+ 18+ ++ )} ++ {item.tvInfo?.sub && ( ++ {hoveredItem === item.id + index && + window.innerWidth > 1024 && ( +++ )} + {item.tvInfo?.dub && ( ++ + {item.tvInfo.sub} +
+++ )} + {item.tvInfo?.eps && ( ++ + {item.tvInfo.dub} +
+++ )} ++ {item.tvInfo.eps} +
+++ )} ++ + {item.description} ++ )} ++++ {item.tvInfo.showType.split(" ").shift()} ++ ++ {item.tvInfo?.duration === "m" || + item.tvInfo?.duration === "?" || + item.duration === "m" || + item.duration === "?" + ? "N/A" + : item.tvInfo?.duration || item.duration || "N/A"} +++ {itemsToRender.remainingItems.map((item, index) => ( ++ > +(cardRefs.current[index] = el)} + > ++ ))} ++ navigate( + `${ + path === "top-upcoming" + ? `/${item.id}` + : `/watch/${item.id}` + }` + ) + } + onMouseEnter={() => handleMouseEnter(item, index)} + onMouseLeave={handleMouseLeave} + > + {hoveredItem === item.id + index && showPlay && ( ++ + {language === "EN" ? item.title : item.japanese_title} + ++ )} + + ++ {(item.tvInfo?.rating === "18+" || + item?.adultContent === true) && ( ++
+ 18+ ++ )} ++ {item.tvInfo?.sub && ( ++ {hoveredItem === item.id + index && + window.innerWidth > 1024 && ( +++ )} + {item.tvInfo?.dub && ( ++ + {item.tvInfo.sub} +
+++ )} ++ + {item.tvInfo.dub} +
+++ )} ++ +++ {item.tvInfo.showType.split(" ").shift()} ++ ++ {item.tvInfo?.duration === "m" || + item.tvInfo?.duration === "?" || + item.duration === "m" || + item.duration === "?" + ? "N/A" + : item.tvInfo?.duration || item.duration || "N/A"} ++++ ); +}; + +export default ContinueWatching; diff --git a/src/components/episodelist/Episodelist.css b/src/components/episodelist/Episodelist.css new file mode 100644 index 0000000..8fd6b62 --- /dev/null +++ b/src/components/episodelist/Episodelist.css @@ -0,0 +1,15 @@ +@keyframes glow { + 0% { + box-shadow: 0 0 7px #ffbade; + } + 50% { + box-shadow: 0 0 20px #ffbade; + } + 100% { + box-shadow: 0 0 7px #ffbade; + } +} + +.glow-animation { + animation: glow 1.5s infinite; +} diff --git a/src/components/episodelist/Episodelist.jsx b/src/components/episodelist/Episodelist.jsx new file mode 100644 index 0000000..cc86327 --- /dev/null +++ b/src/components/episodelist/Episodelist.jsx @@ -0,0 +1,303 @@ +import { useLanguage } from "@/src/context/LanguageContext"; +import { + faAngleDown, + faCirclePlay, + faList, + faCheck, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { useState, useEffect, useRef } from "react"; +import "./Episodelist.css"; + +function Episodelist({ + episodes, + onEpisodeClick, + currentEpisode, + totalEpisodes, +}) { + const [activeEpisodeId, setActiveEpisodeId] = useState(currentEpisode); + const { language } = useLanguage(); + const listContainerRef = useRef(null); + const activeEpisodeRef = useRef(null); + const [showDropDown, setShowDropDown] = useState(false); + const [selectedRange, setSelectedRange] = useState([1, 100]); + const [activeRange, setActiveRange] = useState("1-100"); + const [episodeNum, setEpisodeNum] = useState(currentEpisode); + const dropDownRef = useRef(null); + const [searchedEpisode, setSearchedEpisode] = useState(null); + + const scrollToActiveEpisode = () => { + if (activeEpisodeRef.current && listContainerRef.current) { + const container = listContainerRef.current; + const activeEpisode = activeEpisodeRef.current; + const containerTop = container.getBoundingClientRect().top; + const containerHeight = container.clientHeight; + const activeEpisodeTop = activeEpisode.getBoundingClientRect().top; + const activeEpisodeHeight = activeEpisode.clientHeight; + const offset = activeEpisodeTop - containerTop; + container.scrollTop = + container.scrollTop + + offset - + containerHeight / 2 + + activeEpisodeHeight / 2; + } + }; + useEffect(() => { + setActiveEpisodeId(episodeNum); + }, [episodeNum]); + useEffect(() => { + scrollToActiveEpisode(); + }, [activeEpisodeId]); + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropDownRef.current && !dropDownRef.current.contains(event.target)) { + setShowDropDown(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + function handleChange(e) { + const value = e.target.value; + if (value.trim() === "") { + const newRange = findRangeForEpisode(1); + setSelectedRange(newRange); + setActiveRange(`${newRange[0]}-${newRange[1]}`); + setSearchedEpisode(null); + } else if (!value || isNaN(value)) { + setSearchedEpisode(null); + } else if ( + !isNaN(value) && + parseInt(value, 10) > totalEpisodes && + episodeNum !== null + ) { + const newRange = findRangeForEpisode(episodeNum); + setSelectedRange(newRange); + setActiveRange(`${newRange[0]}-${newRange[1]}`); + setSearchedEpisode(null); + } else if (!isNaN(value) && value.trim() !== "") { + const num = parseInt(value, 10); + const foundEpisode = episodes.find((item) => item?.episode_no === num); + if (foundEpisode) { + const newRange = findRangeForEpisode(num); + setSelectedRange(newRange); + setActiveRange(`${newRange[0]}-${newRange[1]}`); + setSearchedEpisode(foundEpisode?.id); + } + } else { + setSearchedEpisode(null); + } + } + + function findRangeForEpisode(episodeNumber) { + const step = 100; + const start = Math.floor((episodeNumber - 1) / step) * step + 1; + const end = Math.min(start + step - 1, totalEpisodes); + return [start, end]; + } + + function generateRangeOptions(totalEpisodes) { + const ranges = []; + const step = 100; + + for (let i = 0; i < totalEpisodes; i += step) { + const start = i + 1; + const end = Math.min(i + step, totalEpisodes); + ranges.push(`${start}-${end}`); + } + return ranges; + } + useEffect(() => { + if (currentEpisode && episodeNum) { + if (episodeNum < selectedRange[0] || episodeNum > selectedRange[1]) { + const newRange = findRangeForEpisode(episodeNum); + setSelectedRange(newRange); + setActiveRange(`${newRange[0]}-${newRange[1]}`); + } + } + }, [currentEpisode, totalEpisodes, episodeNum]); + + const handleRangeSelect = (range) => { + const [start, end] = range.split("-").map(Number); + setSelectedRange([start, end]); + }; + + useEffect(() => { + const activeEpisode = episodes.find( + (item) => item?.id.match(/ep=(\d+)/)?.[1] === activeEpisodeId + ); + if (activeEpisode) { + setEpisodeNum(activeEpisode?.episode_no); + } + }, [activeEpisodeId, episodes]); + + return ( +++ +++ ++ + Continue Watching +
++ + +++++ {memoizedWatchList.map((item, index) => ( + ++ + ))} ++ + + +++
++ + {item?.adultContent === true && ( ++ + 18+ ++ )} ++++ {language === "EN" + ? item?.title + : item?.japanese_title} +
++ Episode {item.episodeNum} +
+++ ); +} + +export default Episodelist; diff --git a/src/components/error/Error.jsx b/src/components/error/Error.jsx new file mode 100644 index 0000000..6776274 --- /dev/null +++ b/src/components/error/Error.jsx @@ -0,0 +1,21 @@ +import { FaChevronLeft } from "react-icons/fa" +import { useNavigate } from "react-router-dom" + +function Error({ error }) { + const navigate = useNavigate(); + return ( +++List of episodes:
+ {totalEpisodes > 100 && ( +++ )} +++setShowDropDown((prev) => !prev)} + className="text-white w-fit mt-1 text-[13px] relative cursor-pointer bg-[#0D0D15] flex justify-center items-center" + ref={dropDownRef} + > +++ ++ {showDropDown && ( ++ EPS: {selectedRange[0]}-{selectedRange[1]} +
++ + {generateRangeOptions(totalEpisodes).map((item, index) => ( ++ )} +{ + handleRangeSelect(item); + setActiveRange(item); + }} + className={`hover:bg-gray-200 cursor-pointer text-black ${ + item === activeRange ? "bg-[#EFF0F4]" : "" + }`} + > ++ ))} ++ EPS: {item} + {item === activeRange ? ( +
++ ) : null} + +++ + ++30 + ? "p-3 grid grid-cols-5 gap-1 max-[1200px]:grid-cols-12 max-[860px]:grid-cols-10 max-[575px]:grid-cols-8 max-[478px]:grid-cols-6 max-[350px]:grid-cols-5" + : "" + }`} + > + {totalEpisodes > 30 + ? episodes + .slice(selectedRange[0] - 1, selectedRange[1]) + .map((item, index) => { + const episodeNumber = item?.id.match(/ep=(\d+)/)?.[1]; + const isActive = + activeEpisodeId === episodeNumber || + currentEpisode === episodeNumber; + const isSearched = searchedEpisode === item?.id; + + return ( ++{ + if (episodeNumber) { + onEpisodeClick(episodeNumber); + setActiveEpisodeId(episodeNumber); + setSearchedEpisode(null); + } + }} + > + + {index + selectedRange[0]} + ++ ); + }) + : episodes?.map((item, index) => { + const episodeNumber = item?.id.match(/ep=(\d+)/)?.[1]; + const isActive = + activeEpisodeId === episodeNumber || + currentEpisode === episodeNumber; + const isSearched = searchedEpisode === item?.id; + + return ( +{ + if (episodeNumber) { + onEpisodeClick(episodeNumber); + setActiveEpisodeId(episodeNumber); + setSearchedEpisode(null); + } + }} + > ++ ); + })} +{index + 1}
++++ {language === "EN" ? item?.title : item?.japanese_title} +
+ {isActive && ( ++ )} + ++ ) +} + +export default Error \ No newline at end of file diff --git a/src/components/footer/Footer.jsx b/src/components/footer/Footer.jsx new file mode 100644 index 0000000..43404af --- /dev/null +++ b/src/components/footer/Footer.jsx @@ -0,0 +1,62 @@ +import logoTitle from "@/src/config/logoTitle.js"; +import website_name from "@/src/config/website.js"; +import { Link } from "react-router-dom"; + +function Footer() { + return ( + + ); +} + +export default Footer; diff --git a/src/components/genres/Genre.jsx b/src/components/genres/Genre.jsx new file mode 100644 index 0000000..c74424d --- /dev/null +++ b/src/components/genres/Genre.jsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; +import { Link } from "react-router-dom"; + +function Genre({ data }) { + const colors = [ + "#A4B389", + "#FFBADE", + "#935C5F", + "#AD92BC", + "#ABCCD8", + "#D8B2AB", + "#85E1CD", + "#B7C996", + ]; + + const [showAll, setShowAll] = useState(false); + const toggleGenres = () => { + setShowAll((prev) => !prev); + }; + + return ( ++++
{error === "404" ? "404 Error" : "Error"}
+Oops! We couldn't find this page.
+ +++ ); +} + +export default React.memo(Genre); diff --git a/src/components/navbar/Navbar.jsx b/src/components/navbar/Navbar.jsx new file mode 100644 index 0000000..f6f1215 --- /dev/null +++ b/src/components/navbar/Navbar.jsx @@ -0,0 +1,147 @@ +import { useState, useEffect } from "react"; +import logoTitle from "@/src/config/logoTitle"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faBars, + faFilm, + faRandom, + faStar, +} from "@fortawesome/free-solid-svg-icons"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { Link, useLocation } from "react-router-dom"; +import Sidebar from "../sidebar/Sidebar"; +import { SearchProvider } from "@/src/context/SearchContext"; +import WebSearch from "../searchbar/WebSearch"; +import MobileSearch from "../searchbar/MobileSearch"; +import { FaTelegramPlane } from "react-icons/fa"; + +function Navbar() { + const location = useLocation(); + const { language, toggleLanguage } = useLanguage(); + const [isNotHomePage, setIsNotHomePage] = useState( + location.pathname !== "/" && location.pathname !== "/home" + ); + const [isScrolled, setIsScrolled] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 0); + }; + window.addEventListener("scroll", handleScroll); + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, []); + + const handleHamburgerClick = () => { + setIsSidebarOpen(true); + }; + + const handleCloseSidebar = () => { + setIsSidebarOpen(false); + }; + const handleRandomClick = () => { + if (location.pathname === "/random") { + window.location.reload(); + } + }; + useEffect(() => { + setIsNotHomePage( + location.pathname !== "/" && location.pathname !== "/home" + ); + }, [location.pathname]); + + return ( +Genres
++++ {data && + (showAll ? data : data.slice(0, 24)).map((item, index) => { + const textColor = colors[index % colors.length]; + return ( + ++ ++ {item.charAt(0).toUpperCase() + item.slice(1)} ++ + ); + })} ++ + + ); +} + +export default Navbar; diff --git a/src/components/pageslider/PageSlider.jsx b/src/components/pageslider/PageSlider.jsx new file mode 100644 index 0000000..c42ebe2 --- /dev/null +++ b/src/components/pageslider/PageSlider.jsx @@ -0,0 +1,76 @@ +import { faAngleDoubleLeft, faAngleDoubleRight, faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +function PageSlider({ page, totalPages, handlePageChange, start = false, style }) { + const renderPageNumbers = () => { + const pages = []; + if (totalPages === 1) return null; + if (totalPages <= 3) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + if (page === 1) { + pages.push(1, 2, 3); + } else if (page === 2) { + pages.push(1, 2, 3, 4); + } else if (page === totalPages) { + pages.push(totalPages - 2, totalPages - 1, totalPages); + } else if (page === totalPages - 1) { + pages.push(totalPages - 3, totalPages - 2, totalPages - 1, totalPages); + } else { + pages.push(page - 2, page - 1, page, page + 1, page + 2); + } + } + return pages.map((p) => ( + + )); + }; + return ( ++ ++ ) +} + +export default PageSlider \ No newline at end of file diff --git a/src/components/player/IframePlayer.jsx b/src/components/player/IframePlayer.jsx new file mode 100644 index 0000000..efbaab1 --- /dev/null +++ b/src/components/player/IframePlayer.jsx @@ -0,0 +1,148 @@ +/* eslint-disable react/prop-types */ +import { useEffect, useState } from "react"; +import BouncingLoader from "../ui/bouncingloader/Bouncingloader"; +import axios from "axios"; + +export default function IframePlayer({ + animeId, + episodeId, + serverName, + servertype, + animeInfo, + episodeNum, + episodes, + playNext, + autoNext, +}) { + const api_url=import.meta.env.VITE_API_URL; + const baseURL = + serverName.toLowerCase() === "hd-1" + ? import.meta.env.VITE_BASE_IFRAME_URL + : serverName.toLowerCase() === "hd-4" + ? import.meta.env.VITE_BASE_IFRAME_URL_2 + : undefined; + + const [loading, setLoading] = useState(true); + const [iframeLoaded, setIframeLoaded] = useState(false); + const [iframeSrc, setIframeSrc] = useState(""); + const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState( + episodes?.findIndex( + (episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId + ) + ); + + useEffect(() => { + const loadIframeUrl = async () => { + setLoading(true); + setIframeLoaded(false); + setIframeSrc(""); + + const lowerName = serverName.toLowerCase(); + + if (lowerName === "hd-1" || lowerName === "hd-4") { + setIframeSrc(`${baseURL}/${episodeId}/${servertype}`); + } else if (lowerName === "hd-2" || lowerName === "hd-3") { + try { + const res = await axios.get( + `${api_url}/stream?id=${animeId}?ep=${episodeId}&server=${serverName}&type=${servertype}` + ); + + const link = res?.data?.results?.streamingLink?.link; + if (link) { + setIframeSrc(`${link}&_debug=true`); + } else { + console.error("Streaming link not found in response"); + } + } catch (err) { + console.error("Failed to fetch streaming link:", err); + } + } + }; + + loadIframeUrl(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [episodeId, servertype, serverName, animeInfo]); + + useEffect(() => { + if (episodes?.length > 0) { + const newIndex = episodes.findIndex( + (episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId + ); + setCurrentEpisodeIndex(newIndex); + } + }, [episodeId, episodes]); + + useEffect(() => { + const handleMessage = (event) => { + const { currentTime, duration } = event.data; + if (typeof currentTime === "number" && typeof duration === "number") { + if ( + currentTime >= duration && + currentEpisodeIndex < episodes?.length - 1 && + autoNext + ) { + playNext(episodes[currentEpisodeIndex + 1].id.match(/ep=(\d+)/)?.[1]); + } + } + }; + window.addEventListener("message", handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, [autoNext, currentEpisodeIndex, episodes, playNext]); + + useEffect(() => { + setLoading(true); + setIframeLoaded(false); + return () => { + const continueWatching = JSON.parse(localStorage.getItem("continueWatching")) || []; + const newEntry = { + id: animeInfo?.id, + data_id: animeInfo?.data_id, + episodeId, + episodeNum, + adultContent: animeInfo?.adultContent, + poster: animeInfo?.poster, + title: animeInfo?.title, + japanese_title: animeInfo?.japanese_title, + }; + if (!newEntry.data_id) return; + const existingIndex = continueWatching.findIndex( + (item) => item.data_id === newEntry.data_id + ); + if (existingIndex !== -1) { + continueWatching[existingIndex] = newEntry; + } else { + continueWatching.push(newEntry); + } + localStorage.setItem("continueWatching", JSON.stringify(continueWatching)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [episodeId, servertype]); + + return ( ++ {page > 1 && totalPages > 2 && ( + + )} + {page > 1 && ( + + )} + {renderPageNumbers()} + {page < totalPages && ( + + )} + {page < totalPages && totalPages > 2 && ( + + )} +++ {/* Loader Overlay */} ++ ); +} diff --git a/src/components/player/Player.css b/src/components/player/Player.css new file mode 100644 index 0000000..87bd52b --- /dev/null +++ b/src/components/player/Player.css @@ -0,0 +1,59 @@ +.art-subtitle { + padding-inline: 0px !important; + gap: 2px !important; +} +.art-volume-panel { + padding-bottom: 20px !important; +} +.art-settings { + margin-bottom: 20px !important; +} +.art-subtitle { + margin-bottom: 1rem !important; +} +.art-subtitle-line { + min-width: fit-content; + background-color: rgba(0, 0, 0, 0.479) !important; + padding-inline: 3px !important; +} +.art-subtitle-line, +.art-subtitle-line * { + font-size: inherit !important; + color: inherit !important; + line-height: inherit !important; + font-weight: inherit !important; + white-space: inherit !important; +} +@media screen and (max-width: 370px) { + .art-progress { + padding-bottom: 5px !important; + } + .art-controls-left .art-control { + justify-content: flex-start !important; + } + .art-controls-right .art-control { + justify-content: flex-end !important; + } + .art-controls-right .art-control svg { + width: 22px; + height: 22px; + } + .art-controls-left .art-control svg { + width: 22px; + height: 22px; + } + .art-state .art-icon svg { + width: 50px; + height: 50px; + } +} +@media screen and (max-width: 350px) { + .art-controls-right .art-control svg { + width: 20px; + height: 20px; + } + .art-controls-left .art-control svg { + width: 20px; + height: 20px; + } +} diff --git a/src/components/player/Player.jsx b/src/components/player/Player.jsx new file mode 100644 index 0000000..bcf29bb --- /dev/null +++ b/src/components/player/Player.jsx @@ -0,0 +1,494 @@ +/* eslint-disable react/prop-types */ +import Hls from "hls.js"; +import { useEffect, useRef, useState } from "react"; +import Artplayer from "artplayer"; +import artplayerPluginChapter from "./artPlayerPluinChaper"; +import autoSkip from "./autoSkip"; +import artplayerPluginVttThumbnail from "./artPlayerPluginVttThumbnail"; +import { + backward10Icon, + backwardIcon, + captionIcon, + forward10Icon, + forwardIcon, + fullScreenOffIcon, + fullScreenOnIcon, + loadingIcon, + logo, + muteIcon, + pauseIcon, + pipIcon, + playIcon, + playIconLg, + settingsIcon, + volumeIcon, +} from "./PlayerIcons"; +import "./Player.css"; +import website_name from "@/src/config/website"; +import getChapterStyles from "./getChapterStyle"; +import artplayerPluginHlsControl from "artplayer-plugin-hls-control"; +import artplayerPluginUploadSubtitle from "./artplayerPluginUploadSubtitle"; + +Artplayer.LOG_VERSION = false; +Artplayer.CONTEXTMENU = false; + +const KEY_CODES = { + M: "m", + I: "i", + F: "f", + V: "v", + SPACE: " ", + ARROW_UP: "arrowup", + ARROW_DOWN: "arrowdown", + ARROW_RIGHT: "arrowright", + ARROW_LEFT: "arrowleft", +}; + +export default function Player({ + streamUrl, + subtitles, + thumbnail, + intro, + outro, + serverName, + autoSkipIntro, + autoPlay, + autoNext, + episodeId, + episodes, + playNext, + animeInfo, + episodeNum, + streamInfo, +}) { + const artRef = useRef(null); + const leftAtRef = useRef(0); + const proxy = import.meta.env.VITE_PROXY_URL; + const m3u8proxy = import.meta.env.VITE_M3U8_PROXY_URL?.split(",") || []; + const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState( + episodes?.findIndex( + (episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId + ) + ); + + useEffect(() => { + if (episodes?.length > 0) { + const newIndex = episodes.findIndex( + (episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId + ); + setCurrentEpisodeIndex(newIndex); + } + }, [episodeId, episodes]); + useEffect(() => { + const applyChapterStyles = () => { + const existingStyles = document.querySelectorAll( + "style[data-chapter-styles]" + ); + existingStyles.forEach((style) => style.remove()); + const styleElement = document.createElement("style"); + styleElement.setAttribute("data-chapter-styles", "true"); + const styles = getChapterStyles(intro, outro); + styleElement.textContent = styles; + document.head.appendChild(styleElement); + return () => { + styleElement.remove(); + }; + }; + + if (streamUrl || intro || outro) { + const cleanup = applyChapterStyles(); + return cleanup; + } + }, [streamUrl, intro, outro]); + + const 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()); + + // hls.on(Hls.Events.ERROR, (event, data) => { + // console.error("HLS.js error:", data); + // }); + video.addEventListener("timeupdate", () => { + const currentTime = Math.round(video.currentTime); + const duration = Math.round(video.duration); + if (duration > 0 && currentTime >= duration) { + art.pause(); + if (currentEpisodeIndex < episodes?.length - 1 && autoNext) { + playNext( + episodes[currentEpisodeIndex + 1].id.match(/ep=(\d+)/)?.[1] + ); + } + } + }); + } else if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = url; + video.addEventListener("timeupdate", () => { + const currentTime = Math.round(video.currentTime); + const duration = Math.round(video.duration); + if (duration > 0 && currentTime >= duration) { + art.pause(); + if (currentEpisodeIndex < episodes?.length - 1 && autoNext) { + playNext( + episodes[currentEpisodeIndex + 1].id.match(/ep=(\d+)/)?.[1] + ); + } + } + }); + } else { + console.log("Unsupported playback format: m3u8"); + } + }; + + const createChapters = () => { + const chapters = []; + if (intro?.start !== 0 || intro?.end !== 0) { + chapters.push({ start: intro.start, end: intro.end, title: "intro" }); + } + if (outro?.start !== 0 || outro?.end !== 0) { + chapters.push({ start: outro.start, end: outro.end, title: "outro" }); + } + return chapters; + }; + + const handleKeydown = (event, art) => { + const tagName = event.target.tagName.toLowerCase(); + + if (tagName === "input" || tagName === "textarea") return; + + switch (event.key.toLowerCase()) { + case KEY_CODES.M: + art.muted = !art.muted; + break; + case KEY_CODES.I: + art.pip = !art.pip; + break; + case KEY_CODES.F: + event.preventDefault(); + event.stopPropagation(); + art.fullscreen = !art.fullscreen; + break; + case KEY_CODES.V: + event.preventDefault(); + event.stopPropagation(); + art.subtitle.show = !art.subtitle.show; + break; + case KEY_CODES.SPACE: + event.preventDefault(); + event.stopPropagation(); + art.playing ? art.pause() : art.play(); + break; + case KEY_CODES.ARROW_UP: + event.preventDefault(); + event.stopPropagation(); + art.volume = Math.min(art.volume + 0.1, 1); + break; + case KEY_CODES.ARROW_DOWN: + event.preventDefault(); + event.stopPropagation(); + art.volume = Math.max(art.volume - 0.1, 0); + break; + case KEY_CODES.ARROW_RIGHT: + event.preventDefault(); + event.stopPropagation(); + art.currentTime = Math.min(art.currentTime + 10, art.duration); + break; + case KEY_CODES.ARROW_LEFT: + event.preventDefault(); + event.stopPropagation(); + art.currentTime = Math.max(art.currentTime - 10, 0); + break; + default: + break; + } + }; + + useEffect(() => { + if (!streamUrl || !artRef.current) return; + const iframeUrl = streamInfo?.streamingLink?.iframe; + const headers = {}; + headers.referer=new URL(iframeUrl).origin+"/"; + const art = new Artplayer({ + url: + m3u8proxy[Math.floor(Math.random() * m3u8proxy?.length)] + + encodeURIComponent(streamUrl) + + "&headers=" + + encodeURIComponent(JSON.stringify(headers)), + container: artRef.current, + type: "m3u8", + autoplay: autoPlay, + volume: 1, + setting: true, + playbackRate: true, + pip: true, + hotkey: false, + fullscreen: true, + mutex: true, + playsInline: true, + lock: true, + airplay: true, + autoOrientation: true, + fastForward: true, + aspectRatio: true, + plugins: [ + artplayerPluginHlsControl({ + quality: { + setting: true, + getName: (level) => level.height + "P", + title: "Quality", + auto: "Auto", + }, + }), + artplayerPluginUploadSubtitle(), + artplayerPluginChapter({ chapters: createChapters() }), + ], + subtitle: { + style: { + color: "#fff", + "font-weight": "400", + left: "50%", + transform: "translateX(-50%)", + "margin-bottom": "2rem", + }, + escape: false, + }, + layers: [ + { + name: website_name, + html: logo, + tooltip: website_name, + style: { + opacity: 1, + position: "absolute", + top: "5px", + right: "5px", + transition: "opacity 0.5s ease-out", + }, + }, + { + html: "", + style: { + position: "absolute", + left: "50%", + top: 0, + width: "20%", + height: "100%", + transform: "translateX(-50%)", + }, + disable: !Artplayer.utils.isMobile, + click: () => art.toggle(), + }, + { + name: "rewind", + html: "", + style: { position: "absolute", left: 0, top: 0, width: "40%", height: "100%" }, + disable: !Artplayer.utils.isMobile, + click: () => { + art.controls.show = !art.controls.show; + }, + }, + { + name: "forward", + html: "", + style: { position: "absolute", right: 0, top: 0, width: "40%", height: "100%" }, + disable: !Artplayer.utils.isMobile, + click: () => { + art.controls.show = !art.controls.show; + }, + }, + { + name: "backwardIcon", + html: backwardIcon, + style: { + position: "absolute", + left: "25%", + top: "50%", + transform: "translate(50%,-50%)", + opacity: 0, + transition: "opacity 0.5s ease-in-out", + }, + disable: !Artplayer.utils.isMobile, + }, + { + name: "forwardIcon", + html: forwardIcon, + style: { + position: "absolute", + right: "25%", + top: "50%", + transform: "translate(50%, -50%)", + opacity: 0, + transition: "opacity 0.5s ease-in-out", + }, + disable: !Artplayer.utils.isMobile, + }, + ], + controls: [ + { + html: backward10Icon, + position: "right", + tooltip: "Backward 10s", + click: () => { + art.currentTime = Math.max(art.currentTime - 10, 0); + }, + }, + { + html: forward10Icon, + position: "right", + tooltip: "Forward 10s", + click: () => { + art.currentTime = Math.min(art.currentTime + 10, art.duration); + }, + }, + ], + icons: { + play: playIcon, + pause: pauseIcon, + setting: settingsIcon, + volume: volumeIcon, + pip: pipIcon, + volumeClose: muteIcon, + state: playIconLg, + loading: loadingIcon, + fullscreenOn: fullScreenOnIcon, + fullscreenOff: fullScreenOffIcon, + }, + customType: { m3u8: playM3u8 }, + }); + art.on("resize", () => { + art.subtitle.style({ + fontSize: + (art.width > 500 ? art.width * 0.02 : art.width * 0.03) + "px", + }); + }); + art.on("ready", () => { + const continueWatchingList = JSON.parse(localStorage.getItem("continueWatching")) || []; + const currentEntry = continueWatchingList.find((item) => item.episodeId === episodeId); + if (currentEntry?.leftAt) art.currentTime = currentEntry.leftAt; + + art.on("video:timeupdate", () => { + leftAtRef.current = Math.floor(art.currentTime); + }); + + setTimeout(() => { + art.layers[website_name].style.opacity = 0; + }, 2000); + + const defaultSubtitle = subtitles?.find((sub) => sub.label.toLowerCase() === "english"); + if (defaultSubtitle) { + art.subtitle.switch(defaultSubtitle.file, { + name: defaultSubtitle.label, + default: true, + }); + } + + const skipRanges = [ + ...(intro.start != null && intro.end != null ? [[intro.start + 1, intro.end - 1]] : []), + ...(outro.start != null && outro.end != null ? [[outro.start + 1, outro.end]] : []), + ]; + autoSkipIntro && art.plugins.add(autoSkip(skipRanges)); + + document.addEventListener("keydown", (event) => handleKeydown(event, art)); + + art.subtitle.style({ + fontSize: (art.width > 500 ? art.width * 0.02 : art.width * 0.03) + "px", + }); + + if (thumbnail) { + art.plugins.add( + artplayerPluginVttThumbnail({ + vtt: `${proxy}${thumbnail}`, + }) + ); + } + const $rewind = art.layers["rewind"]; + const $forward = art.layers["forward"]; + Artplayer.utils.isMobile && + art.proxy($rewind, "dblclick", () => { + art.currentTime = Math.max(0, art.currentTime - 10); + art.layers["backwardIcon"].style.opacity = 1; + setTimeout(() => { + art.layers["backwardIcon"].style.opacity = 0; + }, 300); + }); + Artplayer.utils.isMobile && + art.proxy($forward, "dblclick", () => { + art.currentTime = Math.max(0, art.currentTime + 10); + art.layers["forwardIcon"].style.opacity = 1; + setTimeout(() => { + art.layers["forwardIcon"].style.opacity = 0; + }, 300); + }); + if (subtitles?.length > 0) { + const defaultEnglishSub = + subtitles.find((sub) => sub.label.toLowerCase() === "english" && sub.default) || + subtitles.find((sub) => sub.label.toLowerCase() === "english"); + + art.setting.add({ + name: "captions", + icon: captionIcon, + html: "Subtitle", + tooltip: defaultEnglishSub?.label || "default", + position: "right", + selector: [ + { + html: "Display", + switch: true, + onSwitch: (item) => { + item.tooltip = item.switch ? "Hide" : "Show"; + art.subtitle.show = !item.switch; + return !item.switch; + }, + }, + ...subtitles.map((sub) => ({ + default: sub.label.toLowerCase() === "english" && sub === defaultEnglishSub, + html: sub.label, + url: sub.file, + })), + ], + onSelect: (item) => { + art.subtitle.switch(item.url, { name: item.html }); + return item.html; + }, + }); + } + }); + + return () => { + if (art && art.destroy) { + art.destroy(false); + } + document.removeEventListener("keydown", handleKeydown); + const continueWatching = JSON.parse(localStorage.getItem("continueWatching")) || []; + const newEntry = { + id: animeInfo?.id, + data_id: animeInfo?.data_id, + episodeId, + episodeNum, + adultContent: animeInfo?.adultContent, + poster: animeInfo?.poster, + title: animeInfo?.title, + japanese_title: animeInfo?.japanese_title, + leftAt: leftAtRef.current, + }; + + if (!newEntry.data_id) return; + + const existingIndex = continueWatching.findIndex((item) => item.data_id === newEntry.data_id); + if (existingIndex !== -1) { + continueWatching[existingIndex] = newEntry; + } else { + continueWatching.push(newEntry); + } + localStorage.setItem("continueWatching", JSON.stringify(continueWatching)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [streamUrl, subtitles, intro, outro]); + + return ; +} \ No newline at end of file diff --git a/src/components/player/PlayerIcons.jsx b/src/components/player/PlayerIcons.jsx new file mode 100644 index 0000000..2410fa3 --- /dev/null +++ b/src/components/player/PlayerIcons.jsx @@ -0,0 +1,103 @@ +const backward10Icon = ``; + +const forward10Icon = ` + `; + +const forwardIcon = ``; + +const backwardIcon = ``; + +const volumeIcon = ``; + +const muteIcon = ` +`; + +const captionIcon = ` +`; +const captionOffIcon = ``; + +const pipOffIcon = ``; + +const loadingIcon = ``; + +const pipIcon = ``; + +const playIconLg = ``; + +const playIcon = ``; + +const pauseIcon = ``; + +const uploadIcon = ` +`; + +const settingsIcon = ` +`; + +const fullScreenOnIcon = ``; + +const fullScreenOffIcon = ``; + +const logo = `++ + ++ + Powered by + + Zen!me + +
+`; + +export { + backward10Icon, + forward10Icon, + backwardIcon, + forwardIcon, + playIcon, + playIconLg, + pauseIcon, + loadingIcon, + uploadIcon, + settingsIcon, + pipIcon, + pipOffIcon, + volumeIcon, + muteIcon, + captionIcon, + captionOffIcon, + fullScreenOnIcon, + fullScreenOffIcon, + logo, +}; diff --git a/src/components/player/artPlayerPluginVttThumbnail.js b/src/components/player/artPlayerPluginVttThumbnail.js new file mode 100644 index 0000000..4cb851c --- /dev/null +++ b/src/components/player/artPlayerPluginVttThumbnail.js @@ -0,0 +1,72 @@ +import getVttArray from "./getVttArray"; + +export default function artplayerPluginVttThumbnail(option) { + return async (art) => { + const { + constructor: { + utils: { setStyle, isMobile, addClass }, + }, + template: { $progress }, + } = art; + + let timer = null; + const thumbnails = await getVttArray(option.vtt); + + function showThumbnails($control, find, width) { + setStyle($control, "backgroundImage", `url(${find.url})`); + setStyle($control, "height", `${find.h}px`); + setStyle($control, "width", `${find.w}px`); + setStyle($control, "backgroundPosition", `-${find.x}px -${find.y}px`); + if (width <= find.w / 2) { + setStyle($control, "left", 0); + } else if (width > $progress.clientWidth - find.w / 2) { + setStyle($control, "left", `${$progress.clientWidth - find.w}px`); + } else { + setStyle($control, "left", `${width - find.w / 2}px`); + } + } + + art.controls.add({ + name: "vtt-thumbnail", + position: "top", + index: 20, + style: option.style || {}, + mounted($control) { + addClass($control, "art-control-thumbnails"); + art.on("setBar", async (type, percentage, event) => { + const isMobileDroging = type === "played" && event && isMobile; + + if (type === "hover" || isMobileDroging) { + const width = $progress.clientWidth * percentage; + const second = percentage * art.duration; + setStyle($control, "display", "flex"); + + const find = thumbnails.find( + (item) => second >= item.start && second <= item.end + ); + if (!find) return setStyle($control, "display", "none"); + + if (width > 0 && width < $progress.clientWidth) { + showThumbnails($control, find, width); + } else { + if (!isMobile) { + setStyle($control, "display", "none"); + } + } + + if (isMobileDroging) { + clearTimeout(timer); + timer = setTimeout(() => { + setStyle($control, "display", "none"); + }, 500); + } + } + }); + }, + }); + + return { + name: "artplayerPluginVttThumbnail", + }; + }; +} diff --git a/src/components/player/artPlayerPluinChaper.js b/src/components/player/artPlayerPluinChaper.js new file mode 100644 index 0000000..ba681d8 --- /dev/null +++ b/src/components/player/artPlayerPluinChaper.js @@ -0,0 +1,211 @@ +import style from "./pluginChapterStyle.js"; + +export default function artplayerPluginChapter(option = {}) { + return (art) => { + const { $player } = art.template; + const { setStyle, append, clamp, query, isMobile, addClass, removeClass } = + art.constructor.utils; + + const html = ` +++ `; + + let titleTimer = null; + let $chapters = []; + + const $progress = art.query(".art-control-progress"); + const $inner = art.query(".art-control-progress-inner"); + const $control = append($inner, ''); + const $title = append($inner, ''); + + function showTitle({ $chapter, width }) { + const title = $chapter.dataset.title.trim(); + if (title) { + setStyle($title, "display", "flex"); + $title.innerText = title; + const titleWidth = $title.clientWidth; + if (width <= titleWidth / 2) { + setStyle($title, "left", 0); + } else if (width > $inner.clientWidth - titleWidth / 2) { + setStyle($title, "left", `${$inner.clientWidth - titleWidth}px`); + } else { + setStyle($title, "left", `${width - titleWidth / 2}px`); + } + } else { + setStyle($title, "display", "none"); + } + } + + function update(chapters = []) { + $chapters = []; + $control.innerText = ""; + removeClass($player, "artplayer-plugin-chapter"); + + if (!Array.isArray(chapters)) return; + if (!chapters.length) return; + if (!art.duration) return; + + chapters = chapters.sort((a, b) => a.start - b.start); + + for (let i = 0; i < chapters.length; i++) { + const chapter = chapters[i]; + const nextChapter = chapters[i + 1]; + + if (chapter.end === Infinity) { + chapter.end = art.duration; + } + + if ( + typeof chapter.start !== "number" || + typeof chapter.end !== "number" || + typeof chapter.title !== "string" + ) { + throw new Error("Illegal chapter data type"); + } + + if ( + chapter.start < 0 || + chapter.end > Math.ceil(art.duration) || + chapter.start >= chapter.end + ) { + throw new Error("Illegal chapter time point"); + } + + if (nextChapter && chapter.end > nextChapter.start) { + throw new Error("Illegal chapter time point"); + } + } + + if (chapters[0].start > 0) { + chapters.unshift({ start: 0, end: chapters[0].start, title: "" }); + } + + if (chapters[chapters.length - 1].end < art.duration) { + chapters.push({ + start: chapters[chapters.length - 1].end, + end: art.duration, + title: "", + }); + } + + for (let i = 0; i < chapters.length - 1; i++) { + if (chapters[i].end !== chapters[i + 1].start) { + chapters.splice(i + 1, 0, { + start: chapters[i].end, + end: chapters[i + 1].start, + title: "", + }); + } + } + + $chapters = chapters.map((chapter) => { + const $chapter = append($control, html); + const start = clamp(chapter.start, 0, art.duration); + const end = clamp(chapter.end, 0, art.duration); + const duration = end - start; + const percentage = duration / art.duration; + $chapter.dataset.start = start; + $chapter.dataset.end = end; + $chapter.dataset.duration = duration; + $chapter.dataset.title = chapter.title.trim(); + $chapter.style.width = `${percentage * 100}%`; + + return { + $chapter, + $hover: query(".art-progress-hover", $chapter), + $loaded: query(".art-progress-loaded", $chapter), + $played: query(".art-progress-played", $chapter), + }; + }); + + addClass($player, "artplayer-plugin-chapter"); + art.emit("setBar", "loaded", art.loaded || 0); + } + + art.on("setBar", (type, percentage, event) => { + if (!$chapters.length) return; + + for (let i = 0; i < $chapters.length; i++) { + const { $chapter, $loaded, $played, $hover } = $chapters[i]; + + const $target = { + hover: $hover, + loaded: $loaded, + played: $played, + }[type]; + + if (!$target) return; + + const width = $control.clientWidth * percentage; + const currentTime = art.duration * percentage; + const duration = parseFloat($chapter.dataset.duration); + const start = parseFloat($chapter.dataset.start); + const end = parseFloat($chapter.dataset.end); + + if (currentTime < start) { + setStyle($target, "width", 0); + } + + if (currentTime > end) { + setStyle($target, "width", "100%"); + } + + if (currentTime >= start && currentTime <= end) { + const percentage = (currentTime - start) / duration; + setStyle($target, "width", `${percentage * 100}%`); + + if (isMobile) { + if (type === "played" && event) { + showTitle({ $chapter, width }); + clearTimeout(titleTimer); + titleTimer = setTimeout(() => { + setStyle($title, "display", "none"); + }, 500); + } + } else { + if (type === "hover") { + showTitle({ $chapter, width }); + } + } + } + } + }); + + if (!isMobile) { + art.proxy($progress, "mouseleave", () => { + if (!$chapters.length) return; + setStyle($title, "display", "none"); + }); + } + + art.once("video:loadedmetadata", () => update(option.chapters)); + + return { + name: "artplayerPluginChapter", + update: ({ chapters }) => update(chapters), + }; + }; +} + +if (typeof document !== "undefined") { + const id = "artplayer-plugin-chapter"; + const $style = document.getElementById(id); + if ($style) { + $style.textContent = style; + } else { + const $style = document.createElement("style"); + $style.id = id; + $style.textContent = style; + document.head.appendChild($style); + } +} + +if (typeof window !== "undefined") { + window["artplayerPluginChapter"] = artplayerPluginChapter; +} diff --git a/src/components/player/artplayerPluginUploadSubtitle.js b/src/components/player/artplayerPluginUploadSubtitle.js new file mode 100644 index 0000000..20a362d --- /dev/null +++ b/src/components/player/artplayerPluginUploadSubtitle.js @@ -0,0 +1,49 @@ +import { uploadIcon } from "./PlayerIcons"; + +export default function artplayerPluginUploadSubtitle() { + return (art) => { + const { getExt } = art.constructor.utils; + + art.setting.add({ + html: ` ++ + + +++ + ++ `, + icon: uploadIcon, + onClick(setting, $setting) { + const $input = $setting.querySelector("input[name='subtitle-upload']"); + const $label = $setting.querySelector(".subtitle-upload-label"); + + art.proxy($input, "change", (event) => { + const file = event.target?.files?.[0]; + if (!file) return; + + const url = URL.createObjectURL(file); + art.subtitle.switch(url, { + type: getExt(file.name), + }); + + event.target.value = null; + + // Update UI + $label.textContent = file.name; + art.notice.show = `Upload Subtitle :${file.name}`; + setting.tooltip = file.name; + }); + }, + }); + }; +} diff --git a/src/components/player/autoSkip.js b/src/components/player/autoSkip.js new file mode 100644 index 0000000..38588b9 --- /dev/null +++ b/src/components/player/autoSkip.js @@ -0,0 +1,74 @@ +export default function autoSkip(option) { + function validateRanges(ranges) { + if (!Array.isArray(ranges)) { + throw new TypeError("Option must be an array of time ranges"); + } + + ranges.forEach((range, index) => { + if (!Array.isArray(range) || range.length !== 2) { + throw new TypeError( + `Range at index ${index} must be an array of two numbers` + ); + } + + const [start, end] = range; + if ( + typeof start !== "number" || + (typeof end !== "number" && end !== Infinity) + ) { + throw new TypeError( + `Range at index ${index} must contain valid numbers or Infinity` + ); + } + + if (start > end && end !== Infinity) { + throw new RangeError( + `In range at index ${index}, start time must be less than end time` + ); + } + + if (index > 0) { + const prevEnd = ranges[index - 1][1]; + if (prevEnd !== Infinity && start <= prevEnd) { + throw new RangeError( + `Range at index ${index} overlaps with the previous range` + ); + } + } + }); + } + validateRanges(option); + return (art) => { + let skipRanges = option; + + function updateRanges() { + const duration = art.duration; + skipRanges = skipRanges.map(([start, end]) => [ + start, + end === Infinity ? duration : end, + ]); + } + + function checkAndSkip() { + const currentTime = art.currentTime; + for (const [start, end] of skipRanges) { + if (currentTime >= start && currentTime < end) { + art.seek = end; + break; + } + } + } + + art.on("video:timeupdate", checkAndSkip); + art.on("video:loadedmetadata", updateRanges); + + return { + name: "autoSkip", + update(newOption = []) { + validateRanges(newOption); + skipRanges = newOption; + updateRanges(); + }, + }; + }; +} diff --git a/src/components/player/getChapterStyle.js b/src/components/player/getChapterStyle.js new file mode 100644 index 0000000..c558d89 --- /dev/null +++ b/src/components/player/getChapterStyle.js @@ -0,0 +1,82 @@ +export default function getChapterStyles(intro, outro) { + let styles = ` + .art-chapters { + gap: 0px !important; + } + `; + + if (intro && outro) { + if ( + intro.start === 0 && + intro.end === 0 && + outro.start === 0 && + outro.end === 0 + ) { + styles += ``; + } else if ( + intro.start === 0 && + intro.end === 0 && + outro.start !== 0 && + outro.end !== 0 + ) { + styles += ` + .art-chapter:nth-child(2) { + background-color: #fdd253; + transform: scaleY(0.6); + } + `; + } else if ( + intro.start === 0 && + intro.end !== 0 && + outro.start === 0 && + outro.end === 0 + ) { + styles += ` + .art-chapter:nth-child(1){ + background-color: #fdd253; + transform: scaleY(0.6); + } + `; + } else if ( + intro.start === 0 && + intro.end !== 0 && + outro.start !== 0 && + outro.end !== 0 + ) { + styles += ` + .art-chapter:nth-child(1), + .art-chapter:nth-child(3) { + background-color: #fdd253; + transform: scaleY(0.6); + } + `; + } else if ( + intro.start !== 0 && + intro.end !== 0 && + outro.start === 0 && + outro.end === 0 + ) { + styles += ` + .art-chapter:nth-child(2) { + background-color: #fdd253; + transform: scaleY(0.6); + } + `; + } else if ( + intro.start !== 0 && + intro.end !== 0 && + outro.start !== 0 && + outro.end !== 0 + ) { + styles += ` + .art-chapter:nth-child(2), + .art-chapter:nth-child(4) { + background-color: #fdd253; + transform: scaleY(0.6); + } + `; + } + } + + return styles; +} diff --git a/src/components/player/getVttArray.js b/src/components/player/getVttArray.js new file mode 100644 index 0000000..6df8d10 --- /dev/null +++ b/src/components/player/getVttArray.js @@ -0,0 +1,101 @@ +function padEnd(str, targetLength, padString) { + if (str.length > targetLength) { + return String(str); + } else { + targetLength = targetLength - str.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); + } + return String(str) + padString.slice(0, targetLength); + } +} + +function t2d(time) { + var arr = time.split("."); + var left = arr[0].split(":") || []; + var right = padEnd(arr[1] || "0", 3, "0"); + var ms = Number(right) / 1000; + + var h = Number(left[left.length - 3] || 0) * 3600; + var m = Number(left[left.length - 2] || 0) * 60; + var s = Number(left[left.length - 1] || 0); + return h + m + s + ms; +} + +export default async function getVttArray(vttUrl = "") { + const vttString = await (await fetch(vttUrl)).text(); + let lines = vttString.split(/\r?\n/).filter((item) => item.trim()); + const vttArray = []; + + //checking if the WEBVTT header is present + const isWebVTTHeader = lines[0].trim().toUpperCase() === "WEBVTT"; + + let startIndex = 0; + let increment = 2; + + // Check if the first line is an index line + const indexLineReg = /^\d+$/; // Regex to match lines containing only digits + + if (!isWebVTTHeader && indexLineReg.test(lines[0].trim())) { + // console.log("WEBVTT not present but index line is present"); + increment = 3; // Set increment to 3 if an index line is present + startIndex = 1; // Start from the second line + } else if (isWebVTTHeader) { + // If WEBVTT is present, check the next line + // console.log("WEBVTT lines is present checking if index line is present..."); + const indexLine = lines[1]; + if (indexLine && indexLineReg.test(indexLine.trim())) { + // console.log("Index line is present"); + increment = 3; // Set increment to 3 if an index line is present + startIndex = 2; // Start from the line after the index + } else { + // console.log("Index line is not present"); + startIndex = 1; // If no index line, start from the line after WEBVTT + increment = 2; // Set increment to 2 + } + } + + for (let i = startIndex; i < lines.length; i += increment) { + const time = lines[i]; + const text = lines[i + 1]; + if (!text.trim()) continue; + + // console.log(`Processing time line: ${time}`); // Logging processing timestamps + + const timeReg = + /((?:[0-9]{2}:)?(?:[0-9]{2}:)?[0-9]{2}(?:.[0-9]{3})?)(?: ?--> ?)((?:[0-9]{2}:)?(?:[0-9]{2}:)?[0-9]{2}(?:.[0-9]{3})?)/; + const timeMatch = time.match(timeReg); + + if (!timeMatch) { + // console.warn(`Failed to match time: ${time}`); // Log failed matches + continue; // Skip to the next loop iteration if match fails + } + + const textReg = /(.*)#(\w{4})=(.*)/i; + const textMatch = text.match(textReg); + const start = Math.floor(t2d(timeMatch[1])); + const end = Math.floor(t2d(timeMatch[2])); + + let url = textMatch[1]; + const isAbsoluteUrl = /^\/|((https?|ftp|file):\/\/)/i.test(url); + if (!isAbsoluteUrl) { + const urlArr = vttUrl.split("/"); + urlArr.pop(); + urlArr.push(url); + url = urlArr.join("/"); + } + + const result = { start, end, url }; + + const keys = textMatch[2].split(""); + const values = textMatch[3].split(","); + + for (let j = 0; j < keys.length; j++) { + result[keys[j]] = values[j]; + } + + vttArray.push(result); + } + + return vttArray; +} diff --git a/src/components/player/pluginChapterStyle.js b/src/components/player/pluginChapterStyle.js new file mode 100644 index 0000000..1e945ce --- /dev/null +++ b/src/components/player/pluginChapterStyle.js @@ -0,0 +1,55 @@ +export default ` +.artplayer-plugin-chapter .art-control-progress-inner { + height: 100% !important; + background-color: transparent !important; +} +.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-hover, +.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-loaded, +.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-played { + display: none !important; +} +.artplayer-plugin-chapter .art-control-thumbnails { + bottom: calc(var(--art-bottom-gap) + 64px) !important; +} +.artplayer-plugin-chapter .art-chapters { + position: absolute; + z-index: 0; + inset: 0; + display: flex; + align-items: center; + gap: 4px; + height: 100%; + transform: scaleY(1.25); +} +.artplayer-plugin-chapter .art-chapters .art-chapter { + display: flex; + align-items: center; + height: 100%; +} +.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner { + position: relative; + cursor: pointer; + width: 100%; + height: 50%; + border-radius: 10px; + overflow: hidden; + transition: height var(--art-transition-duration) ease; + background-color: var(--art-progress-color); +} +.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner { + height: 100%; +} +.artplayer-plugin-chapter .art-chapter-title { + display: none; + position: absolute; + z-index: 70; + top: -50px; + left: 0; + padding: 3px 5px; + line-height: 1; + font-size: 14px; + border-radius: var(--art-border-radius); + white-space: nowrap; + background-color: var(--art-tip-background); +} +`; diff --git a/src/components/producer/Producer.jsx b/src/components/producer/Producer.jsx new file mode 100644 index 0000000..90b7c10 --- /dev/null +++ b/src/components/producer/Producer.jsx @@ -0,0 +1,102 @@ +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import Error from "../error/Error"; +import Topten from "../topten/Topten"; +import Genre from "../genres/Genre"; +import SidecardLoader from "../Loader/Sidecard.loader"; +import PageSlider from "../pageslider/PageSlider"; +import CategoryCard from "../categorycard/CategoryCard"; +import { useEffect, useState } from "react"; +import { useHomeInfo } from "@/src/context/HomeInfoContext"; +import getProducer from "@/src/utils/getProducer.utils"; +import Loader from "../Loader/Loader"; + +function Producer() { + const { id } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const [producerInfo, setProducerInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [totalPages, setTotalPages] = useState(0); + const page = parseInt(searchParams.get("page")) || 1; + const { homeInfo, homeInfoLoading } = useHomeInfo(); + const navigate = useNavigate(); + useEffect(() => { + const fetchProducerInfo = async () => { + setLoading(true); + try { + const data = await getProducer(id, page); + setProducerInfo(data.data); + setTotalPages(data.totalPages); + setLoading(false); + } catch (err) { + setError(err); + console.error("Error fetching category info:", err); + } + }; + fetchProducerInfo(); + window.scrollTo(0, 0); + }, [id, page]); + if (loading) return; + if (error) { + navigate("/error-page"); + return ; + } + if (!producerInfo) { + navigate("/404-not-found-page"); + return null; + } + const handlePageChange = (newPage) => { + setSearchParams({ page: newPage }); + }; + + return ( + + {producerInfo ? ( ++ ); +} +export default Producer; diff --git a/src/components/qtip/Qtip.jsx b/src/components/qtip/Qtip.jsx new file mode 100644 index 0000000..a3c4efe --- /dev/null +++ b/src/components/qtip/Qtip.jsx @@ -0,0 +1,159 @@ +import BouncingLoader from "../ui/bouncingloader/Bouncingloader"; +import getQtip from "@/src/utils/getQtip.utils"; +import { useState, useEffect } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faPlay, + faStar, + faClosedCaptioning, + faMicrophone, +} from "@fortawesome/free-solid-svg-icons"; +import { Link } from "react-router-dom"; + +function Qtip({ id }) { + const [qtip, setQtip] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchQtipInfo = async () => { + setLoading(true); + try { + const data = await getQtip(id); + setQtip(data); + } catch (err) { + console.error("Error fetching anime info:", err); + setError(err); + } finally { + setLoading(false); + } + }; + fetchQtipInfo(); + }, [id]); + + return ( ++ {page > totalPages ? ( ++ ) : ( ++ You came a long way, go back
+ ) : ( +
+ nothing is here ++ {producerInfo && ( ++ )} ++ )} + + + {homeInfoLoading ? ( +++ ) : ( + <> + {homeInfo && homeInfo.topten && ( + + )} + {homeInfo?.genres && } + > + )} + + )} + + {loading || error || !qtip ? ( ++ ); +} + +export default Qtip; diff --git a/src/components/schedule/Schedule.jsx b/src/components/schedule/Schedule.jsx new file mode 100644 index 0000000..200f6a6 --- /dev/null +++ b/src/components/schedule/Schedule.jsx @@ -0,0 +1,241 @@ +import { useState, useEffect, useRef } from "react"; +import getSchedInfo from "../../utils/getScheduleInfo.utils"; +import { Pagination, Navigation } from "swiper/modules"; +import { Swiper, SwiperSlide } from "swiper/react"; +import { FaChevronLeft, FaChevronRight } from "react-icons/fa"; +import BouncingLoader from "../ui/bouncingloader/Bouncingloader"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlay } from "@fortawesome/free-solid-svg-icons"; +import "./schedule.css"; +import { Link } from "react-router-dom"; + +const Schedule = () => { + const [dates, setDates] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [showAll, setShowAll] = useState(false); + const [currentActiveIndex, setCurrentActiveIndex] = useState(null); + const [scheduleData, setscheduleData] = useState([]); + const [currentTime, setCurrentTime] = useState(new Date()); + const cardRefs = useRef([]); + const swiperRef = useRef(null); + const currentDate = new Date(); + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + const monthName = currentDate.toLocaleString("default", { month: "short" }); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const GMTOffset = `GMT ${ + new Date().getTimezoneOffset() > 0 ? "-" : "+" + }${String(Math.floor(Math.abs(new Date().getTimezoneOffset()) / 60)).padStart( + 2, + "0" + )}:${String(Math.abs(new Date().getTimezoneOffset()) % 60).padStart(2, "0")}`; + const months = []; + + useEffect(() => { + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month, day); + const dayname = date.toLocaleString("default", { weekday: "short" }); + const yearr = date.getFullYear(); + const monthh = String(date.getMonth() + 1).padStart(2, "0"); + const dayy = String(date.getDate()).padStart(2, "0"); + const fulldate = `${yearr}-${monthh}-${dayy}`; + months.push({ day, monthName, dayname, fulldate }); + } + setDates(months); + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + const todayIndex = dates.findIndex( + (date) => + date.fulldate === + `${currentDate.getFullYear()}-${String( + currentDate.getMonth() + 1 + ).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}` + ); + + if (todayIndex !== -1) { + setCurrentActiveIndex(todayIndex); + toggleActive(todayIndex); + } + }, [dates]); + + const fetchSched = async (date) => { + try { + setLoading(true); + + // Check if cached data exists + const cachedData = localStorage.getItem(`schedule-${date}`); + if (cachedData) { + const parsedData = JSON.parse(cachedData); + setscheduleData(Array.isArray(parsedData) ? parsedData : []); + } else { + const data = await getSchedInfo(date); + setscheduleData(Array.isArray(data) ? data : []); + localStorage.setItem(`schedule-${date}`, JSON.stringify(data || [])); + } + } catch (err) { + console.error("Error fetching schedule info:", err); + setError(err); + } finally { + setLoading(false); + } + }; + + const toggleActive = (index) => { + cardRefs.current.forEach((card) => { + if (card) { + card.classList.remove("active"); + } + }); + if (cardRefs.current[index]) { + cardRefs.current[index].classList.add("active"); + if (dates[index] && dates[index].fulldate) { + fetchSched(dates[index].fulldate); + } + setCurrentActiveIndex(index); + } + }; + + const toggleShowAll = () => { + setShowAll(!showAll); + }; + + useEffect(() => { + setShowAll(false); + if (currentActiveIndex !== null && swiperRef.current) { + swiperRef.current.slideTo(currentActiveIndex); + } + }, [currentActiveIndex]); + + return ( + <> ++ ) : ( + ++ )} ++ {qtip.title} +
++ {qtip?.rating && ( ++ {qtip?.description && ( +++ )} ++ {qtip.rating}
++ {qtip?.quality && ( ++++ )} +{qtip.quality}
++ {qtip?.subCount && ( ++ {qtip?.type && ( +++ )} + {qtip?.dubCount && ( ++ {qtip.subCount}
+++ )} + {qtip?.episodeCount && ( ++ {qtip.dubCount}
+++ )} ++ {qtip.episodeCount} +
+++ )} +{qtip.type}
++ {qtip.description} +
+ )} ++ {qtip?.japaneseTitle && ( ++ ++ + Japanese: + + {qtip.japaneseTitle} ++ )} + {qtip?.Synonyms && ( ++ + Synonyms: + + {qtip.Synonyms} ++ )} + {qtip?.airedDate && ( ++ Aired: + {qtip.airedDate} ++ )} + {qtip?.status && ( ++ + Status: + + {qtip.status} ++ )} + {qtip?.genres && ( ++ + Genres: + + {qtip.genres.map((genre, index) => ( + + + {genre} + {index === qtip.genres.length - 1 ? "" : ","} + + + ))} ++ )} ++ Watch Now
+ ++++++ Estimated Schedule +++ ({GMTOffset}) {currentTime.toLocaleDateString()}{" "} + {currentTime.toLocaleTimeString()} +
+++ {loading ? ( +++(swiperRef.current = swiper)} + > + {dates && + dates.map((date, index) => ( + + + ++ + ))} +(cardRefs.current[index] = el)} + onClick={() => toggleActive(index)} + className={`h-[70px] flex flex-col justify-center items-center w-full text-center rounded-xl shadow-lg cursor-pointer ${ + currentActiveIndex === index + ? "bg-[#ffbade] text-black" + : "bg-white bg-opacity-5 text-[#ffffff] hover:bg-[#373646] transition-all duration-300 ease-in-out" + }`} + > +++ {date.dayname} +++ {date.monthName} {date.day} ++++ ) : !scheduleData || scheduleData.length === 0 ? ( ++ + No data to display ++ ) : error ? ( ++ Something went wrong ++ ) : ( ++ {(showAll + ? scheduleData + : Array.isArray(scheduleData) + ? scheduleData.slice(0, 7) + : [] + ).map((item, idx) => ( + ++ )} + > + ); +}; + +export default Schedule; diff --git a/src/components/schedule/schedule.css b/src/components/schedule/schedule.css new file mode 100644 index 0000000..440906f --- /dev/null +++ b/src/components/schedule/schedule.css @@ -0,0 +1,11 @@ +.next, +.prev { + width: 30px; + height: 30px; + border-radius: 100%; + background-color: white; + color: black; + font-size: 13px; + padding: 10px; + z-index: 10; +} diff --git a/src/components/searchbar/MobileSearch.jsx b/src/components/searchbar/MobileSearch.jsx new file mode 100644 index 0000000..80a56d7 --- /dev/null +++ b/src/components/searchbar/MobileSearch.jsx @@ -0,0 +1,73 @@ +import Suggestion from '../suggestion/Suggestion'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import useSearch from '@/src/hooks/useSearch'; +import { useNavigate } from 'react-router-dom'; + +function MobileSearch() { + const navigate = useNavigate(); + const { + isSearchVisible, + searchValue, + setSearchValue, + isFocused, + setIsFocused, + debouncedValue, + suggestionRefs, + addSuggestionRef, + } = useSearch(); + const handleSearchClick = () => { + if (searchValue.trim() && window.innerWidth <= 600) { + navigate(`/search?keyword=${encodeURIComponent(searchValue)}`); + } + }; + return ( + <> + {isSearchVisible && ( +++ + + ))} + {scheduleData.length > 7 && ( + + )} ++ {item.time || "N/A"} +++ {item.title || "N/A"} +
++ setSearchValue(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => { + setTimeout(() => { + const isInsideSuggestionBox = suggestionRefs.current.some( + (ref) => ref && ref.contains(document.activeElement), + ); + if (!isInsideSuggestionBox) { + setIsFocused(false); + } + }, 100); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSearchClick(); + } + }} + /> + + {searchValue.trim() && isFocused && ( ++ )} + > + ); +} + +export default MobileSearch; diff --git a/src/components/searchbar/WebSearch.jsx b/src/components/searchbar/WebSearch.jsx new file mode 100644 index 0000000..8f36cd6 --- /dev/null +++ b/src/components/searchbar/WebSearch.jsx @@ -0,0 +1,77 @@ +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Suggestion from "../suggestion/Suggestion"; +import useSearch from "@/src/hooks/useSearch"; +import { useNavigate } from "react-router-dom"; + +function WebSearch() { + const navigate = useNavigate(); + const { + setIsSearchVisible, + searchValue, + setSearchValue, + isFocused, + setIsFocused, + debouncedValue, + suggestionRefs, + addSuggestionRef, + } = useSearch(); + + const handleSearchClick = () => { + if (window.innerWidth <= 600) { + setIsSearchVisible((prev) => !prev); + } + if (searchValue.trim() && window.innerWidth > 600) { + navigate(`/search?keyword=${encodeURIComponent(searchValue)}`); + } + }; + + return ( +++ )} ++ + setSearchValue(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => { + setTimeout(() => { + const isInsideSuggestionBox = suggestionRefs.current.some( + (ref) => ref && ref.contains(document.activeElement), + ); + if (!isInsideSuggestionBox) { + setIsFocused(false); + } + }, 100); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + if (searchValue.trim()) { + navigate(`/search?keyword=${encodeURIComponent(searchValue)}`); + } + } + }} + /> + + {searchValue.trim() && isFocused && ( ++ ); +} + +export default WebSearch; diff --git a/src/components/servers/Servers.css b/src/components/servers/Servers.css new file mode 100644 index 0000000..10bf0e4 --- /dev/null +++ b/src/components/servers/Servers.css @@ -0,0 +1,9 @@ +.servers { + border-bottom: 1px dashed #35373d; +} +.servers:only-child { + border-bottom: none; +} +.servers:last-child { + border-bottom: none; +} diff --git a/src/components/servers/Servers.jsx b/src/components/servers/Servers.jsx new file mode 100644 index 0000000..39f82bd --- /dev/null +++ b/src/components/servers/Servers.jsx @@ -0,0 +1,187 @@ +/* eslint-disable react/prop-types */ +import { + faClosedCaptioning, + faFile, + faMicrophone, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import BouncingLoader from "../ui/bouncingloader/Bouncingloader"; +import "./Servers.css"; +import { useEffect } from "react"; + +function Servers({ + servers, + activeEpisodeNum, + activeServerId, + setActiveServerId, + serverLoading, + setActiveServerType, + setActiveServerName, +}) { + const subServers = + servers?.filter((server) => server.type === "sub") || []; + const dubServers = + servers?.filter((server) => server.type === "dub") || []; + const rawServers = + servers?.filter((server) => server.type === "raw") || []; + + useEffect(() => { + const savedServerName = localStorage.getItem("server_name"); + if (savedServerName) { + const matchingServer = servers?.find( + (server) => server.serverName === savedServerName, + ); + + if (matchingServer) { + setActiveServerId(matchingServer.data_id); + setActiveServerType(matchingServer.type); + } else if (servers && servers.length > 0) { + setActiveServerId(servers[0].data_id); + setActiveServerType(servers[0].type); + } + } else if (servers && servers.length > 0) { + setActiveServerId(servers[0].data_id); + setActiveServerType(servers[0].type); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [servers]); + + const handleServerSelect = (server) => { + setActiveServerId(server.data_id); + setActiveServerType(server.type); + setActiveServerName(server.serverName); + localStorage.setItem("server_name", server.serverName); + localStorage.setItem("server_type", server.type); + }; + return ( +++ )} ++ + {serverLoading ? ( ++ ); +} + +export default Servers; diff --git a/src/components/sidebar/Sidebar.jsx b/src/components/sidebar/Sidebar.jsx new file mode 100644 index 0000000..61a61c9 --- /dev/null +++ b/src/components/sidebar/Sidebar.jsx @@ -0,0 +1,141 @@ +import { FaChevronLeft } from "react-icons/fa"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFilm, faRandom } from "@fortawesome/free-solid-svg-icons"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { useEffect } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { + cleanupScrollbar, + toggleScrollbar, +} from "@/src/helper/toggleScrollbar"; + +const Sidebar = ({ isOpen, onClose }) => { + const { language, toggleLanguage } = useLanguage(); + const location = useLocation(); + + useEffect(() => { + toggleScrollbar(isOpen); + return () => { + cleanupScrollbar(); + }; + }, [isOpen]); + + useEffect(() => { + onClose(); + }, [location]); + + return ( + <> + {isOpen && ( + + )} + +++ ) : servers ? ( ++ ++ ) : ( ++++ You are watching
+
+ + Episode {activeEpisodeNum} + ++ If the current server doesn't work, please try other servers + beside. +
++ {rawServers.length > 0 && ( ++++ )} + {subServers.length > 0 && ( ++++ RAW:
++ {rawServers.map((item, index) => ( ++handleServerSelect(item)} + > ++ ))} ++ {item.serverName} +
+++ )} + {dubServers.length > 0 && ( ++++ SUB:
++ {subServers.map((item, index) => ( ++handleServerSelect(item)} + > ++ ))} ++ {item.serverName} +
+++ )} ++++ DUB:
++ {dubServers.map((item, index) => ( ++handleServerSelect(item)} + > ++ ))} ++ {item.serverName} +
++ Could not load servers
+ )} +
+ Either reload or try again after sometime +++ > + ); +}; + +export default Sidebar; diff --git a/src/components/sidecard/Sidecard.jsx b/src/components/sidecard/Sidecard.jsx new file mode 100644 index 0000000..fb539da --- /dev/null +++ b/src/components/sidecard/Sidecard.jsx @@ -0,0 +1,142 @@ +import React, { useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faClosedCaptioning, + faMicrophone, +} from "@fortawesome/free-solid-svg-icons"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { Link, useNavigate } from "react-router-dom"; +import useToolTipPosition from "@/src/hooks/useToolTipPosition"; +import Qtip from "../qtip/Qtip"; + +function Sidecard({ data, label, className, limit }) { + const { language } = useLanguage(); + const navigate = useNavigate(); + const [showAll, setShowAll] = useState(false); + const [hoverTimeout, setHoverTimeout] = useState(null); + const handleMouseEnter = (item, index) => { + const timeout = setTimeout(() => { + setHoveredItem(item.id + index); + }, 400); + setHoverTimeout(timeout); + }; + const handleMouseLeave = () => { + clearTimeout(hoverTimeout); + setHoveredItem(null); + }; + const toggleShowAll = () => { + setShowAll((prev) => !prev); + }; + + const displayedData = limit + ? data.slice(0, limit) + : showAll + ? data + : data.slice(0, 6); + const [hoveredItem, setHoveredItem] = useState(null); + const { tooltipPosition, tooltipHorizontalPosition, cardRefs } = + useToolTipPosition(hoveredItem, data); + return ( ++++ +++ {[ + { icon: faRandom, label: "Random" }, + { icon: faFilm, label: "Movie" }, + ].map((item, index) => ( + +++ + {item.label} +
+ + ))} ++++ {["EN", "JP"].map((lang, index) => ( + + ))} +++++ Anime name +
++ {[ + { name: "Home", path: "/home" }, + { name: "Subbed Anime", path: "/subbed-anime" }, + { name: "Dubbed Anime", path: "/dubbed-anime" }, + { name: "Most Popular", path: "/most-popular" }, + { name: "Movies", path: "/movie" }, + { name: "TV Series", path: "/tv" }, + { name: "OVAs", path: "/ova" }, + { name: "ONAs", path: "/ona" }, + { name: "Specials", path: "/special" }, + { + name: "Join Telegram", + path: "https://t.me/zenime_discussion", + }, + ].map((item, index) => ( +
+- + + {item.name} + +
+ ))} +++ ); +} + +export default React.memo(Sidecard); diff --git a/src/components/splashscreen/SplashScreen.css b/src/components/splashscreen/SplashScreen.css new file mode 100644 index 0000000..7401cba --- /dev/null +++ b/src/components/splashscreen/SplashScreen.css @@ -0,0 +1,227 @@ +/* Base styles */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + color: white; +} + +/* Container and background */ +.splash-container { + min-height: 100vh; + width: 100%; + position: relative; + background: url('/splash.jpg') no-repeat center center fixed; + background-size: cover; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 0 30px; +} + +.splash-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 1; +} + +.content-wrapper { + position: relative; + z-index: 2; + width: 100%; + max-width: 800px; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 140px; +} + +/* Logo */ +.logo-container { + margin-bottom: 30px; +} + +.logo { + height: 75px; + width: auto; +} + +/* Search */ +.search-container { + width: 100%; + max-width: 500px; + position: relative; + margin-bottom: 24px; +} + +.search-input { + width: 100%; + padding: 14px 48px 14px 20px; + background: rgba(17, 17, 17, 0.75); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: white; + font-size: 16px; + outline: none; + transition: border-color 0.2s; +} + +.search-input:focus { + border-color: rgba(255, 255, 255, 0.3); +} + +.search-input::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.search-button { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 0; + font-size: 18px; + transition: color 0.2s; +} + +.search-button:hover { + color: white; +} + +/* Enter button */ +.enter-button { + background: white; + color: black; + padding: 12px 24px; + border-radius: 8px; + text-decoration: none; + font-weight: 500; + margin: 8px 0 60px; + transition: background-color 0.2s; +} + +.enter-button:hover { + background: #ffbade; +} + +/* FAQ Section */ +.faq-section { + width: 100%; + max-width: 700px; +} + +.faq-title { + font-size: 32px; + font-weight: 700; + text-align: center; + margin-bottom: 40px; + color: white; +} + +.faq-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.faq-item { + background: #141414; + border-radius: 12px; + overflow: hidden; + border: 1px solid #1a1a1a; +} + +.faq-question { + width: 100%; + padding: 18px 24px; + display: flex; + justify-content: space-between; + align-items: center; + background: none; + border: none; + color: white; + font-size: 17px; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.faq-question:hover { + background: #1a1a1a; +} + +.faq-toggle { + font-size: 16px; + color: white; + opacity: 0.8; + transition: transform 0.2s ease; +} + +.faq-toggle.rotate { + transform: rotate(180deg); +} + +.faq-answer { + padding: 0 24px 18px; + color: rgba(255, 255, 255, 0.7); + line-height: 1.6; + font-size: 15px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .content-wrapper { + padding-top: 100px; + } + + .logo { + height: 60px; + } + + .search-input { + padding: 12px 40px 12px 16px; + font-size: 15px; + } + + .faq-title { + font-size: 24px; + margin-bottom: 24px; + } +} + +@media (max-width: 480px) { + .content-wrapper { + padding-top: 80px; + } + + .logo { + height: 50px; + } + + .search-input { + padding: 12px 36px 12px 14px; + font-size: 14px; + } + + .enter-button { + padding: 10px 20px; + font-size: 14px; + } + + .faq-question { + padding: 16px; + font-size: 15px; + } + + .faq-answer { + padding: 0 16px 16px; + font-size: 14px; + } +} diff --git a/src/components/splashscreen/SplashScreen.jsx b/src/components/splashscreen/SplashScreen.jsx new file mode 100644 index 0000000..b060ae0 --- /dev/null +++ b/src/components/splashscreen/SplashScreen.jsx @@ -0,0 +1,107 @@ +import { useState, useCallback } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import "./SplashScreen.css"; +import logoTitle from "@/src/config/logoTitle"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faMagnifyingGlass, faChevronDown } from "@fortawesome/free-solid-svg-icons"; + +const FAQ_ITEMS = [ + { + question: "Is JustAnime safe?", + answer: "Yes, JustAnime is completely safe to use. We ensure all content is properly scanned and secured for our users." + }, + { + question: "What makes JustAnime the best site to watch anime free online?", + answer: "JustAnime offers high-quality streaming, a vast library of anime, no intrusive ads, and a user-friendly interface - all completely free." + }, + { + question: "How do I request an anime?", + answer: "You can submit anime requests through our contact form or by reaching out to our support team." + } +]; + +function SplashScreen() { + const navigate = useNavigate(); + const [search, setSearch] = useState(""); + const [expandedFaq, setExpandedFaq] = useState(null); + + const handleSearchSubmit = useCallback(() => { + const trimmedSearch = search.trim(); + if (!trimmedSearch) return; + const queryParam = encodeURIComponent(trimmedSearch); + navigate(`/search?keyword=${queryParam}`); + }, [search, navigate]); + + const handleKeyDown = useCallback( + (e) => { + if (e.key === "Enter") { + handleSearchSubmit(); + } + }, + [handleSearchSubmit] + ); + + const toggleFaq = (index) => { + setExpandedFaq(expandedFaq === index ? null : index); + }; + + return ( +{label}
++ {data && + displayedData.map((item, index) => ( ++(cardRefs.current[index] = el)} + > ++ ))} + {!limit && data.length > 6 && ( + + )} ++ {hoveredItem === item.id + index && + window.innerWidth > 1024 && ( ++++ )} ++ navigate(`/watch/${item.id}`)} + onMouseEnter={() => handleMouseEnter(item, index)} + onMouseLeave={handleMouseLeave} + /> +
+ + window.scrollTo({ top: 0, behavior: "smooth" }) + } + > + {language === "EN" ? item.title : item.japanese_title} + +++ {item.tvInfo?.sub && ( ++++ )} + {item.tvInfo?.dub && ( ++ + {item.tvInfo.sub} +
+++ )} + {item.tvInfo?.showType && ( ++ + {item.tvInfo.dub} +
++ ++ )} ++ {item.tvInfo.showType} +
++ ++ ); +} + +export default SplashScreen; diff --git a/src/components/spotlight/Spotlight.css b/src/components/spotlight/Spotlight.css new file mode 100644 index 0000000..01d867e --- /dev/null +++ b/src/components/spotlight/Spotlight.css @@ -0,0 +1,68 @@ +.swiper { + width: 100%; +} +.swiper-slide { + font-size: 18px; + display: -webkit-box; + display: -ms-flexbox; + display: -webkit-flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-box-align: center; + -ms-flex-align: center; +} +.button-prev, +.button-next { + width: 40px; + height: 40px; + color: white; + background-color: #383747; + border-radius: 7px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: all 0.3s ease-out; +} +.button-prev:hover, +.button-next:hover { + background-color: #ffbade; + color: #383747; +} + +.button-prev::after { + font-family: "Font Awesome 5 Free"; + content: "\f053"; + font-weight: 900; + font-size: 14px; +} + +.button-next::after { + font-family: "Font Awesome 5 Free"; + content: "\f054"; + font-weight: 900; + font-size: 14px; +} + +.swiper-horizontal > .swiper-pagination-bullets { + display: none; +} +.swiper-pagination-bullet-active { + background-color: rgb(239, 213, 22) !important; +} +@media only screen and (max-width: 575px) { + .swiper-horizontal > .swiper-pagination-bullets { + /* bottom: var(--swiper-pagination-bottom, 8px); */ + bottom: 0; + right: 10px !important ; + left: auto !important; + width: 20px !important; + bottom: 5px !important; + display: flex !important; + gap: 18px; + align-items: center; + justify-content: center; + height: 80%; + flex-direction: column; + } +} diff --git a/src/components/spotlight/Spotlight.jsx b/src/components/spotlight/Spotlight.jsx new file mode 100644 index 0000000..94e2e7a --- /dev/null +++ b/src/components/spotlight/Spotlight.jsx @@ -0,0 +1,54 @@ +import { Swiper, SwiperSlide } from "swiper/react"; +import { Navigation, Autoplay } from "swiper/modules"; +import "swiper/css"; +import "swiper/css/autoplay"; +import "swiper/css/navigation"; +import "./Spotlight.css"; +import Banner from "../banner/Banner"; + +const Spotlight = ({ spotlights }) => { + return ( + <> +++++ ++
+ setSearch(e.target.value)} + onKeyDown={handleKeyDown} + /> + ++ + + Enter Homepage → + + +++Frequently Asked Questions
++ {FAQ_ITEMS.map((item, index) => ( +++ + {expandedFaq === index && ( ++ ))} ++ {item.answer} ++ )} +++ > + ); +}; + +export default Spotlight; diff --git a/src/components/suggestion/Suggestion.jsx b/src/components/suggestion/Suggestion.jsx new file mode 100644 index 0000000..5cacb94 --- /dev/null +++ b/src/components/suggestion/Suggestion.jsx @@ -0,0 +1,115 @@ +import getSearchSuggestion from "@/src/utils/getSearchSuggestion.utils"; +import { useEffect, useState } from "react"; +import BouncingLoader from "../ui/bouncingloader/Bouncingloader"; +import { FaChevronRight } from "react-icons/fa"; +import { Link } from "react-router-dom"; + +function Suggestion({ keyword, className }) { + const [suggestion, setSuggestion] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasFetched, setHasFetched] = useState(false); + + useEffect(() => { + const fetchSearchSuggestion = async () => { + if (!keyword) return; + setLoading(true); + setHasFetched(false); + try { + const data = await getSearchSuggestion(keyword); + setSuggestion(data); + setHasFetched(true); + } catch (err) { + console.error("Error fetching search suggestion info:", err); + setError(err); + } finally { + setLoading(false); + } + }; + fetchSearchSuggestion(); + }, [keyword]); + + return ( ++ + ++ {spotlights && spotlights.length > 0 ? ( + <> ++ {spotlights.map((item, index) => ( + + > + ) : ( ++ + ))} ++ No spotlights to show.
+ )} ++ {loading ? ( ++ ); +} + +export default Suggestion; diff --git a/src/components/topten/Topten.jsx b/src/components/topten/Topten.jsx new file mode 100644 index 0000000..c0a6758 --- /dev/null +++ b/src/components/topten/Topten.jsx @@ -0,0 +1,176 @@ +import React, { useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faClosedCaptioning, + faMicrophone, +} from "@fortawesome/free-solid-svg-icons"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { Link, useNavigate } from "react-router-dom"; +import useToolTipPosition from "@/src/hooks/useToolTipPosition"; +import Qtip from "../qtip/Qtip"; + +function Topten({ data, className }) { + const { language } = useLanguage(); + const [activePeriod, setActivePeriod] = useState("today"); + const [hoveredItem, setHoveredItem] = useState(null); + const [hoverTimeout, setHoverTimeout] = useState(null); + const navigate = useNavigate(); + + const handlePeriodChange = (period) => { + setActivePeriod(period); + }; + + const handleNavigate = (id) => { + navigate(`/${id}`); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const currentData = + activePeriod === "today" + ? data.today + : activePeriod === "week" + ? data.week + : data.month; + + const { tooltipPosition, tooltipHorizontalPosition, cardRefs } = + useToolTipPosition(hoveredItem, currentData); + + const handleMouseEnter = (item, index) => { + if (hoverTimeout) clearTimeout(hoverTimeout); + setHoveredItem(item.id + index); + }; + + const handleMouseLeave = () => { + setHoverTimeout( + setTimeout(() => { + setHoveredItem(null); + }, 300) // Small delay to prevent flickering + ); + }; + + return ( ++ ) : error && !suggestion ? ( + Error loading suggestions+ ) : suggestion && hasFetched ? ( ++ {suggestion.map((item, index) => ( + ++ ) : hasFetched ? ( +{ + e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg"; + }} + /> +
+ {item?.title && ( ++ + ))} + {!loading && hasFetched && ( + ++ {item.title || "N/A"} +
+ )} + {item?.japanese_title && ( ++ {item.japanese_title || "N/A"} +
+ )} + {(item?.releaseDate || item?.showType || item?.duration) && ( +++ )} ++ {item.releaseDate || "N/A"} +
+ ++ {item.showType || "N/A"} +
+ ++ {item.duration || "N/A"} +
+++ + )} ++ View all results +
++ No results found!
+ ) : null} +++ ); +} + +export default React.memo(Topten); diff --git a/src/components/trending/Trending.jsx b/src/components/trending/Trending.jsx new file mode 100644 index 0000000..002ad54 --- /dev/null +++ b/src/components/trending/Trending.jsx @@ -0,0 +1,77 @@ +import { Pagination, Navigation } from "swiper/modules"; +import { Swiper, SwiperSlide } from "swiper/react"; +import { FaChevronLeft, FaChevronRight } from "react-icons/fa"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { Link, useNavigate } from "react-router-dom"; + +const Trending = ({ trending }) => { + const { language } = useLanguage(); + const navigate = useNavigate(); + return ( +++ +Top 10
++ {["today", "week", "month"].map((period) => ( +
+- handlePeriodChange(period)} + > + {period.charAt(0).toUpperCase() + period.slice(1)} +
+ ))} ++ {currentData && + currentData.map((item, index) => ( ++(cardRefs.current[index] = el)} + > ++ ))} ++ {`${index + 1 < 10 ? "0" : ""}${index + 1}`} +
++ {/* Image with tooltip behavior */} ++navigate(`/watch/${item.id}`)} + onMouseEnter={() => handleMouseEnter(item, index)} + onMouseLeave={handleMouseLeave} + /> + + {/* Tooltip positioned near image */} + {hoveredItem === item.id + index && + window.innerWidth > 1024 && ( +
{ + if (hoverTimeout) clearTimeout(hoverTimeout); + }} + onMouseLeave={handleMouseLeave} + > ++ )} + ++ + handleNavigate(item.id)} + > + {language === "EN" ? item.title : item.japanese_title} + +++ {item.tvInfo?.sub && ( ++++ )} + {item.tvInfo?.dub && ( ++ + {item.tvInfo.sub} +
+++ )} ++ + {item.tvInfo.dub} +
+++ ); +}; + +export default Trending; diff --git a/src/components/ui/Skeleton/Skeleton.css b/src/components/ui/Skeleton/Skeleton.css new file mode 100644 index 0000000..d51e7a0 --- /dev/null +++ b/src/components/ui/Skeleton/Skeleton.css @@ -0,0 +1,23 @@ +@keyframes shimmer { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } + } + + .shimmer-effect { + background: linear-gradient( + to right, + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.2) 20%, + rgba(255, 255, 255, 0.3) 40%, + rgba(255, 255, 255, 0.2) 60%, + rgba(255, 255, 255, 0.1) 80%, + rgba(0, 0, 0, 0.03) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite linear; + } + \ No newline at end of file diff --git a/src/components/ui/Skeleton/Skeleton.jsx b/src/components/ui/Skeleton/Skeleton.jsx new file mode 100644 index 0000000..eab2eaa --- /dev/null +++ b/src/components/ui/Skeleton/Skeleton.jsx @@ -0,0 +1,16 @@ +import { cn } from "@/lib/utils"; +import './Skeleton.css'; + +function Skeleton({ className, animation=true, ...props }) { + return ( + + ); +} + +export { Skeleton }; diff --git a/src/components/ui/bouncingloader/Bouncingloader.css b/src/components/ui/bouncingloader/Bouncingloader.css new file mode 100644 index 0000000..c5b1723 --- /dev/null +++ b/src/components/ui/bouncingloader/Bouncingloader.css @@ -0,0 +1,45 @@ +.bouncing-loading > div { + width: 18px; + height: 18px; + background-color: #858490; + border-radius: 100%; + display: inline-block; + -webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both; + animation: sk-bouncedelay 1.4s infinite ease-in-out both; +} + +.bouncing-loading .span1 { + -webkit-animation-delay: -0.32s; + animation-delay: -0.32s; +} + +.bouncing-loading .span2 { + -webkit-animation-delay: -0.16s; + animation-delay: -0.16s; +} + +@-webkit-keyframes sk-bouncedelay { + 0%, + 100%, + 80% { + -webkit-transform: scale(0); + } + + 40% { + -webkit-transform: scale(1); + } +} + +@keyframes sk-bouncedelay { + 0%, + 100%, + 80% { + -webkit-transform: scale(0); + transform: scale(0); + } + + 40% { + -webkit-transform: scale(1); + transform: scale(1); + } +} diff --git a/src/components/ui/bouncingloader/Bouncingloader.jsx b/src/components/ui/bouncingloader/Bouncingloader.jsx new file mode 100644 index 0000000..9c81444 --- /dev/null +++ b/src/components/ui/bouncingloader/Bouncingloader.jsx @@ -0,0 +1,12 @@ +import "./Bouncingloader.css" +const BouncingLoader = () => { + return ( ++ Trending +
++++ {trending && + trending.map((item, idx) => ( + +navigate(`/watch/${item.id}`)} + > + + ))} ++++ + {item.number} + ++ ++ {language === "EN" ? item.title : item.japanese_title} +++ +
+++++ +++ + + + ++ ); +}; + +export default BouncingLoader; \ No newline at end of file diff --git a/src/components/voiceactor/Voiceactor.jsx b/src/components/voiceactor/Voiceactor.jsx new file mode 100644 index 0000000..bcc588e --- /dev/null +++ b/src/components/voiceactor/Voiceactor.jsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { FaChevronRight } from "react-icons/fa"; +import VoiceactorList from "../voiceactorlist/VoiceactorList"; + +function Voiceactor({ animeInfo, className }) { + const [showVoiceActors, setShowVoiceActors] = useState(false); + return ( +++ ); +} + +export default Voiceactor; diff --git a/src/components/voiceactorlist/VoiceactorList.jsx b/src/components/voiceactorlist/VoiceactorList.jsx new file mode 100644 index 0000000..43f951d --- /dev/null +++ b/src/components/voiceactorlist/VoiceactorList.jsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from "react"; +import { + faAngleDoubleLeft, + faAngleDoubleRight, + faChevronLeft, + faChevronRight, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import fetchVoiceActorInfo from "@/src/utils/getVoiceActor.utils"; +import VoiceActorlistLoader from "../Loader/VoiceActorlist.loader"; +import { useNavigate } from "react-router-dom"; +import Error from "../error/Error"; +import { + cleanupScrollbar, + toggleScrollbar, +} from "@/src/helper/toggleScrollbar"; +import PageSlider from "../pageslider/PageSlider"; + +function VoiceactorList({ id, isOpen, onClose }) { + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [error, setError] = useState(null); + const [VoiceactorList, setVoiceactorList] = useState([]); + const navigate = useNavigate(); + + useEffect(() => { + toggleScrollbar(isOpen); + return () => { + cleanupScrollbar(); + }; + }, [isOpen]); + + useEffect(() => { + const fetchCategoryInfo = async () => { + setLoading(true); + try { + const data = await fetchVoiceActorInfo(id, page); + setVoiceactorList(data.data); + setTotalPages(data.totalPages); + setLoading(false); + } catch (err) { + setError(err); + console.error("Error fetching category info:", err); + } + }; + fetchCategoryInfo(); + }, [page]); + if (error) { + navigate("/error-page"); + return+++ Characters & Voice Actors +
+ ++ {animeInfo.charactersVoiceActors.slice(0, 6).map((character, index) => ( ++ {showVoiceActors && ( ++ {character.character && ( ++ ))} +++ )} + {character.voiceActors.length > 0 && character.voiceActors[0] && ( ++ {character.character.poster && ( ++{ + e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg"; + }} + className="w-[45px] h-[45px] flex-shrink-0 rounded-full object-cover" + loading="lazy" + /> + )} +
+ {character.character.name && ( +++ {character.character.name} +
+ )} + {character.character.cast && ( ++ {character.character.cast} +
+ )} +++ )} ++++ {character.voiceActors[0].name && ( + + {character.voiceActors[0].name} + + )} ++ {character.voiceActors[0].poster && ( +{ + e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg"; + }} + className="w-[45px] h-[45px] rounded-full object-cover grayscale hover:grayscale-0 hover:cursor-pointer flex-shrink-0 transition-all duration-300 ease-in-out" + /> + )} +
setShowVoiceActors(false)} + /> + )} + ; + } + if (!VoiceactorList) { + navigate("/404-not-found-page"); + return null; + } + return ( + ++ ); +} + +export default VoiceactorList; diff --git a/src/components/watchcontrols/Watchcontrols.jsx b/src/components/watchcontrols/Watchcontrols.jsx new file mode 100644 index 0000000..c5ca17e --- /dev/null +++ b/src/components/watchcontrols/Watchcontrols.jsx @@ -0,0 +1,97 @@ +import { faBackward, faForward } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useEffect, useState } from "react"; + +const ToggleButton = ({ label, isActive, onClick }) => ( + +); + +export default function WatchControls({ + autoPlay, + setAutoPlay, + autoSkipIntro, + setAutoSkipIntro, + autoNext, + setAutoNext, + episodeId, + episodes = [], + onButtonClick, +}) { + const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState( + episodes?.findIndex( + (episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId + ) + ); + + useEffect(() => { + if (episodes?.length > 0) { + const newIndex = episodes.findIndex( + (episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId + ); + setCurrentEpisodeIndex(newIndex); + } + }, [episodeId, episodes]); + + return ( ++ {!loading && ( +++ Characters & Voice Actors +
+ )} + + {loading ? ( ++ ) : ( + + {VoiceactorList.map((item, index) => ( ++ )} + +++ ))} +++ + {item.voiceActors && + item.voiceActors.length > 0 && + (item.voiceActors.length > 1 ? ( +{ + e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg"; + }} + /> +
+ {item.character.name && ( +++ {item.character.name} +
+ )} + {item.character.cast && ( ++ {item.character.cast} +
+ )} ++ {item.voiceActors.map((data, index) => ( ++ ) : ( +{ + e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg"; + }} + /> + ))} +
+ {item?.voiceActors[0]?.name && ( ++ ))} ++ {item.voiceActors[0].name} +
+ )} +{ + e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg"; + }} + /> +
+ ++ ++ ++ ); +} diff --git a/src/config/logoTitle.js b/src/config/logoTitle.js new file mode 100644 index 0000000..95b3110 --- /dev/null +++ b/src/config/logoTitle.js @@ -0,0 +1,3 @@ +const logoTitle="Zen!me" + +export default logoTitle; \ No newline at end of file diff --git a/src/config/website.js b/src/config/website.js new file mode 100644 index 0000000..d7cdf5d --- /dev/null +++ b/src/config/website.js @@ -0,0 +1,3 @@ +const website_name = "JustAnime"; + +export default website_name; \ No newline at end of file diff --git a/src/context/HomeInfoContext.jsx b/src/context/HomeInfoContext.jsx new file mode 100644 index 0000000..814e8b0 --- /dev/null +++ b/src/context/HomeInfoContext.jsx @@ -0,0 +1,31 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import getHomeInfo from '../utils/getHomeInfo.utils.js'; + +const HomeInfoContext = createContext(); + +export const HomeInfoProvider = ({ children }) => { + const [homeInfo, setHomeInfo] = useState(null); + const [homeInfoLoading, setHomeInfoLoading] = useState(true); + const [error, setError] = useState(null); + useEffect(() => { + const fetchHomeInfo = async () => { + try { + const data = await getHomeInfo(); + setHomeInfo(data); + } catch (err) { + console.error("Error fetching home info:", err); + setError(err); + } finally { + setHomeInfoLoading(false); + } + }; + fetchHomeInfo(); + }, []); + return ( +++setAutoPlay((prev) => !prev)} + /> + setAutoSkipIntro((prev) => !prev)} + /> + setAutoNext((prev) => !prev)} + /> + + + +++ {children} + + ); +}; + +export const useHomeInfo = () => useContext(HomeInfoContext); diff --git a/src/context/LanguageContext.jsx b/src/context/LanguageContext.jsx new file mode 100644 index 0000000..cec8067 --- /dev/null +++ b/src/context/LanguageContext.jsx @@ -0,0 +1,27 @@ +import { createContext, useContext, useState, useEffect } from 'react'; + +const LanguageContext = createContext(); + +export const LanguageProvider = ({ children }) => { + const [language, setLanguage] = useState(() => { + const storedLanguage = localStorage.getItem('language'); + return storedLanguage ? storedLanguage : 'EN'; + }); + useEffect(() => { + localStorage.setItem('language', language); + }, [language]); + + const toggleLanguage = (lang) => { + setLanguage(lang); + }; + + return ( ++ {children} + + ); +}; + +export const useLanguage = () => { + return useContext(LanguageContext); +}; diff --git a/src/context/SearchContext.jsx b/src/context/SearchContext.jsx new file mode 100644 index 0000000..36e738e --- /dev/null +++ b/src/context/SearchContext.jsx @@ -0,0 +1,13 @@ +import { createContext, useContext, useState } from 'react'; + +const SearchContext = createContext(); +export function SearchProvider({ children }) { + const [isSearchVisible, setIsSearchVisible] = useState(false); + + return ( ++ {children} + + ); +} +export const useSearchContext = () => useContext(SearchContext); \ No newline at end of file diff --git a/src/helper/toggleScrollbar.js b/src/helper/toggleScrollbar.js new file mode 100644 index 0000000..77e0bdd --- /dev/null +++ b/src/helper/toggleScrollbar.js @@ -0,0 +1,32 @@ +export function toggleScrollbar(isOpen) { + const getScrollbarWidth = () => { + return window.innerWidth - document.documentElement.clientWidth; + }; + const body = document.body; + if (isOpen) { + const scrollbarWidth = getScrollbarWidth(); + body.style.paddingRight = `${scrollbarWidth}px`; + body.classList.add("overflow-y-hidden"); + + const style = document.createElement("style"); + style.id = "hide-scrollbar"; + style.innerHTML = `::-webkit-scrollbar { display: none; }`; + document.head.appendChild(style); + } else { + body.style.paddingRight = "0"; + body.classList.remove("overflow-y-hidden"); + const styleElement = document.getElementById("hide-scrollbar"); + if (styleElement) { + styleElement.remove(); + } + } +} +export function cleanupScrollbar() { + const body = document.body; + body.style.paddingRight = "0"; + body.classList.remove("overflow-y-hidden"); + const styleElement = document.getElementById("hide-scrollbar"); + if (styleElement) { + styleElement.remove(); + } +} diff --git a/src/hooks/useSearch.js b/src/hooks/useSearch.js new file mode 100644 index 0000000..f094b86 --- /dev/null +++ b/src/hooks/useSearch.js @@ -0,0 +1,64 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useLocation } from "react-router-dom"; +import { useSearchContext } from "@/src/context/SearchContext"; + +const useSearch = () => { + const { isSearchVisible, setIsSearchVisible } = useSearchContext(); + const [searchValue, setSearchValue] = useState(""); + const [isFocused, setIsFocused] = useState(false); + const [debouncedValue, setDebouncedValue] = useState(""); + const suggestionRefs = useRef([]); + const location = useLocation(); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(searchValue); + }, 500); + return () => { + clearTimeout(timer); + }; + }, [searchValue]); + + useEffect(() => { + setIsSearchVisible(false); + setSearchValue(""); + setDebouncedValue(""); + // setIsFocused(false); + }, [location, setIsSearchVisible]); + + useEffect(() => { + const handleClickOutside = (event) => { + const isInsideSuggestionBox = suggestionRefs.current.some( + (ref) => ref && ref.contains(event.target) + ); + const isInsideInput = document.activeElement === event.target; + if (!isInsideSuggestionBox && !isInsideInput) { + setIsFocused(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + const addSuggestionRef = useCallback((ref) => { + if (ref && !suggestionRefs.current.includes(ref)) { + suggestionRefs.current.push(ref); + } + }, []); + + return { + isSearchVisible, + setIsSearchVisible, + searchValue, + setSearchValue, + isFocused, + setIsFocused, + debouncedValue, + suggestionRefs, + addSuggestionRef, + }; +}; + +export default useSearch; diff --git a/src/hooks/useToolTipPosition.js b/src/hooks/useToolTipPosition.js new file mode 100644 index 0000000..ae74331 --- /dev/null +++ b/src/hooks/useToolTipPosition.js @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from "react"; + +const useToolTipPosition = (hoveredItem, data) => { + const cardRefs = useRef([]); + const [tooltipPosition, setTooltipPosition] = useState("top-1/2"); + const [tooltipHorizontalPosition, setTooltipHorizontalPosition] = + useState("left-1/2"); + + const updateToolTipPosition = () => { + if (hoveredItem !== null) { + const refIndex = data.findIndex( + (item, index) => item.id + index === hoveredItem + ); + const ref = cardRefs.current[refIndex]; + if (ref) { + const { top, height, left, width } = ref.getBoundingClientRect(); + const adjustedTop = top + height / 2 - 64; + const bottomY = window.innerHeight - adjustedTop; + if (adjustedTop < bottomY) { + setTooltipPosition("top-1/2"); + } else { + setTooltipPosition("bottom-1/2"); + } + const adjustedLeft = left + width / 2; + const spaceRight = window.innerWidth - adjustedLeft; + if (spaceRight > 320) { + setTooltipHorizontalPosition("left-1/2"); + } else { + setTooltipHorizontalPosition("right-1/2"); + } + } + } + }; + + useEffect(() => { + updateToolTipPosition(); + const handleScroll = () => { + updateToolTipPosition(); + }; + window.addEventListener("scroll", handleScroll); + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, [hoveredItem, data]); + + return { tooltipPosition, tooltipHorizontalPosition, cardRefs }; +}; + +export default useToolTipPosition; diff --git a/src/hooks/useWatch.js b/src/hooks/useWatch.js new file mode 100644 index 0000000..cc416bb --- /dev/null +++ b/src/hooks/useWatch.js @@ -0,0 +1,269 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useState, useEffect, useRef } from "react"; +import getAnimeInfo from "@/src/utils/getAnimeInfo.utils"; +import getEpisodes from "@/src/utils/getEpisodes.utils"; +import getNextEpisodeSchedule from "../utils/getNextEpisodeSchedule.utils"; +import getServers from "../utils/getServers.utils"; +import getStreamInfo from "../utils/getStreamInfo.utils"; + +export const useWatch = (animeId, initialEpisodeId) => { + const [error, setError] = useState(null); + const [buffering, setBuffering] = useState(true); + const [streamInfo, setStreamInfo] = useState(null); + const [animeInfo, setAnimeInfo] = useState(null); + const [episodes, setEpisodes] = useState(null); + const [animeInfoLoading, setAnimeInfoLoading] = useState(false); + const [totalEpisodes, setTotalEpisodes] = useState(null); + const [seasons, setSeasons] = useState(null); + const [servers, setServers] = useState(null); + const [streamUrl, setStreamUrl] = useState(null); + const [isFullOverview, setIsFullOverview] = useState(false); + const [subtitles, setSubtitles] = useState([]); + const [thumbnail, setThumbnail] = useState(null); + const [intro, setIntro] = useState(null); + const [outro, setOutro] = useState(null); + const [episodeId, setEpisodeId] = useState(null); + const [activeEpisodeNum, setActiveEpisodeNum] = useState(null); + const [activeServerId, setActiveServerId] = useState(null); + const [activeServerType, setActiveServerType] = useState(null); + const [activeServerName, setActiveServerName] = useState(null); + const [serverLoading, setServerLoading] = useState(true); + const [nextEpisodeSchedule, setNextEpisodeSchedule] = useState(null); + const isServerFetchInProgress = useRef(false); + const isStreamFetchInProgress = useRef(false); + + useEffect(() => { + setEpisodes(null); + setEpisodeId(null); + setActiveEpisodeNum(null); + setServers(null); + setActiveServerId(null); + setStreamInfo(null); + setStreamUrl(null); + setSubtitles([]); + setThumbnail(null); + setIntro(null); + setOutro(null); + setBuffering(true); + setServerLoading(true); + setError(null); + setAnimeInfo(null); + setSeasons(null); + setTotalEpisodes(null); + setAnimeInfoLoading(true); + isServerFetchInProgress.current = false; + isStreamFetchInProgress.current = false; + }, [animeId]); + + useEffect(() => { + const fetchInitialData = async () => { + try { + setAnimeInfoLoading(true); + const [animeData, episodesData] = await Promise.all([ + getAnimeInfo(animeId, false), + getEpisodes(animeId), + ]); + setAnimeInfo(animeData?.data); + setSeasons(animeData?.seasons); + setEpisodes(episodesData?.episodes); + setTotalEpisodes(episodesData?.totalEpisodes); + const newEpisodeId = + initialEpisodeId || + (episodesData?.episodes?.length > 0 + ? episodesData.episodes[0].id.match(/ep=(\d+)/)?.[1] + : null); + setEpisodeId(newEpisodeId); + } catch (err) { + console.error("Error fetching initial data:", err); + setError(err.message || "An error occurred."); + } finally { + setAnimeInfoLoading(false); + } + }; + fetchInitialData(); + }, [animeId]); + + useEffect(() => { + const fetchNextEpisodeSchedule = async () => { + try { + const data = await getNextEpisodeSchedule(animeId); + setNextEpisodeSchedule(data); + } catch (err) { + console.error("Error fetching next episode schedule:", err); + } + }; + fetchNextEpisodeSchedule(); + }, [animeId]); + + useEffect(() => { + if (!episodes || !episodeId) { + setActiveEpisodeNum(null); + return; + } + const activeEpisode = episodes.find((episode) => { + const match = episode.id.match(/ep=(\d+)/); + return match && match[1] === episodeId; + }); + const newActiveEpisodeNum = activeEpisode ? activeEpisode.episode_no : null; + if (activeEpisodeNum !== newActiveEpisodeNum) { + setActiveEpisodeNum(newActiveEpisodeNum); + } + }, [episodeId, episodes]); + + useEffect(() => { + if (!episodeId || !episodes || isServerFetchInProgress.current) return; + + const fetchServers = async () => { + isServerFetchInProgress.current = true; + setServerLoading(true); + try { + const data = await getServers(animeId, episodeId); + console.log(data); + + const filteredServers = data?.filter( + (server) => + server.serverName === "HD-1" || + server.serverName === "HD-2" || + server.serverName === "HD-3" + ); + if (filteredServers.some((s) => s.type === "sub")) { + filteredServers.push({ + type: "sub", + data_id: "69696969", + server_id: "41", + serverName: "HD-4", + }); + } + if (filteredServers.some((s) => s.type === "dub")) { + filteredServers.push({ + type: "dub", + data_id: "96969696", + server_id: "42", + serverName: "HD-4", + }); + } + const savedServerName = localStorage.getItem("server_name"); + const savedServerType = localStorage.getItem("server_type"); + let initialServer = + data.find( + (s) => + s.serverName === savedServerName && s.type === savedServerType + ) || + data.find((s) => s.serverName === savedServerName) || + data.find((s) => s.type === savedServerType) || + data.find( + (s) => s.serverName === "HD-1" && s.type === savedServerType + ) || + data.find( + (s) => s.serverName === "HD-2" && s.type === savedServerType + ) || + data.find( + (s) => s.serverName === "HD-3" && s.type === savedServerType + ) || + data.find( + (s) => s.serverName === "HD-4" && s.type === savedServerType + ) || + filteredServers[0]; + setServers(filteredServers); + setActiveServerType(initialServer?.type); + setActiveServerName(initialServer?.serverName); + setActiveServerId(initialServer?.data_id); + } catch (error) { + console.error("Error fetching servers:", error); + setError(error.message || "An error occurred."); + } finally { + setServerLoading(false); + isServerFetchInProgress.current = false; + } + }; + fetchServers(); + }, [episodeId, episodes]); + // Fetch stream info only when episodeId, activeServerId, and servers are ready + useEffect(() => { + if ( + !episodeId || + !activeServerId || + !servers || + isServerFetchInProgress.current || + isStreamFetchInProgress.current + ) + return; + if ( + (activeServerName?.toLowerCase() === "hd-1" + || activeServerName?.toLowerCase() === "hd-2"|| activeServerName?.toLowerCase() === "hd-3"|| activeServerName?.toLowerCase() === "hd-4") + && + !serverLoading + ) { + setBuffering(false); + return; + } + const fetchStreamInfo = async () => { + isStreamFetchInProgress.current = true; + setBuffering(true); + try { + const server = servers.find((srv) => srv.data_id === activeServerId); + if (server) { + const data = await getStreamInfo( + animeId, + episodeId, + server.serverName.toLowerCase(), + server.type.toLowerCase() + ); + setStreamInfo(data); + setStreamUrl(data?.streamingLink?.link?.file || null); + setIntro(data?.streamingLink?.intro || null); + setOutro(data?.streamingLink?.outro || null); + const subtitles = + data?.streamingLink?.tracks + ?.filter((track) => track.kind === "captions") + .map(({ file, label }) => ({ file, label })) || []; + setSubtitles(subtitles); + const thumbnailTrack = data?.streamingLink?.tracks?.find( + (track) => track.kind === "thumbnails" && track.file + ); + if (thumbnailTrack) setThumbnail(thumbnailTrack.file); + } else { + setError("No server found with the activeServerId."); + } + } catch (err) { + console.error("Error fetching stream info:", err); + setError(err.message || "An error occurred."); + } finally { + setBuffering(false); + isStreamFetchInProgress.current = false; + } + }; + fetchStreamInfo(); + }, [episodeId, activeServerId, servers]); + + return { + error, + buffering, + serverLoading, + streamInfo, + animeInfo, + episodes, + nextEpisodeSchedule, + animeInfoLoading, + totalEpisodes, + seasons, + servers, + streamUrl, + isFullOverview, + setIsFullOverview, + subtitles, + thumbnail, + intro, + outro, + episodeId, + setEpisodeId, + activeEpisodeNum, + setActiveEpisodeNum, + activeServerId, + setActiveServerId, + activeServerType, + setActiveServerType, + activeServerName, + setActiveServerName, + }; +}; diff --git a/src/hooks/useWatchControl.js b/src/hooks/useWatchControl.js new file mode 100644 index 0000000..6ae19f9 --- /dev/null +++ b/src/hooks/useWatchControl.js @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; + +export default function useWatchControl() { + const [autoPlay, setAutoPlay] = useState( + () => JSON.parse(localStorage.getItem("autoPlay")) || false + ); + const [autoSkipIntro, setAutoSkipIntro] = useState( + () => JSON.parse(localStorage.getItem("autoSkipIntro")) || false + ); + const [autoNext, setAutoNext] = useState( + () => JSON.parse(localStorage.getItem("autoNext")) || false + ); + + useEffect(() => { + localStorage.setItem("autoPlay", JSON.stringify(autoPlay)); + }, [autoPlay]); + + useEffect(() => { + localStorage.setItem("autoSkipIntro", JSON.stringify(autoSkipIntro)); + }, [autoSkipIntro]); + + useEffect(() => { + localStorage.setItem("autoNext", JSON.stringify(autoNext)); + }, [autoNext]); + + return { + autoPlay, + setAutoPlay, + autoSkipIntro, + setAutoSkipIntro, + autoNext, + setAutoNext, + }; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..765f332 --- /dev/null +++ b/src/index.css @@ -0,0 +1,126 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #201f31; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} +body { + overflow-y: scroll; +} +.scrollbar-visible { + scrollbar-width: auto; + scrollbar-color: #888 #333; +} +.scrollbar-visible::-webkit-scrollbar { + width: 20px; +} + +.scrollbar-visible::-webkit-scrollbar-thumb { + background-color: #888; +} + +.scrollbar-visible::-webkit-scrollbar-track { + background: black; +} +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +::-webkit-scrollbar { + width: 16px; +} + +::-webkit-scrollbar-track { + background: #23222c; +} + +::-webkit-scrollbar-thumb { + background: #65646a; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +.is-visible { + opacity: 1; +} +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 10% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} +.dot { + width: 4px; + height: 4px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background: rgba(255, 255, 255, 0.3); + display: inline-block; +} diff --git a/src/lib/api.js b/src/lib/api.js deleted file mode 100644 index 1a6081f..0000000 --- a/src/lib/api.js +++ /dev/null @@ -1,858 +0,0 @@ -// Use absolute URL for server components and relative URL for client components -const isServer = typeof window === 'undefined'; -const API_BASE_URL = isServer - ? process.env.ANIWATCH_API // Use environment variable with fallback - : "/api/v2/hianime"; // Use relative URL for client-side - -// Common headers for all API requests -const API_HEADERS = { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'Origin': 'https://hianime.to', - 'Referer': 'https://hianime.to/', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' -}; - -export const fetchRecentEpisodes = async (page = 1) => { - try { - const response = await fetch(`${API_BASE_URL}/category/recently-updated?page=${page}`, { - headers: API_HEADERS, - credentials: 'omit' - }); - if (!response.ok) throw new Error('Failed to fetch recent episodes'); - const data = await response.json(); - return { - results: data.data.animes.map(anime => ({ - id: anime.id, - name: anime.name, - poster: anime.poster, - type: anime.type, - episodes: { - sub: anime.episodes?.sub || 0, - dub: anime.episodes?.dub || 0 - } - })) || [], - currentPage: data.data.currentPage, - hasNextPage: data.data.hasNextPage - }; - } catch (error) { - console.error('Error fetching recent episodes:', error); - return { results: [] }; - } -}; - -export const fetchTopAiring = async (page = 1) => { - try { - const response = await fetch(`${API_BASE_URL}/category/top-airing?page=${page}`); - if (!response.ok) throw new Error('Failed to fetch top airing'); - const data = await response.json(); - return { - results: data.data.animes.map(anime => ({ - id: anime.id, - name: anime.name, - poster: anime.poster, - type: anime.type, - episodes: { - sub: anime.episodes?.sub || 0, - dub: anime.episodes?.dub || 0 - } - })) || [], - currentPage: data.data.currentPage, - hasNextPage: data.data.hasNextPage - }; - } catch (error) { - console.error('Error fetching top airing:', error); - return { results: [] }; - } -}; - -export const fetchMostPopular = async (page = 1) => { - try { - const response = await fetch(`${API_BASE_URL}/category/most-popular?page=${page}`); - if (!response.ok) throw new Error('Failed to fetch most popular'); - const data = await response.json(); - return { - results: data.data.animes.map(anime => ({ - id: anime.id, - name: anime.name, - poster: anime.poster, - type: anime.type, - episodes: { - sub: anime.episodes?.sub || 0, - dub: anime.episodes?.dub || 0 - } - })) || [], - currentPage: data.data.currentPage, - hasNextPage: data.data.hasNextPage - }; - } catch (error) { - console.error('Error fetching most popular:', error); - return { results: [] }; - } -}; - -export const fetchMostFavorite = async (page = 1) => { - try { - const response = await fetch(`${API_BASE_URL}/category/most-favorite?page=${page}`); - if (!response.ok) throw new Error('Failed to fetch most favorite'); - const data = await response.json(); - return { - results: data.data.animes || [], - currentPage: data.data.currentPage, - hasNextPage: data.data.hasNextPage - }; - } catch (error) { - console.error('Error fetching most favorite:', error); - return { results: [] }; - } -}; - -export const fetchLatestCompleted = async (page = 1) => { - try { - const response = await fetch(`${API_BASE_URL}/category/completed?page=${page}`); - if (!response.ok) throw new Error('Failed to fetch latest completed'); - const data = await response.json(); - return { - results: data.data.animes.map(anime => ({ - id: anime.id, - name: anime.name, - poster: anime.poster, - type: anime.type, - episodes: { - sub: anime.episodes?.sub || 0, - dub: anime.episodes?.dub || 0 - } - })) || [], - currentPage: data.data.currentPage, - hasNextPage: data.data.hasNextPage - }; - } catch (error) { - console.error('Error fetching latest completed:', error); - return { results: [] }; - } -}; - -export const fetchTopUpcoming = async (page = 1) => { - try { - const response = await fetch(`${API_BASE_URL}/category/top-upcoming?page=${page}`); - if (!response.ok) throw new Error('Failed to fetch top upcoming'); - const data = await response.json(); - return { - results: data.data.animes || [], - currentPage: data.data.currentPage, - hasNextPage: data.data.hasNextPage - }; - } catch (error) { - console.error('Error fetching top upcoming:', error); - return { results: [] }; - } -}; - -export const fetchTrending = async () => { - try { - const response = await fetch(`${API_BASE_URL}/home`); - if (!response.ok) throw new Error('Failed to fetch trending anime'); - const data = await response.json(); - - // Map the trending animes to match the TrendingList component's expected format - const trendingAnimes = (data.data.trendingAnimes || []).map(anime => ({ - id: anime.id, - title: anime.name, - image: anime.poster, - rank: anime.rank - })); - - return { - results: trendingAnimes - }; - } catch (error) { - console.error('Error fetching trending anime:', error); - return { results: [] }; - } -}; - -export const fetchAnimeInfo = async (id) => { - try { - if (!id) { - return null; - } - - const encodedId = encodeURIComponent(id); - const url = `${API_BASE_URL}/anime/${encodedId}`; - - // Server-side fetch doesn't need credentials or mode settings - const requestOptions = { - method: 'GET', - headers: API_HEADERS, - }; - - const response = await fetch(url, requestOptions); - - // Handle failed requests gracefully - if (!response.ok) { - return createFallbackAnimeData(id); - } - - // Parse the JSON response - const data = await response.json(); - - // Check if the response is successful - if (!data.success && data.status !== 200) { - return createFallbackAnimeData(id); - } - - // The data structure might be nested in different ways depending on the API - const responseData = data.data || data; - - // Extract the anime data from the response - const animeData = responseData.anime; - - if (!animeData) { - return createFallbackAnimeData(id); - } - - // Create mock characterVoiceActor data if missing - if (!animeData.info?.characterVoiceActor || !Array.isArray(animeData.info?.characterVoiceActor) || animeData.info?.characterVoiceActor.length === 0) { - - // Ensure the info object exists - if (!animeData.info) animeData.info = {}; - - // Add mock data for the characters and voice actors - animeData.info.characterVoiceActor = [ - { - character: { - id: "character-1", - name: animeData.info?.name ? `${animeData.info.name} Main Character` : "Main Character", - poster: animeData.info?.poster || "https://via.placeholder.com/150", - cast: "Main" - }, - voiceActor: { - id: "voice-actor-1", - name: "Voice Actor", - poster: "https://via.placeholder.com/150", - cast: "Japanese" - } - }, - { - character: { - id: "character-2", - name: "Supporting Character", - poster: "https://via.placeholder.com/150", - cast: "Supporting" - }, - voiceActor: { - id: "voice-actor-2", - name: "Voice Actor 2", - poster: "https://via.placeholder.com/150", - cast: "Japanese" - } - } - ]; - } - - // Check for characterVoiceActor data - console.log('[API Debug] charactersVoiceActors:', - animeData.info?.charactersVoiceActors - ? `Found ${animeData.info.charactersVoiceActors.length} characters` - : 'Missing charactersVoiceActors data' - ); - - // Check the raw API response structure for characterVoiceActor - if (animeData.info) { - console.log('[API Debug] Raw charactersVoiceActors type:', - animeData.info.charactersVoiceActors ? - typeof animeData.info.charactersVoiceActors + ' ' + - (Array.isArray(animeData.info.charactersVoiceActors) ? 'is Array' : 'not Array') : - 'undefined' - ); - } - - // Return the complete data structure as expected by the components - return { - info: { - id: id, - name: animeData.info?.name || '', - jname: animeData.info?.jname || '', - poster: animeData.info?.poster || '', - description: animeData.info?.description || '', - stats: { - rating: animeData.info?.stats?.rating || '0', - quality: animeData.info?.stats?.quality || 'HD', - episodes: animeData.info?.stats?.episodes || { sub: 0, dub: 0 }, - type: animeData.info?.stats?.type || 'TV', - duration: animeData.info?.stats?.duration || 'Unknown' - }, - promotionalVideos: Array.isArray(animeData.info?.promotionalVideos) - ? animeData.info.promotionalVideos - : [], - characterVoiceActor: (() => { - // Explicit validation of charactersVoiceActors data (note the "s" in characters) - const charData = animeData.info?.charactersVoiceActors; - if (!charData) { - return []; - } - if (!Array.isArray(charData)) { - return []; - } - - // Validate each item in the array to ensure it has the required structure - return charData.filter(item => { - if (!item) return false; - if (!item.character || !item.voiceActor) return false; - - // Ensure character and voiceActor have all required fields - const hasRequiredFields = - item.character.id && - item.character.name && - item.character.poster && - item.voiceActor.id && - item.voiceActor.name && - item.voiceActor.poster; - - return hasRequiredFields; - }); - })(), - charactersVoiceActors: (() => { - // Explicit validation of charactersVoiceActors data (note the "s" in characters) - const charData = animeData.info?.charactersVoiceActors; - if (!charData) { - return []; - } - if (!Array.isArray(charData)) { - return []; - } - - // Validate each item in the array to ensure it has the required structure - return charData.filter(item => { - if (!item) return false; - if (!item.character || !item.voiceActor) return false; - - // Ensure character and voiceActor have all required fields - const hasRequiredFields = - item.character.id && - item.character.name && - item.character.poster && - item.voiceActor.id && - item.voiceActor.name && - item.voiceActor.poster; - - return hasRequiredFields; - }); - })() - }, - moreInfo: animeData.moreInfo || { - aired: '', - genres: [], - status: 'Unknown', - studios: '', - duration: '' - }, - relatedAnime: Array.isArray(responseData.relatedAnimes) - ? responseData.relatedAnimes - : [], - recommendations: Array.isArray(responseData.recommendedAnimes) - ? responseData.recommendedAnimes - : [], - mostPopular: Array.isArray(responseData.mostPopularAnimes) - ? responseData.mostPopularAnimes - : [], - seasons: Array.isArray(responseData.seasons) - ? responseData.seasons - : [] - }; - } catch (error) { - return createFallbackAnimeData(id); - } -}; - -// Helper function to create fallback anime data when the API fails -function createFallbackAnimeData(id) { - // Create the mock character data to be reused - const mockCharacterData = [ - { - character: { - id: "character-1", - name: "Main Character", - poster: "https://via.placeholder.com/150", - cast: "Main" - }, - voiceActor: { - id: "voice-actor-1", - name: "Voice Actor", - poster: "https://via.placeholder.com/150", - cast: "Japanese" - } - }, - { - character: { - id: "character-2", - name: "Supporting Character", - poster: "https://via.placeholder.com/150", - cast: "Supporting" - }, - voiceActor: { - id: "voice-actor-2", - name: "Voice Actor 2", - poster: "https://via.placeholder.com/150", - cast: "Japanese" - } - } - ]; - - return { - info: { - id: id, - name: 'Anime Information Temporarily Unavailable', - jname: '', - poster: 'https://via.placeholder.com/225x318?text=Anime', - description: 'The anime data could not be loaded at this time. Please try again later.', - stats: { - rating: '0', - quality: 'HD', - episodes: { - sub: 0, - dub: 0 - }, - type: 'Unknown', - duration: 'Unknown' - }, - promotionalVideos: [], - // Use same property name as in fetchAnimeInfo to match what the frontend expects - characterVoiceActor: mockCharacterData, - // Also include the API property name for compatibility - charactersVoiceActors: mockCharacterData - }, - moreInfo: { - aired: '', - genres: ['Action', 'Adventure'], - status: 'Unknown', - studios: '', - duration: '' - }, - relatedAnime: [], - recommendations: [], - mostPopular: [], - seasons: [] - }; -} - -export const fetchAnimeEpisodes = async (animeId) => { - try { - if (!animeId) { - console.error('Invalid anime ID provided'); - return { episodes: [] }; - } - - const apiUrl = `${API_BASE_URL}/anime/${encodeURIComponent(animeId)}/episodes`; - console.log(`[API Call] Fetching episodes for anime: ${animeId}`); - - const response = await fetch(apiUrl, { - headers: API_HEADERS, - credentials: 'omit' - }); - - if (!response.ok) { - throw new Error(`Failed to fetch episodes: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - console.log('[API Response] Episodes count:', data?.data?.episodes?.length || 0); - - if (!data || !data.data) { - console.error('[API Error] Empty response received for episodes'); - return { episodes: [] }; - } - - // API returns episodes with episode.id in the format (animeId?ep=episodeNumber) - // We use this id directly for all episode operations - - return { - episodes: data.data.episodes || [], - totalEpisodes: data.data.totalEpisodes || 0 - }; - } catch (error) { - console.error('Error fetching anime episodes:', error); - return { episodes: [] }; - } -}; - -export const fetchEpisodeServers = async (episodeId) => { - try { - if (!episodeId || episodeId === 'undefined') { - console.error('Invalid episode ID provided'); - return { servers: [] }; - } - - console.log(`[API] Processing episode ID: ${episodeId}`); - - // Use the episode.id directly - it's in the format "animeId?ep=episodeNumber" - const apiUrl = `${API_BASE_URL}/episode/servers?animeEpisodeId=${encodeURIComponent(episodeId)}`; - console.log(`[API Call] Fetching servers from: ${apiUrl}`); - - const response = await fetch(apiUrl, { - headers: API_HEADERS, - credentials: 'omit' - }); - - if (!response.ok) { - throw new Error(`Failed to fetch episode servers: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - console.log('[API Response] Episode servers:', data); - - if (!data || !data.success || !data.data) { - console.error('[API Error] Empty response received for episode servers'); - return { servers: [] }; - } - - // Get all servers from the response (sub, dub, raw) - // The response has separate arrays for sub, dub, and raw servers - const subServers = data.data.sub || []; - const dubServers = data.data.dub || []; - const rawServers = data.data.raw || []; - - // Combine all servers into a single array for easier handling - const allServers = [ - ...subServers.map(s => ({ ...s, category: 'sub' })), - ...dubServers.map(s => ({ ...s, category: 'dub' })), - ...rawServers.map(s => ({ ...s, category: 'raw' })) - ]; - - return { - servers: allServers, - episodeId: data.data.episodeId, - episodeNo: data.data.episodeNo, - hasSubServers: subServers.length > 0, - hasDubServers: dubServers.length > 0, - hasRawServers: rawServers.length > 0 - }; - } catch (error) { - console.error('Error fetching episode servers:', error); - return { servers: [] }; - } -}; - -export const fetchEpisodeSources = async (episodeId, dub = false, server = 'hd-2') => { - try { - if (!episodeId || episodeId === 'undefined') { - console.error('Invalid episode ID provided'); - return { sources: [] }; - } - - console.log(`[API] Processing episode ID for sources: ${episodeId}`); - - // Use the episode.id directly - it's in the format "animeId?ep=episodeNumber" - const category = dub ? 'dub' : 'sub'; - const serverName = server || 'hd-2'; // Default to hd-2 if server is null or empty - const apiUrl = `${API_BASE_URL}/episode/sources?animeEpisodeId=${encodeURIComponent(episodeId)}&category=${category}&server=${serverName}`; - console.log(`[API Call] Fetching sources from: ${apiUrl}`); - - const response = await fetch(apiUrl, { - headers: API_HEADERS, - credentials: 'omit' - }); - - // Log raw response details for debugging - console.log('[API Response] Status:', response.status, response.statusText); - console.log('[API Response] Headers:', [...response.headers.entries()]); - - if (!response.ok) { - throw new Error(`Failed to fetch episode sources: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - console.log('[API Response] Raw data:', JSON.stringify(data, null, 2)); - - if (!data) { - console.error('[API Error] No data received - response was null or undefined'); - return { sources: [] }; - } - - // Check if response is valid (status 200 or success flag) - // Some API responses use success flag, others use status code - const isValidResponse = (data.success === true) || (data.status === 200); - if (!isValidResponse) { - console.error('[API Error] Response indicates failure - invalid status or success flag'); - return { sources: [] }; - } - - // Get the data object from either data.data (new format) or data (old format) - const responseData = data.data || data; - - if (!responseData) { - console.error('[API Error] Empty data object in response'); - return { sources: [] }; - } - - if (!responseData.sources || responseData.sources.length === 0) { - console.error('[API Error] No sources found in response data'); - return { sources: [] }; - } - - console.log('[API Success] Found sources:', responseData.sources.map(s => ({ - url: s.url.substring(0, 50) + '...', - quality: s.quality, - isM3U8: s.isM3U8 - }))); - - return { - sources: responseData.sources || [], - headers: responseData.headers || { "Referer": "https://hianime.to/" }, - subtitles: responseData.tracks || responseData.subtitles || [], - anilistID: responseData.anilistID || null, - malID: responseData.malID || null, - intro: responseData.intro || null, - outro: responseData.outro || null - }; - } catch (error) { - console.error('Error fetching episode sources:', error); - return { sources: [] }; - } -}; - -export const searchAnime = async (query, page = 1, filters = {}) => { - try { - // Build the URL with query and page parameters - let url = `${API_BASE_URL}/search?q=${encodeURIComponent(query)}&page=${page}`; - - // Add any additional filters to the URL - if (filters && Object.keys(filters).length > 0) { - Object.entries(filters).forEach(([key, value]) => { - if (value) { - url += `&${encodeURIComponent(key)}=${encodeURIComponent(value)}`; - } - }); - } - - console.log("[API] Searching anime at:", url); - - // Make the request - const response = await fetch(url, { - headers: API_HEADERS, - next: { revalidate: 60 }, // Cache for 60 seconds - cache: 'no-cache' // Don't use browser cache - }); - - if (!response.ok) { - console.error(`[API] Search error: ${response.status} ${response.statusText}`); - throw new Error(`Failed to search anime: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - console.log("[API] Search response:", data); - - // Check if the response is valid and matches expected format - if (!data.data) { - console.error('[API] Invalid response format from search API:', data); - return { - results: [], - mostPopularResults: [], - currentPage: page, - hasNextPage: false, - searchQuery: query, - searchFilters: filters - }; - } - - return { - results: data.data.animes || [], - mostPopularResults: data.data.mostPopularAnimes || [], - currentPage: data.data.currentPage || page, - hasNextPage: data.data.hasNextPage || false, - totalPages: data.data.totalPages || 1, - searchQuery: data.data.searchQuery || query, - searchFilters: data.data.searchFilters || filters - }; - } catch (error) { - console.error('[API] Error searching anime:', error); - return { - results: [], - mostPopularResults: [], - currentPage: page, - hasNextPage: false, - searchQuery: query, - searchFilters: filters - }; - } -}; - -export const fetchGenres = async () => { - try { - const response = await fetch(`${API_BASE_URL}/home`); - if (!response.ok) throw new Error('Failed to fetch genres'); - const data = await response.json(); - return data.data.genres || []; - } catch (error) { - console.error('Error fetching genres:', error); - return []; - } -}; - -export const fetchGenreAnime = async (genre, page = 1) => { - try { - const response = await fetch(`${API_BASE_URL}/genre/${encodeURIComponent(genre)}?page=${page}`); - if (!response.ok) throw new Error('Failed to fetch genre anime'); - const data = await response.json(); - return { - results: data.data.animes || [], - currentPage: data.data.currentPage, - hasNextPage: data.data.hasNextPage - }; - } catch (error) { - console.error('Error fetching genre anime:', error); - return { results: [] }; - } -}; - -export const fetchSearchSuggestions = async (query) => { - try { - console.log("[API] Fetching search suggestions for:", query); - const response = await fetch(`${API_BASE_URL}/search/suggestion?q=${encodeURIComponent(query)}`, { - headers: API_HEADERS, - next: { revalidate: 60 }, // Cache for 60 seconds - cache: 'no-cache' // Don't use browser cache - }); - - if (!response.ok) { - console.error(`[API] Search suggestions error: ${response.status} ${response.statusText}`); - throw new Error(`Failed to fetch search suggestions: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - console.log("[API] Search suggestions response:", data); - - if (!data.data) { - console.error('[API] Invalid response format from search suggestions API:', data); - return []; - } - - // Map the suggestions to include required fields - return (data.data.suggestions || []).map(suggestion => ({ - id: suggestion.id, - title: suggestion.name || suggestion.title, - image: suggestion.poster || suggestion.image, - // Include additional fields that might be useful for display - type: suggestion.type || 'ANIME', - jname: suggestion.jname || '' - })); - } catch (error) { - console.error('[API] Error fetching search suggestions:', error); - return []; - } -}; - -export const fetchSchedule = async () => { - try { - const today = new Date().toISOString().split('T')[0]; - const response = await fetch(`${API_BASE_URL}/schedule?date=${today}`); - if (!response.ok) throw new Error('Failed to fetch schedule'); - const data = await response.json(); - - // Map the scheduled animes to include all required fields - const scheduledAnimes = data.data.scheduledAnimes || []; - return { - scheduledAnimes: scheduledAnimes.map(anime => ({ - id: anime.id, - time: anime.time, - name: anime.name, - jname: anime.jname, - airingTimestamp: anime.airingTimestamp, - secondsUntilAiring: anime.secondsUntilAiring - })) - }; - } catch (error) { - console.error('Error fetching schedule:', error); - return { scheduledAnimes: [] }; - } -}; - -export const fetchSpotlightAnime = async (limit = 8) => { - try { - const response = await fetch(`${API_BASE_URL}/home`); - if (!response.ok) throw new Error('Failed to fetch spotlight anime'); - const data = await response.json(); - - // Map the spotlight animes to match the exact schema from the API - const spotlightAnimes = (data.data.spotlightAnimes || []).map(anime => ({ - id: anime.id, - name: anime.name, - jname: anime.jname, - poster: anime.poster, - banner: anime.banner, - description: anime.description, - rank: anime.rank, - otherInfo: anime.otherInfo || [], - episodes: { - sub: anime.episodes?.sub || 0, - dub: anime.episodes?.dub || 0 - } - })); - - return spotlightAnimes.slice(0, limit); - } catch (error) { - console.error('Error fetching spotlight anime:', error); - return []; - } -}; - -// Top 10 sections with proper data mapping -export const fetchTopToday = async () => { - try { - const response = await fetch(`${API_BASE_URL}/home`); - if (!response.ok) throw new Error('Failed to fetch top today'); - const data = await response.json(); - - // Map the top 10 animes to include all required fields - return (data.data.top10Animes?.today || []).map(anime => ({ - id: anime.id, - name: anime.name, - poster: anime.poster, - rank: anime.rank, - episodes: anime.episodes || { sub: 0, dub: 0 } - })); - } catch (error) { - console.error('Error fetching top today:', error); - return []; - } -}; - -export const fetchTopWeek = async () => { - try { - const response = await fetch(`${API_BASE_URL}/home`); - if (!response.ok) throw new Error('Failed to fetch top week'); - const data = await response.json(); - - // Map the top 10 animes to include all required fields - return (data.data.top10Animes?.week || []).map(anime => ({ - id: anime.id, - name: anime.name, - poster: anime.poster, - rank: anime.rank, - episodes: anime.episodes || { sub: 0, dub: 0 } - })); - } catch (error) { - console.error('Error fetching top week:', error); - return []; - } -}; - -export const fetchTopMonth = async () => { - try { - const response = await fetch(`${API_BASE_URL}/home`); - if (!response.ok) throw new Error('Failed to fetch top month'); - const data = await response.json(); - - // Map the top 10 animes to include all required fields - return (data.data.top10Animes?.month || []).map(anime => ({ - id: anime.id, - name: anime.name, - poster: anime.poster, - rank: anime.rank, - episodes: anime.episodes || { sub: 0, dub: 0 } - })); - } catch (error) { - console.error('Error fetching top month:', error); - return []; - } -}; \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..e5bd98c --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,13 @@ +import { LanguageProvider } from './context/LanguageContext'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App.jsx'; +import './index.css'; + +createRoot(document.getElementById('root')).render( ++ +); diff --git a/src/pages/Home/Home.jsx b/src/pages/Home/Home.jsx new file mode 100644 index 0000000..789fb6a --- /dev/null +++ b/src/pages/Home/Home.jsx @@ -0,0 +1,82 @@ +import website_name from "@/src/config/website.js"; +import Spotlight from "@/src/components/spotlight/Spotlight.jsx"; +import Trending from "@/src/components/trending/Trending.jsx"; +import Cart from "@/src/components/cart/Cart.jsx"; +import CategoryCard from "@/src/components/categorycard/CategoryCard.jsx"; +import Genre from "@/src/components/genres/Genre.jsx"; +import Topten from "@/src/components/topten/Topten.jsx"; +import Loader from "@/src/components/Loader/Loader.jsx"; +import Error from "@/src/components/error/Error.jsx"; +import { useHomeInfo } from "@/src/context/HomeInfoContext.jsx"; +import Schedule from "@/src/components/schedule/Schedule"; +import ContinueWatching from "@/src/components/continue/ContinueWatching"; + +function Home() { + const { homeInfo, homeInfoLoading, error } = useHomeInfo(); + if (homeInfoLoading) return+ ++ ; + if (error) return ; + if (!homeInfo) return ; + return ( + <> + ++ > + ); +} + +export default Home; diff --git a/src/pages/a2z/AtoZ.jsx b/src/pages/a2z/AtoZ.jsx new file mode 100644 index 0000000..f2717e6 --- /dev/null +++ b/src/pages/a2z/AtoZ.jsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react"; +import { useSearchParams, Link } from "react-router-dom"; +import getCategoryInfo from "@/src/utils/getCategoryInfo.utils"; +import CategoryCard from "@/src/components/categorycard/CategoryCard"; +import Loader from "@/src/components/Loader/Loader"; +import Error from "@/src/components/error/Error"; +import PageSlider from "@/src/components/pageslider/PageSlider"; + +function AtoZ({ path }) { + const [searchParams, setSearchParams] = useSearchParams(); + const [categoryInfo, setCategoryInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [totalPages, setTotalPages] = useState(0); + const page = parseInt(searchParams.get("page")) || 1; + const currentLetter = path.split("/").pop() || ""; + + useEffect(() => { + const fetchAtoZInfo = async () => { + setLoading(true); + try { + const data = await getCategoryInfo(path, page); + setCategoryInfo(data.data); + setTotalPages(data.totalPages); + setLoading(false); + } catch (err) { + setError(err); + setLoading(false); + console.error("Error fetching category info:", err); + } + }; + fetchAtoZInfo(); + window.scrollTo(0, 0); + }, [path, page]); + + if (loading) return+ + + +++ + + + +++++ + + + +++ + ; + if (error) { + return ; + } + if (!categoryInfo) { + return null; + } + const handlePageChange = (newPage) => { + setSearchParams({ page: newPage }); + }; + + return ( + ++ ); +} + +export default AtoZ; diff --git a/src/pages/animeInfo/AnimeInfo.jsx b/src/pages/animeInfo/AnimeInfo.jsx new file mode 100644 index 0000000..60b1e4f --- /dev/null +++ b/src/pages/animeInfo/AnimeInfo.jsx @@ -0,0 +1,416 @@ +import getAnimeInfo from "@/src/utils/getAnimeInfo.utils"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faPlay, + faClosedCaptioning, + faMicrophone, +} from "@fortawesome/free-solid-svg-icons"; +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import website_name from "@/src/config/website"; +import CategoryCard from "@/src/components/categorycard/CategoryCard"; +import Sidecard from "@/src/components/sidecard/Sidecard"; +import Loader from "@/src/components/Loader/Loader"; +import Error from "@/src/components/error/Error"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { useHomeInfo } from "@/src/context/HomeInfoContext"; +import Voiceactor from "@/src/components/voiceactor/Voiceactor"; + +function InfoItem({ label, value, isProducer = true }) { + return ( + value && ( ++
+- + + Home + + +
+- A-Z List
++++ Sort By Letters +
++ {[ + "All", + "#", + "0-9", + ...Array.from({ length: 26 }, (_, i) => + String.fromCharCode(65 + i) + ), + ].map((item, index) => { + const linkPath = + item.toLowerCase() === "all" + ? "" + : item === "#" + ? "other" + : item; + const isActive = + (currentLetter === "az-list" && item.toLowerCase() === "all") || + (currentLetter === "other" && item === "#") || + currentLetter === item.toLowerCase(); + + return ( + + {item} + + ); + })} +++++ {categoryInfo && categoryInfo.length > 0 && ( +++ )} + + + {`${label}: `} + + {Array.isArray(value) ? ( + value.map((item, index) => + isProducer ? ( + :;,.?/\\|{}[\]`~*_]/g, "") + .split(" ") + .join("-") + .replace(/-+/g, "-")}`} + key={index} + className="cursor-pointer hover:text-[#ffbade]" + > + {item} + {index < value.length - 1 && ", "} + + ) : ( + + {item} + + ) + ) + ) : isProducer ? ( + :;,.?/\\|{}[\]`~*_]/g, "") + .split(" ") + .join("-") + .replace(/-+/g, "-")}`} + className="cursor-pointer hover:text-[#ffbade]" + > + {value} + + ) : ( + {value} + )} + ++ ) + ); +} + +function Tag({ bgColor, index, icon, text }) { + return ( ++ {icon &&+ ); +} + +function AnimeInfo({ random = false }) { + const { language } = useLanguage(); + const { id: paramId } = useParams(); + const id = random ? null : paramId; + const [isFull, setIsFull] = useState(false); + const [animeInfo, setAnimeInfo] = useState(null); + const [seasons, setSeasons] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { homeInfo } = useHomeInfo(); + const { id: currentId } = useParams(); + const navigate = useNavigate(); + useEffect(() => { + if (id === "404-not-found-page") { + console.log("404 got!"); + return null; + } else { + const fetchAnimeInfo = async () => { + setLoading(true); + try { + const data = await getAnimeInfo(id, random); + setSeasons(data?.seasons); + setAnimeInfo(data.data); + } catch (err) { + console.error("Error fetching anime info:", err); + setError(err); + } finally { + setLoading(false); + } + }; + fetchAnimeInfo(); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }, [id, random]); + useEffect(() => { + if (animeInfo && location.pathname === `/${animeInfo.id}`) { + document.title = `Watch ${animeInfo.title} English Sub/Dub online Free on ${website_name}`; + } + return () => { + document.title = `${website_name} | Free anime streaming platform`; + }; + }, [animeInfo]); + if (loading) return} + {text}
+; + if (error) { + return ; + } + if (!animeInfo) { + navigate("/404-not-found-page"); + return undefined; + } + const { title, japanese_title, poster, animeInfo: info } = animeInfo; + const tags = [ + { + condition: info.tvInfo?.rating, + bgColor: "#ffffff", + text: info.tvInfo.rating, + }, + { + condition: info.tvInfo?.quality, + bgColor: "#FFBADE", + text: info.tvInfo.quality, + }, + { + condition: info.tvInfo?.sub, + icon: faClosedCaptioning, + bgColor: "#B0E3AF", + text: info.tvInfo.sub, + }, + { + condition: info.tvInfo?.dub, + icon: faMicrophone, + bgColor: "#B9E7FF", + text: info.tvInfo.dub, + }, + ]; + + return ( + <> + +++
+++++ {animeInfo.adultContent && ( +
+ 18+ ++ )} ++++ {[ + ["Home", "home"], + [info.tvInfo?.showType, info.tvInfo?.showType], + ].map(([text, link], index) => ( +
+- + + {text} + + +
+ ))} ++ {language === "EN" ? title : japanese_title} +
++ {language === "EN" ? title : japanese_title} +
++ {tags.map( + ({ condition, icon, bgColor, text }, index) => + condition && ( ++ {animeInfo?.animeInfo?.Status?.toLowerCase() !== "not-yet-aired" ? ( + ++ ) + )} + + {[info.tvInfo?.showType, info.tvInfo?.duration].map( + (item, index) => + item && ( +++ ++ ) + )} +{item}
++ Watch Now
+ + ) : ( +++ )} + {info?.Overview && ( +Not released
++ {info.Overview.length > 270 ? ( + <> + {isFull + ? info.Overview + : `${info.Overview.slice(0, 270)}...`} + setIsFull(!isFull)} + > + {isFull ? "- Less" : "+ More"} + + > + ) : ( + info.Overview + )} ++ )} ++ {`${website_name} is the best site to watch `} + {title} + {` SUB online, or you can even watch `} + {title} + {` DUB in HD quality.`} +
++++
+++ Share Anime +
+to your friends
++++ {info?.Overview && ( ++++ )} + {[ + { label: "Japanese", value: info?.Japanese }, + { label: "Synonyms", value: info?.Synonyms }, + { label: "Aired", value: info?.Aired }, + { label: "Premiered", value: info?.Premiered }, + { label: "Duration", value: info?.Duration }, + { label: "Status", value: info?.Status }, + { label: "MAL Score", value: info?.["MAL Score"] }, + ].map(({ label, value }, index) => ( +Overview:
+++{info.Overview}
++ ))} + {info?.Genres && ( + ++ )} + {[ + { label: "Studios", value: info?.Studios }, + { label: "Producers", value: info?.Producers }, + ].map(({ label, value }, index) => ( +Genres:
++ {info.Genres.map((genre, index) => ( + + {genre} + + ))} +++ ))} + + {`${website_name} is the best site to watch `} + {title} + {` SUB online, or you can even watch `} + {title} + {` DUB in HD quality.`} +
+++ > + ); +} + +export default AnimeInfo; diff --git a/src/pages/category/Category.jsx b/src/pages/category/Category.jsx new file mode 100644 index 0000000..326c61e --- /dev/null +++ b/src/pages/category/Category.jsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import getCategoryInfo from "@/src/utils/getCategoryInfo.utils"; +import CategoryCard from "@/src/components/categorycard/CategoryCard"; +import Genre from "@/src/components/genres/Genre"; +import Topten from "@/src/components/topten/Topten"; +import Loader from "@/src/components/Loader/Loader"; +import Error from "@/src/components/error/Error"; +import { useNavigate } from "react-router-dom"; +import { useHomeInfo } from "@/src/context/HomeInfoContext"; +import PageSlider from "@/src/components/pageslider/PageSlider"; +import SidecardLoader from "@/src/components/Loader/Sidecard.loader"; + +function Category({ path, label }) { + const [searchParams, setSearchParams] = useSearchParams(); + const [categoryInfo, setCategoryInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [totalPages, setTotalPages] = useState(0); + const page = parseInt(searchParams.get("page")) || 1; + const { homeInfo, homeInfoLoading } = useHomeInfo(); + const navigate = useNavigate(); + useEffect(() => { + const fetchCategoryInfo = async () => { + setLoading(true); + try { + const data = await getCategoryInfo(path, page); + setCategoryInfo(data.data); + setTotalPages(data.totalPages); + setLoading(false); + } catch (err) { + setError(err); + console.error("Error fetching category info:", err); + } + }; + fetchCategoryInfo(); + window.scrollTo(0, 0); + }, [path, page]); + if (loading) return+ {seasons?.length > 0 && ( ++++ )} + {animeInfo?.charactersVoiceActors.length > 0 && ( ++ More Seasons +
++ {seasons.map((season, index) => ( + +++ {season.season} +
+ ++ + ))} +
+ )} + {animeInfo.recommended_data.length > 0 && ( + + )} + + {animeInfo.related_data.length > 0 && ( +++ )} + {homeInfo && homeInfo.most_popular && ( + + )} + ; + if (error) { + navigate("/error-page"); + return ; + } + if (!categoryInfo) { + navigate("/404-not-found-page"); + return null; + } + const handlePageChange = (newPage) => { + setSearchParams({ page: newPage }); + }; + + return ( + ++ ); +} + +export default Category; diff --git a/src/pages/search/Search.jsx b/src/pages/search/Search.jsx new file mode 100644 index 0000000..f52033c --- /dev/null +++ b/src/pages/search/Search.jsx @@ -0,0 +1,74 @@ +import CategoryCard from '@/src/components/categorycard/CategoryCard'; +import Genre from '@/src/components/genres/Genre'; +import CategoryCardLoader from '@/src/components/Loader/CategoryCard.loader'; +import SidecardLoader from '@/src/components/Loader/Sidecard.loader'; +import PageSlider from '@/src/components/pageslider/PageSlider'; +import Sidecard from '@/src/components/sidecard/Sidecard'; +import { useHomeInfo } from '@/src/context/HomeInfoContext'; +import getSearch from '@/src/utils/getSearch.utils'; +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +function Search() { + const { homeInfo, homeInfoLoading } = useHomeInfo(); + const [searchParams, setSearchParams] = useSearchParams(); + const keyword = searchParams.get("keyword"); + const page = parseInt(searchParams.get("page"), 10) || 1; + const [searchData, setSearchData] = useState(null); + const [totalPages, setTotalPages] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchSearch = async () => { + setLoading(true); + try { + const data = await getSearch(keyword,page); + setSearchData(data.data); + setTotalPages(data.totalPage); + setLoading(false); + } catch (err) { + console.error("Error fetching anime info:", err); + setError(err); + setLoading(false); + } + }; + fetchSearch(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, [keyword, page]); + + const handlePageChange = (newPage) => { + setSearchParams({ keyword, page: newPage }); + }; + return ( +++ {categoryInfo ? ( ++
++Share Anime
+to your friends
++ {page > totalPages ? ( ++ ) : ( ++ You came a long way, go back
+ ) : ( +
+ nothing is here ++ {categoryInfo && categoryInfo.length > 0 && ( ++ )} ++ )} + + + {homeInfoLoading ? ( +++ ) : ( + <> + {homeInfo && homeInfo.topten && ( + + )} + {homeInfo?.genres && } + > + )} + + )} + + {loading ? ( ++ ); +} + +export default Search; diff --git a/src/pages/watch/Watch.jsx b/src/pages/watch/Watch.jsx new file mode 100644 index 0000000..e9ee8c0 --- /dev/null +++ b/src/pages/watch/Watch.jsx @@ -0,0 +1,541 @@ +/* eslint-disable react/prop-types */ +import { useEffect, useRef, useState } from "react"; +import { useLocation, useParams, Link, useNavigate } from "react-router-dom"; +import { useLanguage } from "@/src/context/LanguageContext"; +import { useHomeInfo } from "@/src/context/HomeInfoContext"; +import { useWatch } from "@/src/hooks/useWatch"; +import BouncingLoader from "@/src/components/ui/bouncingloader/Bouncingloader"; +import IframePlayer from "@/src/components/player/IframePlayer"; +import Episodelist from "@/src/components/episodelist/Episodelist"; +import website_name from "@/src/config/website"; +import Sidecard from "@/src/components/sidecard/Sidecard"; +import CategoryCard from "@/src/components/categorycard/CategoryCard"; +import { + faClosedCaptioning, + faMicrophone, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Servers from "@/src/components/servers/Servers"; +import CategoryCardLoader from "@/src/components/Loader/CategoryCard.loader"; +import { Skeleton } from "@/src/components/ui/Skeleton/Skeleton"; +import SidecardLoader from "@/src/components/Loader/Sidecard.loader"; +import Voiceactor from "@/src/components/voiceactor/Voiceactor"; +import Watchcontrols from "@/src/components/watchcontrols/Watchcontrols"; +import useWatchControl from "@/src/hooks/useWatchControl"; +import Player from "@/src/components/player/Player"; + +export default function Watch() { + const location = useLocation(); + const navigate = useNavigate(); + const { id: animeId } = useParams(); + const queryParams = new URLSearchParams(location.search); + let initialEpisodeId = queryParams.get("ep"); + const [tags, setTags] = useState([]); + const { language } = useLanguage(); + const { homeInfo } = useHomeInfo(); + const isFirstSet = useRef(true); + const [showNextEpisodeSchedule, setShowNextEpisodeSchedule] = useState(true); + const { + // error, + buffering, + streamInfo, + streamUrl, + animeInfo, + episodes, + nextEpisodeSchedule, + animeInfoLoading, + totalEpisodes, + isFullOverview, + intro, + outro, + subtitles, + thumbnail, + setIsFullOverview, + activeEpisodeNum, + seasons, + episodeId, + setEpisodeId, + activeServerId, + setActiveServerId, + servers, + serverLoading, + activeServerType, + setActiveServerType, + activeServerName, + setActiveServerName + } = useWatch(animeId, initialEpisodeId); + const { + autoPlay, + setAutoPlay, + autoSkipIntro, + setAutoSkipIntro, + autoNext, + setAutoNext, + } = useWatchControl(); + + useEffect(() => { + if (!episodes || episodes.length === 0) return; + + const isValidEpisode = episodes.some(ep => { + const epNumber = ep.id.split('ep=')[1]; + return epNumber === episodeId; + }); + + // If missing or invalid episodeId, fallback to first + if (!episodeId || !isValidEpisode) { + const fallbackId = episodes[0].id.match(/ep=(\d+)/)?.[1]; + if (fallbackId && fallbackId !== episodeId) { + setEpisodeId(fallbackId); + } + return; + } + + const newUrl = `/watch/${animeId}?ep=${episodeId}`; + if (isFirstSet.current) { + navigate(newUrl, { replace: true }); + isFirstSet.current = false; + } else { + navigate(newUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [episodeId, animeId, navigate, episodes]); + + // Update document title + useEffect(() => { + if (animeInfo) { + document.title = `Watch ${animeInfo.title} English Sub/Dub online Free on ${website_name}`; + } + return () => { + document.title = `${website_name} | Free anime streaming platform`; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animeId]); + + // Redirect if no episodes + useEffect(() => { + if (totalEpisodes !== null && totalEpisodes === 0) { + navigate(`/${animeId}`); + } + }, [streamInfo, episodeId, animeId, totalEpisodes, navigate]); + + useEffect(() => { + const adjustHeight = () => { + if (window.innerWidth > 1200) { + const player = document.querySelector(".player"); + const episodes = document.querySelector(".episodes"); + if (player && episodes) { + episodes.style.height = `${player.clientHeight}px`; + } + } else { + const episodes = document.querySelector(".episodes"); + if (episodes) { + episodes.style.height = "auto"; + } + } + }; + adjustHeight(); + window.addEventListener("resize", adjustHeight); + return () => { + window.removeEventListener("resize", adjustHeight); + }; + }); + + function Tag({ bgColor, index, icon, text }) { + return ( ++ ) : page > totalPages ? You came a long way, go back
: searchData && searchData.length > 0 ? ( +
nothing is here++ ) : error ?+ + Couldn't get search result please try again
: ( +{`Search results for: ${keyword}`}
+ )} ++ {homeInfoLoading ? ( +++ ) : ( + <> + {homeInfo?.most_popular && } + {homeInfo?.genres && } + > + )} + + {icon &&+ ); + } + + useEffect(() => { + setTags([ + { + condition: animeInfo?.animeInfo?.tvInfo?.rating, + bgColor: "#ffffff", + text: animeInfo?.animeInfo?.tvInfo?.rating, + }, + { + condition: animeInfo?.animeInfo?.tvInfo?.quality, + bgColor: "#FFBADE", + text: animeInfo?.animeInfo?.tvInfo?.quality, + }, + { + condition: animeInfo?.animeInfo?.tvInfo?.sub, + icon: faClosedCaptioning, + bgColor: "#B0E3AF", + text: animeInfo?.animeInfo?.tvInfo?.sub, + }, + { + condition: animeInfo?.animeInfo?.tvInfo?.dub, + icon: faMicrophone, + bgColor: "#B9E7FF", + text: animeInfo?.animeInfo?.tvInfo?.dub, + }, + ]); + }, [animeId, animeInfo]); + return ( +} + {text}
+++ ); +} diff --git a/src/utils/category.utils.js b/src/utils/category.utils.js new file mode 100644 index 0000000..dfc5d69 --- /dev/null +++ b/src/utils/category.utils.js @@ -0,0 +1,89 @@ +export const categoryRoutes = [ + "genre/action", + "genre/adventure", + "genre/cars", + "genre/comedy", + "genre/dementia", + "genre/demons", + "genre/drama", + "genre/ecchi", + "genre/fantasy", + "genre/game", + "genre/harem", + "genre/historical", + "genre/horror", + "genre/isekai", + "genre/josei", + "genre/kids", + "genre/magic", + "genre/martial-arts", + "genre/mecha", + "genre/military", + "genre/music", + "genre/mystery", + "genre/parody", + "genre/police", + "genre/psychological", + "genre/romance", + "genre/samurai", + "genre/school", + "genre/sci-fi", + "genre/seinen", + "genre/shoujo", + "genre/shoujo-ai", + "genre/shounen", + "genre/shounen-ai", + "genre/slice-of-life", + "genre/space", + "genre/sports", + "genre/super-power", + "genre/supernatural", + "genre/thriller", + "genre/vampire", + "top-airing", + "most-popular", + "most-favorite", + "completed", + "recently-updated", + "recently-added", + "top-upcoming", + "subbed-anime", + "dubbed-anime", + "movie", + "special", + "ova", + "ona", + "tv", +]; + +export const azRoute = [ + "az-list", + "az-list/other", + "az-list/0-9", + "az-list/a", + "az-list/b", + "az-list/c", + "az-list/d", + "az-list/e", + "az-list/f", + "az-list/g", + "az-list/h", + "az-list/i", + "az-list/j", + "az-list/k", + "az-list/l", + "az-list/m", + "az-list/n", + "az-list/o", + "az-list/p", + "az-list/q", + "az-list/r", + "az-list/s", + "az-list/t", + "az-list/u", + "az-list/v", + "az-list/w", + "az-list/x", + "az-list/y", + "az-list/z", +]; diff --git a/src/utils/getAnimeInfo.utils.js b/src/utils/getAnimeInfo.utils.js new file mode 100644 index 0000000..8736365 --- /dev/null +++ b/src/utils/getAnimeInfo.utils.js @@ -0,0 +1,18 @@ +import axios from "axios"; + +export default async function fetchAnimeInfo(id, random = false) { + const api_url = import.meta.env.VITE_API_URL; + try { + if (random) { + const id = await axios.get(`${api_url}/random/id`); + const response = await axios.get(`${api_url}/info?id=${id.data.results}`); + return response.data.results; + } else { + const response = await axios.get(`${api_url}/info?id=${id}`); + return response.data.results; + } + } catch (error) { + console.error("Error fetching anime info:", error); + return error; + } +} diff --git a/src/utils/getCategoryInfo.utils.js b/src/utils/getCategoryInfo.utils.js new file mode 100644 index 0000000..409631e --- /dev/null +++ b/src/utils/getCategoryInfo.utils.js @@ -0,0 +1,14 @@ +import axios from "axios"; + +const getCategoryInfo = async (path,page) => { + const api_url = import.meta.env.VITE_API_URL; + try { + const response = await axios.get(`${api_url}/${path}?page=${page}`); + return response.data.results; + } catch (err) { + console.error("Error fetching genre info:", err); + return err; + } +}; + +export default getCategoryInfo; diff --git a/src/utils/getEpisodes.utils.js b/src/utils/getEpisodes.utils.js new file mode 100644 index 0000000..d98c5f6 --- /dev/null +++ b/src/utils/getEpisodes.utils.js @@ -0,0 +1,12 @@ +import axios from "axios"; + +export default async function getEpisodes(id) { + const api_url = import.meta.env.VITE_API_URL; + try { + const response = await axios.get(`${api_url}/episodes/${id}`); + return response.data.results; + } catch (error) { + console.error("Error fetching anime info:", error); + return error; + } +} diff --git a/src/utils/getHomeInfo.utils.js b/src/utils/getHomeInfo.utils.js new file mode 100644 index 0000000..655182b --- /dev/null +++ b/src/utils/getHomeInfo.utils.js @@ -0,0 +1,58 @@ +import axios from "axios"; + +const CACHE_KEY = "homeInfoCache"; +const CACHE_DURATION = 24 * 60 * 60 * 1000; + +export default async function getHomeInfo() { + const api_url = import.meta.env.VITE_API_URL; + + const currentTime = Date.now(); + const cachedData = JSON.parse(localStorage.getItem(CACHE_KEY)); + + if (cachedData && currentTime - cachedData.timestamp < CACHE_DURATION) { + return cachedData.data; + } + const response = await axios.get(`${api_url}`); + if ( + !response.data.results || + Object.keys(response.data.results).length === 0 + ) { + return null; + } + const { + spotlights, + trending, + topTen: topten, + today: todaySchedule, + topAiring: top_airing, + mostPopular: most_popular, + mostFavorite: most_favorite, + latestCompleted: latest_completed, + latestEpisode: latest_episode, + topUpcoming: top_upcoming, + recentlyAdded: recently_added, + genres, + } = response.data.results; + + const dataToCache = { + data: { + spotlights, + trending, + topten, + todaySchedule, + top_airing, + most_popular, + most_favorite, + latest_completed, + latest_episode, + top_upcoming, + recently_added, + genres, + }, + timestamp: currentTime, + }; + + localStorage.setItem(CACHE_KEY, JSON.stringify(dataToCache)); + + return dataToCache.data; +} diff --git a/src/utils/getNextEpisodeSchedule.utils.js b/src/utils/getNextEpisodeSchedule.utils.js new file mode 100644 index 0000000..e01cb5b --- /dev/null +++ b/src/utils/getNextEpisodeSchedule.utils.js @@ -0,0 +1,14 @@ +import axios from "axios"; + +const getNextEpisodeSchedule = async (id) => { + const api_url = import.meta.env.VITE_API_URL; + try { + const response = await axios.get(`${api_url}/schedule/${id}`); + return response.data.results; + } catch (err) { + console.error("Error fetching next episode schedule:", err); + return err; + } +}; + +export default getNextEpisodeSchedule; diff --git a/src/utils/getProducer.utils.js b/src/utils/getProducer.utils.js new file mode 100644 index 0000000..8f4e5bc --- /dev/null +++ b/src/utils/getProducer.utils.js @@ -0,0 +1,14 @@ +import axios from "axios"; + +const getProducer = async (producer, page) => { + const api_url = import.meta.env.VITE_API_URL; + try { + const response = await axios.get(`${api_url}/producer/${producer}?page=${page}`); + return response.data.results; + } catch (err) { + console.error("Error fetching genre info:", err); + return err; + } +}; + +export default getProducer; diff --git a/src/utils/getQtip.utils.js b/src/utils/getQtip.utils.js new file mode 100644 index 0000000..19c8026 --- /dev/null +++ b/src/utils/getQtip.utils.js @@ -0,0 +1,18 @@ +import axios from "axios"; + +const getQtip = async (id) => { + try { + let workerUrls = import.meta.env.VITE_WORKER_URL?.split(","); + let baseUrl = workerUrls?.length + ? workerUrls[Math.floor(Math.random() * workerUrls.length)] + : import.meta.env.VITE_API_URL; + if (!baseUrl) throw new Error("No API endpoint defined."); + const response = await axios.get(`${baseUrl}/qtip/${id.split("-").pop()}`); + return response.data.results; + } catch (err) { + console.error("Error fetching genre info:", err); + return null; + } +}; + +export default getQtip; diff --git a/src/utils/getScheduleInfo.utils.js b/src/utils/getScheduleInfo.utils.js new file mode 100644 index 0000000..1095c23 --- /dev/null +++ b/src/utils/getScheduleInfo.utils.js @@ -0,0 +1,12 @@ +import axios from "axios"; + +export default async function getSchedInfo(date) { + try { + const api_url = import.meta.env.VITE_API_URL; + const response = await axios.get(`${api_url}/schedule?date=${date}`); + return response.data.results; + } catch (error) { + console.error(error); + return error; + } +} diff --git a/src/utils/getSearch.utils.js b/src/utils/getSearch.utils.js new file mode 100644 index 0000000..48aafb9 --- /dev/null +++ b/src/utils/getSearch.utils.js @@ -0,0 +1,17 @@ +import axios from "axios"; + +const getSearch = async (keyword, page) => { + const api_url = import.meta.env.VITE_API_URL; + if (!page) page = 1; + try { + const response = await axios.get( + `${api_url}/search?keyword=${keyword}&page=${page}` + ); + return response.data.results; + } catch (err) { + console.error("Error fetching genre info:", err); + return err; + } +}; + +export default getSearch; diff --git a/src/utils/getSearchSuggestion.utils.js b/src/utils/getSearchSuggestion.utils.js new file mode 100644 index 0000000..0d1173d --- /dev/null +++ b/src/utils/getSearchSuggestion.utils.js @@ -0,0 +1,16 @@ +import axios from "axios"; + +const getSearchSuggestion = async (keyword) => { + const api_url = import.meta.env.VITE_API_URL; + try { + const response = await axios.get( + `${api_url}/search/suggest?keyword=${keyword}` + ); + return response.data.results; + } catch (err) { + console.error("Error fetching genre info:", err); + return err; + } +}; + +export default getSearchSuggestion; diff --git a/src/utils/getServers.utils.js b/src/utils/getServers.utils.js new file mode 100644 index 0000000..0ed3e14 --- /dev/null +++ b/src/utils/getServers.utils.js @@ -0,0 +1,14 @@ +import axios from "axios"; + +export default async function getServers(animeId, episodeId) { + try { + const api_url = import.meta.env.VITE_API_URL; + const response = await axios.get( + `${api_url}/servers/${animeId}?ep=${episodeId}` + ); + return response.data.results; + } catch (error) { + console.error(error); + return error; + } +} diff --git a/src/utils/getStreamInfo.utils.js b/src/utils/getStreamInfo.utils.js new file mode 100644 index 0000000..d044e5c --- /dev/null +++ b/src/utils/getStreamInfo.utils.js @@ -0,0 +1,12 @@ +import axios from "axios"; + +export default async function getStreamInfo(animeId,episodeId,serverName,type) { + const api_url = import.meta.env.VITE_API_URL; + try { + const response = await axios.get(`${api_url}/stream?id=${animeId}?ep=${episodeId}&server=${serverName}&type=${type}`); + return response.data.results; + } catch (error) { + console.error("Error fetching stream info:", error); + return error; + } +} diff --git a/src/utils/getTopSearch.utils.js b/src/utils/getTopSearch.utils.js new file mode 100644 index 0000000..a1d1282 --- /dev/null +++ b/src/utils/getTopSearch.utils.js @@ -0,0 +1,32 @@ +import axios from "axios"; + +const getTopSearch = async () => { + try { + let workerUrls = import.meta.env.VITE_WORKER_URL?.split(","); + let baseUrl = workerUrls?.length + ? workerUrls[Math.floor(Math.random() * workerUrls.length)] + : import.meta.env.VITE_API_URL; + const storedData = localStorage.getItem("topSearch"); + if (storedData) { + const { data, timestamp } = JSON.parse(storedData); + if (Date.now() - timestamp <= 7 * 24 * 60 * 60 * 1000) { + return data; + } + } + const { data } = await axios.get(`${baseUrl}/top-search`); + const results = data?.results || []; + if (results.length) { + localStorage.setItem( + "topSearch", + JSON.stringify({ data: results, timestamp: Date.now() }) + ); + return results; + } + return []; + } catch (error) { + console.error("Error fetching top search data:", error); + return null; + } +}; + +export default getTopSearch; diff --git a/src/utils/getVoiceActor.utils.js b/src/utils/getVoiceActor.utils.js new file mode 100644 index 0000000..57afecf --- /dev/null +++ b/src/utils/getVoiceActor.utils.js @@ -0,0 +1,14 @@ +import axios from "axios"; + +export default async function fetchVoiceActorInfo(id, page) { + const api_url = import.meta.env.VITE_API_URL; + try { + const response = await axios.get( + `${api_url}/character/list/${id}?page=${page}` + ); + return response.data.results; + } catch (error) { + console.error("Error fetching anime info:", error); + return error; + } +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..2725950 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,62 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + screens: { + "custom-md": "600px", + "custom-xl": "1200px", + "ultra-wide":"1660px", + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + chart: { + 1: "hsl(var(--chart-1))", + 2: "hsl(var(--chart-2))", + 3: "hsl(var(--chart-3))", + 4: "hsl(var(--chart-4))", + 5: "hsl(var(--chart-5))", + }, + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..2e3d156 --- /dev/null +++ b/vercel.json @@ -0,0 +1,3 @@ +{ + "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..454ab3e --- /dev/null +++ b/vite.config.js @@ -0,0 +1,12 @@ +import path from "path" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./"), + }, + }, +})+++ +
+ {animeInfo && ( +++ {[ + ["Home", "home"], + [animeInfo?.showType, animeInfo?.showType], + ].map(([text, link], index) => ( +
+ )} +- + + {text} + + +
+ ))} ++ Watching{" "} + {language === "EN" + ? animeInfo?.title + : animeInfo?.japanese_title} +
++++ {!episodes ? ( +++ ) : ( + setEpisodeId(id)} + totalEpisodes={totalEpisodes} + /> + )} + +++ {!buffering ? (( activeServerName.toLowerCase()==="hd-1" || activeServerName.toLowerCase()==="hd-2" || activeServerName.toLowerCase()==="hd-3" || activeServerName.toLowerCase()==="hd-4") ? ++ + {!buffering && ( +setEpisodeId(id)} + autoNext={autoNext} + />: setEpisodeId(id)} + animeInfo={animeInfo} + episodeNum={activeEpisodeNum} + streamInfo={streamInfo} + /> + ) : ( + ++ )} ++ + {!buffering && !activeServerType ? ( + servers ? ( + <> + Probably this server is down, try other servers +
+
+ Either reload or try again after sometime + > + ) : ( + <> + Probably streaming server is down +
+ Either reload or try again after sometime + > + ) + ) : null} +setEpisodeId(id)} + /> + )} + + {seasons?.length > 0 && ( + ++ )} + {nextEpisodeSchedule?.nextEpisodeSchedule && + showNextEpisodeSchedule && ( ++ Watch more seasons of this anime +
++ {seasons.map((season, index) => ( + +++ {season.season} +
+ ++ + ))} +
++ )} ++++ 🚀 + {" Estimated the next episode will come at "} + + {new Date( + new Date( + nextEpisodeSchedule.nextEpisodeSchedule + ).getTime() - + new Date().getTimezoneOffset() * 60000 + ).toLocaleDateString("en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + })} + ++ setShowNextEpisodeSchedule(false)} + > + × + ++ {animeInfo && animeInfo?.poster ? ( +++ ) : ( +
+ )} + + {animeInfo && animeInfo?.title ? ( +++ {language ? animeInfo?.title : animeInfo?.japanese_title} +
+ ) : ( ++ )} + + {animeInfo ? ( + tags.map( + ({ condition, icon, bgColor, text }, index) => + condition && ( ++ {animeInfo ? ( + animeInfo?.animeInfo?.Overview && ( ++ ) + ) + ) : ( + + )} + + {[ + animeInfo?.animeInfo?.tvInfo?.showType, + animeInfo?.animeInfo?.tvInfo?.duration, + ].map( + (item, index) => + item && ( +++ ++ ) + )} +{item}
+++ ) + ) : ( ++++ {animeInfo?.animeInfo?.Overview.length > 270 ? ( + <> + {isFullOverview + ? animeInfo?.animeInfo?.Overview + : `${animeInfo?.animeInfo?.Overview.slice( + 0, + 270 + )}...`} + setIsFullOverview(!isFullOverview)} + > + {isFullOverview ? "- Less" : "+ More"} + + > + ) : ( + animeInfo?.animeInfo?.Overview + )} +
+++ )} ++ + + + + {`${website_name} is the best site to watch `} + + {language ? animeInfo?.title : animeInfo?.japanese_title} + + {` SUB online, or you can even watch `} + + {language ? animeInfo?.title : animeInfo?.japanese_title} + + {` DUB in HD quality.`} +
+ + View detail + ++++
++Share Anime
+to your friends
++++ {animeInfo?.charactersVoiceActors.length > 0 && ( +++ )} + {animeInfo?.recommended_data.length > 0 ? ( + + ) : ( + + )} + + {animeInfo && animeInfo.related_data ? ( +++ ) : ( + + )} + {homeInfo && homeInfo.most_popular && ( + + )} +