mirror of
https://github.com/JustAnimeCore/JustAnime.git
synced 2026-04-17 13:51:44 +00:00
FRESHHHH
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
#Refer https://github.com/itzzzme/anime-api to host your backend API
|
||||
VITE_API_URL=<your_hosted_api>/api
|
||||
|
||||
#Refer this gist to setup proxy server https://gist.github.com/itzzzme/180813be2c7b45eedc8ce8344c8dea3b
|
||||
VITE_PROXY_URL=<proxy_server_name>/?url=
|
||||
|
||||
#Refer https://github.com/itzzzme/m3u8proxy to host you m3u8 proxy server though it's optional but if you don't set it up you may get CORS error for some servers if you set up from the given repo then only the url structure will look like this
|
||||
VITE_M3U8_PROXY_URL=<m3u8_proxy_server_name>/m3u8-proxy?url=
|
||||
|
||||
#totaly optional / if you don't want to setup worker just change the code of getQtip.utils.js following the pattern of any other utils file
|
||||
VITE_WORKER_URL=https://worker1.workers.dev,https://worker2.workers.dev,https://worker3.workers.dev,...
|
||||
|
||||
VITE_BASE_IFRAME_URL=https://megaplay.buzz/stream/s-2
|
||||
|
||||
VITE_BASE_IFRAME_URL_2=https://vidwish.live/stream/s-2
|
||||
133
.gitignore
vendored
Normal file
133
.gitignore
vendored
Normal file
@@ -0,0 +1,133 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# lock json files
|
||||
package-lock.json
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Sayan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
119
README.md
Normal file
119
README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<p align="center">
|
||||
<div align="center">
|
||||
<a href="https://zenime.site/">
|
||||
<img alt="AnimeHi" src="https://raw.githubusercontent.com/itzzzme/zenime/refs/heads/main/public/logo.png" width="220"/>
|
||||
</a>
|
||||
</div>
|
||||
<h3 align="center">Zenime - Ad free anime streaming platform</h3>
|
||||
<p align="center">
|
||||
<a href="https://github.com/itzzzme/zenime">
|
||||
<img src="https://img.shields.io/github/stars/itzzzme/zenime" alt="Github Stars">
|
||||
</a>
|
||||
<img src="https://img.shields.io/github/issues/itzzzme/zenime" alt="Github Issues">
|
||||
<a href="https://github.com/itzzzme/zenime">
|
||||
<img src="https://img.shields.io/github/forks/itzzzme/zenime" alt="Github Forks" />
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://zenime.site">Zenime</a> is an open-source anime streaming service that uses <a href="https://github.com/itzzzme/anime-api">custom</a> 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.
|
||||
</p>
|
||||
|
||||
<details>
|
||||
<summary>View more Features</summary>
|
||||
|
||||
### General
|
||||
|
||||
- Sub Anime support
|
||||
- Dub Anime support
|
||||
- User-friendly interface
|
||||
- Mobile responsive
|
||||
- Fast page load
|
||||
- Character & Voice Actors
|
||||
|
||||
### Watch Page
|
||||
|
||||
- Related Animes
|
||||
- Recommended Animes
|
||||
- Available seasons
|
||||
- Estimated schedule of upcoming episodes
|
||||
- **Player**
|
||||
- Autoplay
|
||||
- Autoskip intro/outro
|
||||
- Autonext
|
||||
|
||||
</details>
|
||||
|
||||
## Previews
|
||||
|
||||
<div style="text-align: left;">
|
||||
<img src="https://raw.githubusercontent.com/itzzzme/zenime/refs/heads/main/public/homepage.webp" alt="Home Page" style="max-width: 80%;" >
|
||||
<details>
|
||||
<summary style="margin-top:10px">View more screenshots</summary>
|
||||
<br/>
|
||||
AnimeInfo Page
|
||||
<img style="margin-top:10px" src="https://raw.githubusercontent.com/itzzzme/zenime/refs/heads/main/public/animeinfo.webp" alt="AnimeInfo Page" style="max-width: 80%;">
|
||||
<br/>
|
||||
Searchbar
|
||||
<img style="margin-top:10px" src="https://raw.githubusercontent.com/itzzzme/zenime/refs/heads/main/public/searchbar.webp" alt="Searchbar" style="max-width: 50%;">
|
||||
<br/>
|
||||
Character & Voice Actors
|
||||
<img style="margin-top:10px" src="https://raw.githubusercontent.com/itzzzme/zenime/refs/heads/main/public/voiceactors.webp" alt="Character & Voice Actors" style="max-width: 80%;">
|
||||
<br/>
|
||||
Watch Page
|
||||
<img style="margin-top:10px" src="https://raw.githubusercontent.com/itzzzme/zenime/refs/heads/main/public/watchpage.webp" alt="Watch Page" style="max-width: 80%;">
|
||||
<br/>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
## Installation and Local Development
|
||||
|
||||
### 1. Make sure you have node installed on your device
|
||||
|
||||
### 2. Run the following code to clone the repository and install all required dependencies
|
||||
|
||||
```bash
|
||||
git clone https://github.com/itzzzme/zenime.git
|
||||
cd zenime
|
||||
npm install # or yarn
|
||||
```
|
||||
|
||||
### 3. Refer the <a href="https://github.com/itzzzme/zenime/blob/main/.env.example">.env.example</a> to set your .env file up
|
||||
|
||||
## Start the server
|
||||
|
||||
```bash
|
||||
npm start # or npm run dev (to run develepment server)
|
||||
```
|
||||
## Live Deployment
|
||||
|
||||
### Vercel
|
||||
|
||||
Host your own instance of <a href="https://zenime.site">Zenime</a> on vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/itzzzme/zenime)
|
||||
|
||||
### Render
|
||||
|
||||
Host your own instance of <a href="https://zenime.site">Zenime</a> on Render.
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/itzzzme/zenime)
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- Pull requests are welcomed that address bug fixes, improvements, or new features.
|
||||
- Fork the repository and create a new branch for your changes.
|
||||
- Ensure your code follows our coding standards.
|
||||
- Include tests if applicable.
|
||||
- Describe your changes clearly in the pull request, explaining the problem and solution.
|
||||
|
||||
### Reporting Issues
|
||||
|
||||
If you discover any issues or have suggestions for improvement, please open an issue. Provide a clear and concise description of the problem, steps to reproduce it, and any relevant information about your environment.
|
||||
|
||||
### Support
|
||||
|
||||
If you like the project feel free to drop a star ✨. Your appreciation means a lot.
|
||||
|
||||
<p align="center" style="text-decoration: none;">Made by <a href="https://github.com/itzzzme" tarGET="_blank">itzzzme
|
||||
</a>🫰</p>
|
||||
20
components.json
Normal file
20
components.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
38
eslint.config.js
Normal file
38
eslint.config.js
Normal file
@@ -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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
93
index.html
Normal file
93
index.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<link rel="shortcut icon" href="favicon.png" type="image/x-icon" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<meta
|
||||
name="description"
|
||||
content="JustAnime is a Free anime streaming website which you can watch English Subbed and Dubbed Anime online. WATCH NOW!. Hianime, 9animetv, aniwatchtv"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="justanime, just anime, zenime, hianime to, aniwatch, zorox, zoro anime, zoro to, zoroxtv, watch anime online free, free watch anime, anime online to watch"
|
||||
/>
|
||||
<meta name="author" content="Zenime Team" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="rating" content="General" />
|
||||
<meta name="language" content="English" />
|
||||
|
||||
<meta
|
||||
property="og:title"
|
||||
content="JustAnime | Watch Free Anime, Online Anime Streaming - JustAnime"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="JustAnime to is a free no ads anime site to watch free anime. Online anime streaming at justanime with DUB, SUB in HD. Hianime, 9animetv, Zoro, s3taku,justanime."
|
||||
/>
|
||||
<meta property="og:image" content="https://i.postimg.cc/pVqqMKkR/2IAVHlI.webp" />
|
||||
<meta property="og:url" content="https://zenime.site" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:site_name" content="JustAnime" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="JustAnime | Free Anime Streaming Platform"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Stream free English-subbed and dubbed anime online with no ads. Enjoy hassle-free viewing and daily updates on JustAnime!"
|
||||
/>
|
||||
<meta name="twitter:image" content="https://i.postimg.cc/pVqqMKkR/2IAVHlI.webp" />
|
||||
<meta name="twitter:site" content="@ZenimeOfficial" />
|
||||
<title>JustAnime | Free Anime Streaming Platform</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
"name": "Zenime - Watch Anime Free",
|
||||
"description": "Zenime offers free streaming of English-subbed and dubbed anime series and movies. No account needed and no ads!",
|
||||
"thumbnailUrl": "https://i.postimg.cc/pVqqMKkR/2IAVHlI.webp",
|
||||
"uploadDate": "2024-11-08",
|
||||
"contentUrl": "https://zenime.site",
|
||||
"duration": "PT30M",
|
||||
"interactionStatistic": {
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": { "@type": "WatchAction" },
|
||||
"userInteractionCount": 50000
|
||||
},
|
||||
"author": {
|
||||
"@type": "Individual",
|
||||
"name": "itzzzme",
|
||||
"url": "https://zenime.site"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Individual",
|
||||
"name": "itzzzme",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "https://i.postimg.cc/SsKY6Y9f/2H76i57.png"
|
||||
}
|
||||
},
|
||||
"potentialAction": {
|
||||
"@type": "WatchAction",
|
||||
"target": "https://zenime.site"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="canonical" href="https://zenime.site" />
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
lib/utils.js
Normal file
6
lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
54
package.json
Normal file
54
package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "justanime",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"host": "vite --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"artplayer": "^5.2.3",
|
||||
"artplayer-plugin-chapter": "^1.0.0",
|
||||
"artplayer-plugin-hls-control": "^1.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"hls.js": "^1.5.17",
|
||||
"lucide-react": "^0.447.0",
|
||||
"react": "^18.3.1",
|
||||
"react-content-loader": "^7.0.2",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-lazy-load": "^4.0.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"styled-components": "^6.1.13",
|
||||
"swiper": "^11.2.5",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-Agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://zenime.site/sitemap.xml
|
||||
2430
public/sitemap.xml
Normal file
2430
public/sitemap.xml
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/splash.jpg
Normal file
BIN
public/splash.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
27
src/App.css
Normal file
27
src/App.css
Normal file
@@ -0,0 +1,27 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
max-width: 2048px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.content {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
73
src/App.jsx
Normal file
73
src/App.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { HomeInfoProvider } from "./context/HomeInfoContext";
|
||||
import Home from "./pages/Home/Home";
|
||||
import AnimeInfo from "./pages/animeInfo/AnimeInfo";
|
||||
import Navbar from "./components/navbar/Navbar";
|
||||
import Footer from "./components/footer/Footer";
|
||||
import Error from "./components/error/Error";
|
||||
import Category from "./pages/category/Category";
|
||||
import AtoZ from "./pages/a2z/AtoZ";
|
||||
import { azRoute, categoryRoutes } from "./utils/category.utils";
|
||||
import "./App.css";
|
||||
import Search from "./pages/search/Search";
|
||||
import Watch from "./pages/watch/Watch";
|
||||
import Producer from "./components/producer/Producer";
|
||||
import SplashScreen from "./components/splashscreen/SplashScreen";
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
|
||||
// Scroll to top on location change
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [location]);
|
||||
|
||||
// Check if the current route is for the splash screen
|
||||
const isSplashScreen = location.pathname === "/";
|
||||
|
||||
return (
|
||||
<HomeInfoProvider>
|
||||
<div className="app-container">
|
||||
<main className="content">
|
||||
{!isSplashScreen && <Navbar />}
|
||||
<Routes>
|
||||
<Route path="/" element={<SplashScreen />} />
|
||||
<Route path="/home" element={<Home />} />
|
||||
<Route path="/:id" element={<AnimeInfo />} />
|
||||
<Route path="/watch/:id" element={<Watch />} />
|
||||
<Route path="/random" element={<AnimeInfo random={true} />} />
|
||||
<Route path="/404-not-found-page" element={<Error error="404" />} />
|
||||
<Route path="/error-page" element={<Error />} />
|
||||
{/* Render category routes */}
|
||||
{categoryRoutes.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={`/${path}`}
|
||||
element={
|
||||
<Category path={path} label={path.split("-").join(" ")} />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{/* Render A to Z routes */}
|
||||
{azRoute.map((path) => (
|
||||
<Route
|
||||
key={path}
|
||||
path={`/${path}`}
|
||||
element={<AtoZ path={path} />}
|
||||
/>
|
||||
))}
|
||||
<Route path="/producer/:id" element={<Producer />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
{/* Catch-all route for 404 */}
|
||||
<Route path="*" element={<Error error="404" />} />
|
||||
</Routes>
|
||||
{!isSplashScreen && <Footer />}
|
||||
</main>
|
||||
</div>
|
||||
</HomeInfoProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
58
src/components/Loader/AnimeInfo.loader.jsx
Normal file
58
src/components/Loader/AnimeInfo.loader.jsx
Normal file
@@ -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) => <Skeleton key={index} className={className} />)
|
||||
);
|
||||
|
||||
function AnimeInfoLoader() {
|
||||
return (
|
||||
<>
|
||||
<div className="relative grid grid-cols-[minmax(0,75%),minmax(0,25%)] h-fit w-full overflow-hidden mt-[64px] max-[1200px]:flex max-[1200px]:flex-col max-md:mt-[50px]">
|
||||
<Skeleton className="absolute inset-0 w-full h-full blur-lg z-[-900] bg-gray-500" animation={false} />
|
||||
<div className="flex items-start z-10 px-14 py-[70px] bg-[#252434] bg-opacity-70 gap-x-8 max-[1024px]:px-6 max-[1024px]:py-10 max-[1024px]:gap-x-4 max-[575px]:flex-col max-[575px]:items-center max-[575px]:justify-center">
|
||||
<Skeleton className="w-[200px] h-[270px] rounded-none" />
|
||||
<div className="flex flex-col ml-4 gap-y-5 w-full max-[575px]:items-center max-[575px]:justify-center max-[575px]:mt-0">
|
||||
<ul className="flex gap-x-2 items-center w-fit max-[1200px]:hidden">
|
||||
<SkeletonItems count={3} className="w-[40px] h-[15px] rounded-xl " />
|
||||
</ul>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<SkeletonItems count={2} className="w-[50%] h-[20px] rounded-xl " />
|
||||
</div>
|
||||
<div className="flex gap-x-[3px]">
|
||||
<SkeletonItems count={6} className="w-[30px] h-[20px] rounded-sm" />
|
||||
</div>
|
||||
<Skeleton className="w-[150px] h-[40px] rounded-3xl mt-4" />
|
||||
<div className="flex flex-col gap-y-2 mt-5 max-[575px]:hidden">
|
||||
<SkeletonItems count={3} className="w-[80%] h-[15px] rounded-3xl " />
|
||||
</div>
|
||||
<div className="flex gap-x-4 items-center mt-4">
|
||||
<Skeleton className="w-[60px] h-[60px] rounded-full bg-gray-500 max-[575px]:hidden" />
|
||||
<div className="flex flex-col w-fit gap-y-2">
|
||||
<SkeletonItems count={2} className="w-[150px] h-[10px] rounded-xl " />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#4c4b57c3] flex items-center px-8 max-[1200px]:py-10 max-[1200px]:bg-[#363544e0] max-[575px]:p-4">
|
||||
<div className="w-full flex flex-col h-fit gap-y-4">
|
||||
<SkeletonItems count={6} className="w-full h-[15px] rounded-xl" />
|
||||
<div className="flex gap-x-4 py-2 mt-4">
|
||||
<Skeleton className="w-[50px] h-[20px] " />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<SkeletonItems count={4} className="w-[30px] h-[20px] rounded-sm bg-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonItems count={2} className="w-[90%] h-[15px] rounded-xl " />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col px-4">
|
||||
<CategoryCardLoader className="mt-[60px]"/>
|
||||
<SidecardLoader className="mt-[60px]" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default AnimeInfoLoader;
|
||||
26
src/components/Loader/AtoZ.loader.jsx
Normal file
26
src/components/Loader/AtoZ.loader.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton";
|
||||
import CategoryCardLoader from "./CategoryCard.loader";
|
||||
|
||||
const SkeletonItems = ({ count, className }) => (
|
||||
[...Array(count)].map((_, index) => <Skeleton key={index} className={className} />)
|
||||
);
|
||||
|
||||
function AtoZLoader() {
|
||||
return (
|
||||
<div className="max-w-[1260px] mx-auto px-[15px] flex flex-col mt-[64px] max-md:mt-[50px]">
|
||||
<ul className="flex gap-x-4 mt-[50px] items-center w-fit max-[1200px]:hidden">
|
||||
<Skeleton className="w-[50px] h-[15px]" />
|
||||
<Skeleton className="w-[70px] h-[15px]" />
|
||||
</ul>
|
||||
<div className="flex flex-col gap-y-5 mt-6">
|
||||
<Skeleton className="w-[200px] h-[15px]" />
|
||||
<div className='flex gap-x-[7px] flex-wrap justify-start gap-y-2 max-md:justify-start'>
|
||||
<SkeletonItems count={20} className="w-[40px] h-[20px] rounded-sm"/>
|
||||
</div>
|
||||
</div>
|
||||
<CategoryCardLoader showLabelSkeleton={false}/>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
export default AtoZLoader;
|
||||
27
src/components/Loader/Cart.loader.jsx
Normal file
27
src/components/Loader/Cart.loader.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton"
|
||||
const SkeletonItems = ({ count, className }) => (
|
||||
[...Array(count)].map((_, index) => <Skeleton key={index} className={className} />)
|
||||
);
|
||||
function CartLoader() {
|
||||
return (
|
||||
<div className="flex flex-col w-1/4 space-y-7 max-[1200px]:w-full">
|
||||
<Skeleton className="w-[200px] h-[20px]" />
|
||||
<div className='w-full space-y-4 flex flex-col '>
|
||||
{[...Array(5)].map((item, index) => (
|
||||
<div key={index} style={{ borderBottom: "1px solid rgba(255, 255, 255, .075)" }} className="flex pb-4 items-center">
|
||||
<Skeleton className="w-[60px] h-[75px] rounded-none" />
|
||||
<div className='flex flex-col ml-4 space-y-2 w-full'>
|
||||
<Skeleton className='w-[90%] h-[15px]' />
|
||||
<div className='flex items-center w-fit space-x-1'>
|
||||
<SkeletonItems count={3} className="w-[30px] h-[15px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='w-[100px] h-[30px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartLoader
|
||||
23
src/components/Loader/Category.loader.jsx
Normal file
23
src/components/Loader/Category.loader.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton"
|
||||
import CategoryCardLoader from "./CategoryCard.loader"
|
||||
import SidecardLoader from "./Sidecard.loader"
|
||||
|
||||
function CategoryLoader() {
|
||||
return (
|
||||
<div className='w-full flex flex-col gap-y-4 mt-[64px] max-md:mt-[50px]'>
|
||||
<div className="flex gap-x-4 items-center p-5 mt-4">
|
||||
<Skeleton className="w-[60px] h-[60px] rounded-full bg-gray-500 max-[575px]:hidden" />
|
||||
<div className="flex flex-col w-fit gap-y-2">
|
||||
<Skeleton className="w-[150px] h-[10px] rounded-xl " />
|
||||
<Skeleton className="w-[150px] h-[10px] rounded-xl " />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex max-[1200px]:flex-col max-[1200px]:gap-y-10">
|
||||
<CategoryCardLoader className={"mt-[0px]"} />
|
||||
<SidecardLoader />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoryLoader
|
||||
35
src/components/Loader/CategoryCard.loader.jsx
Normal file
35
src/components/Loader/CategoryCard.loader.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton";
|
||||
|
||||
function CategoryCardLoader({ className, showLabelSkeleton = true }) {
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
{showLabelSkeleton && (
|
||||
<Skeleton className="w-[200px] h-[20px] max-[320px]:w-[70px]" />
|
||||
)}
|
||||
<div className="grid grid-cols-6 gap-x-3 gap-y-8 mt-6 max-[1400px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-2">
|
||||
{[...Array(12)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col"
|
||||
style={{ height: "fit-content" }}
|
||||
>
|
||||
<div className="w-full relative">
|
||||
<Skeleton className="w-full h-[250px] object-cover max-[1200px]:h-[35vw] max-[758px]:h-[45vw] max-[478px]:h-[60vw] rounded-none" />
|
||||
<div className="absolute left-2 bottom-4 flex items-center justify-center w-fit space-x-1 z-20 max-[320px]:w-[80%] max-[320px]:left-0">
|
||||
<Skeleton className="w-[50px] h-[15px] bg-gray-600 max-[320px]:w-[40%]" />
|
||||
<Skeleton className="w-[50px] h-[15px] bg-gray-600 max-[320px]:w-[40%]" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="mt-1 w-[90%] h-[15px]" />
|
||||
<div className="flex items-center gap-x-2 w-full mt-2">
|
||||
<Skeleton className="w-[40%] h-[12px]" />
|
||||
<Skeleton className="w-[40%] h-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryCardLoader;
|
||||
32
src/components/Loader/Home.loader.jsx
Normal file
32
src/components/Loader/Home.loader.jsx
Normal file
@@ -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 (
|
||||
<div className="px-4 w-full h-full max-[1200px]:px-0 bg-[#3a395100]">
|
||||
<SpotlightLoader />
|
||||
<Trendingloader />
|
||||
<div className="mt-16 flex gap-6 max-[1200px]:px-4 max-[1200px]:grid max-[1200px]:grid-cols-2 max-[1200px]:mt-12 max-[1200px]:gap-y-10 max-[680px]:grid-cols-1">
|
||||
<CartLoader />
|
||||
<CartLoader />
|
||||
<CartLoader />
|
||||
<CartLoader />
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col max-[1200px]:px-4">
|
||||
<div>
|
||||
<CategoryCardLoader className="mt-[60px]" />
|
||||
<CategoryCardLoader className="mt-[60px]" />
|
||||
<CategoryCardLoader className="mt-[60px]" />
|
||||
</div>
|
||||
<div className="w-full mt-[60px]">
|
||||
<SidecardLoader />
|
||||
<SidecardLoader />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomeLoader;
|
||||
24
src/components/Loader/Loader.jsx
Normal file
24
src/components/Loader/Loader.jsx
Normal file
@@ -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 <HomeLoader />;
|
||||
case "animeInfo":
|
||||
return <AnimeInfoLoader />;
|
||||
case "category":
|
||||
return <CategoryLoader />;
|
||||
case "producer":
|
||||
return <ProducerLoader />;
|
||||
case "AtoZ":
|
||||
return <AtoZLoader />;
|
||||
default:
|
||||
return <div className="loading-skeleton default-skeleton"></div>;
|
||||
}
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
15
src/components/Loader/Producer.loader.jsx
Normal file
15
src/components/Loader/Producer.loader.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import CategoryCardLoader from "./CategoryCard.loader";
|
||||
import SidecardLoader from "./Sidecard.loader";
|
||||
|
||||
function ProducerLoader() {
|
||||
return (
|
||||
<div className="w-full mt-[100px] flex flex-col gap-y-4 max-md:mt-[50px]">
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex max-[1200px]:flex-col max-[1200px]:gap-y-10">
|
||||
<CategoryCardLoader className={"mt-[0px]"} />
|
||||
<SidecardLoader />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProducerLoader;
|
||||
26
src/components/Loader/Sidecard.loader.jsx
Normal file
26
src/components/Loader/Sidecard.loader.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton";
|
||||
function SidecardLoader({ className }) {
|
||||
return (
|
||||
<div className={`flex flex-col space-y-6 ${className}`}>
|
||||
<Skeleton className='w-[200px] h-[15px]' />
|
||||
<div className='flex flex-col space-y-4 bg-[#2B2A3C] p-4 pt-8 w-full'>
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<div key={index} className='flex items-center gap-x-4'>
|
||||
<div className="flex pb-4 relative container items-center">
|
||||
<Skeleton className="w-[60px] h-[75px] rounded-md" />
|
||||
<div className='flex flex-col ml-4 space-y-2 w-[60%]'>
|
||||
<Skeleton className='w-[90%] h-[15px]' />
|
||||
<div className='flex flex-wrap items-center space-x-1'>
|
||||
<Skeleton className="w-[30%] h-[15px]" />
|
||||
<Skeleton className="w-[30%] h-[15px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidecardLoader;
|
||||
34
src/components/Loader/Spotlight.loader.jsx
Normal file
34
src/components/Loader/Spotlight.loader.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton"
|
||||
const SkeletonItems = ({ count, className }) => (
|
||||
[...Array(count)].map((_, index) => <Skeleton key={index} className={className} />)
|
||||
);
|
||||
function SpotlightLoader() {
|
||||
return (
|
||||
<section className="w-full h-[600px] max-[1390px]:h-[530px] max-[1300px]:h-[500px] max-md:h-[420px] relative">
|
||||
<div className="absolute flex flex-col left-0 bottom-[100px] w-[55%] p-4 z-10 max-[1390px]:w-[45%] max-[1390px]:bottom-[10px] max-[1300px]:w-[600px] max-[1120px]:w-[60%] max-md:w-[90%] max-[300px]:w-full">
|
||||
<Skeleton className="w-[400px] h-[20px] max-md:w-[180px]" />
|
||||
<Skeleton className="w-[70%] h-[20px] mt-6 text-left max-[1300px]:mt-4 max-sm:w-[80%] max-[320px]:w-full " />
|
||||
<div className="flex h-fit justify-center items-center w-fit space-x-5 mt-8 max-[1300px]:mt-6 max-md:hidden">
|
||||
<SkeletonItems count={2} className="w-[30px] h-[15px]" />
|
||||
<div className="flex space-x-3 w-fit">
|
||||
<Skeleton className="w-[80px] h-[15px]" />
|
||||
<div className='flex space-x-[1px] rounded-r-[5px] rounded-l-[5px] w-fit py-[1px] overflow-hidden'>
|
||||
<SkeletonItems count={2} className="w-[30px] h-[15px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 max-[1300px]:w-[500px] flex flex-col gap-y-2 max-[1120px]:w-[90%] max-md:hidden">
|
||||
<Skeleton className="w-full h-[13px]" />
|
||||
<Skeleton className="w-[85%] h-[13px]" />
|
||||
<Skeleton className="w-[70%] h-[13px]" />
|
||||
</div>
|
||||
<div className='flex gap-x-5 mt-10 max-md:mt-6 max-sm:w-full max-[320px]:flex-col max-[320px]:space-y-3'>
|
||||
<Skeleton className="w-[170px] h-[40px] max-[575px]:w-[120px] max-[575px]:h-[30px]" />
|
||||
<Skeleton className="w-[150px] h-[40px] max-[575px]:w-[120px] max-[575px]:h-[30px]" />
|
||||
</div>
|
||||
</div>
|
||||
</section >
|
||||
)
|
||||
}
|
||||
|
||||
export default SpotlightLoader
|
||||
34
src/components/Loader/Trending.loader.jsx
Normal file
34
src/components/Loader/Trending.loader.jsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col w-full mt-10 max-[1200px]:px-4">
|
||||
<Skeleton className="w-[150px] h-[20px] max-[400px]:w-[100px]" />
|
||||
<div className="w-full h-[250px] overflow-hidden flex mt-6 justify-around max-[1300px]:h-fit">
|
||||
{[...Array(count)].map((_, index) => (
|
||||
<div key={index}>
|
||||
<Skeleton className="w-[200px] h-full rounded-none max-[1300px]:w-[22vw] max-[1300px]:h-[30vw] max-[720px]:w-[25vw] max-[720px]:h-[35vw]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrendingLoader;
|
||||
21
src/components/Loader/VoiceActorlist.loader.jsx
Normal file
21
src/components/Loader/VoiceActorlist.loader.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton"
|
||||
|
||||
function VoiceActorlistLoader() {
|
||||
return (
|
||||
<div className="w-full h-fit grid grid-cols-2 gap-4 overflow-y-hidden max-sm:gap-2 max-md:h-[400px] max-md:flex max-md:flex-col">
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<div key={index} className="h-[80px] p-4 rounded-md bg-[#444445]">
|
||||
<div className="flex h-full items-center gap-x-2">
|
||||
<Skeleton className="w-[45px] h-[45px] rounded-full max-sm:w-[30px] max-sm:h-[30px]" />
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Skeleton className="h-[10px] w-[100px] rounded-md max-[300px]:w-[50px] max-[300px]:h-[8px]" />
|
||||
<Skeleton className="h-[10px] w-[70px] rounded-md max-[300px]:w-[20px] max-[300px]:h-[8px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceActorlistLoader
|
||||
133
src/components/banner/Banner.css
Normal file
133
src/components/banner/Banner.css
Normal file
@@ -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%
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/components/banner/Banner.jsx
Normal file
136
src/components/banner/Banner.jsx
Normal file
@@ -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 (
|
||||
<section className="spotlight w-full h-full">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="absolute right-0 object-cover h-full w-[80%] bg-auto max-[1200px]:w-full max-[1200px]:bottom-0"
|
||||
/>
|
||||
<div className="spotlight-overlay"></div>
|
||||
<div className="absolute flex flex-col left-0 bottom-[50px] w-[55%] p-4 z-10 max-[1390px]:w-[45%] max-[1390px]:bottom-[10px] max-[1300px]:w-[600px] max-[1120px]:w-[60%] max-md:w-[90%] max-[300px]:w-full">
|
||||
<p className="text-[#ffbade] font-semibold text-[20px] w-fit max-[1300px]:text-[15px]">
|
||||
#{index + 1} Spotlight
|
||||
</p>
|
||||
<h3 className="text-white line-clamp-2 text-5xl font-bold mt-6 text-left max-[1390px]:text-[45px] max-[1300px]:text-3xl max-[1300px]:mt-4 max-md:text-2xl max-md:mt-1 max-[575px]:text-[22px] max-sm:leading-6 max-sm:w-[80%] max-[320px]:w-full ">
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</h3>
|
||||
<div className="flex h-fit justify-center items-center w-fit space-x-5 mt-8 max-[1300px]:mt-6 max-md:hidden">
|
||||
{item.tvInfo && (
|
||||
<>
|
||||
{item.tvInfo.showType && (
|
||||
<div className="flex space-x-1 justify-center items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[8px] bg-white px-[4px] py-[3px] rounded-full"
|
||||
/>
|
||||
<p className="text-white text-[16px]">
|
||||
{item.tvInfo.showType}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.tvInfo.duration && (
|
||||
<div className="flex space-x-1 justify-center items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className="text-white text-[14px]"
|
||||
/>
|
||||
<p className="text-white text-[17px]">
|
||||
{item.tvInfo.duration}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.tvInfo.releaseDate && (
|
||||
<div className="flex space-x-1 justify-center items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faCalendar}
|
||||
className="text-white text-[14px]"
|
||||
/>
|
||||
<p className="text-white text-[16px]">
|
||||
{item.tvInfo.releaseDate}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-3 w-fit">
|
||||
{item.tvInfo.quality && (
|
||||
<div className="bg-[#ffbade] py-[1px] px-[6px] rounded-md w-fit text-[11px] font-bold h-fit">
|
||||
{item.tvInfo.quality}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex space-x-[1px] rounded-r-[5px] rounded-l-[5px] w-fit py-[1px] overflow-hidden">
|
||||
{item.tvInfo.episodeInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] px-[4px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.episodeInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.tvInfo.episodeInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] px-[4px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-semibold">
|
||||
{item.tvInfo.episodeInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-[17px] font-sm mt-6 text-left line-clamp-3 max-[1200px]:line-clamp-2 max-[1300px]:w-[500px] max-[1120px]:w-[90%] max-md:hidden">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="flex gap-x-5 mt-10 max-md:mt-6 max-sm:w-full max-[320px]:flex-col max-[320px]:space-y-3">
|
||||
<button className="flex justify-center items-center bg-[#ffbade] px-4 py-2 rounded-3xl gap-x-2 max-[320px]:w-fit ">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[8px] bg-[#000000] px-[6px] py-[6px] rounded-full text-[#ffbade] max-[320px]:text-[6px]"
|
||||
/>
|
||||
<Link
|
||||
to={`/watch/${item.id}`}
|
||||
className="max-[1000px]:text-[15px] font-semibold max-[320px]:text-[12px]"
|
||||
>
|
||||
Watch Now
|
||||
</Link>
|
||||
</button>
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="flex bg-[#3B3A52] justify-center items-center px-4 py-2 rounded-3xl gap-x-2 max-[320px]:w-fit max-[320px]:px-3"
|
||||
>
|
||||
<p className="text-white max-[1000px]:text-[15px] font-semibold max-[320px]:text-[12px]">
|
||||
Detail
|
||||
</p>
|
||||
<FaChevronRight className="text-white max-[320px]:text-[10px]" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default Banner;
|
||||
10
src/components/cart/Cart.css
Normal file
10
src/components/cart/Cart.css
Normal file
@@ -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;
|
||||
}
|
||||
132
src/components/cart/Cart.jsx
Normal file
132
src/components/cart/Cart.jsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col w-1/4 space-y-7 max-[1200px]:w-full">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade] max-md:text-xl">
|
||||
{label}
|
||||
</h1>
|
||||
<div className="w-full space-y-4 flex flex-col">
|
||||
{data &&
|
||||
data.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ borderBottom: "1px solid rgba(255, 255, 255, .075)" }}
|
||||
className="flex pb-4 items-center relative"
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="flex-shrink-0 w-[60px] h-[75px] rounded-md object-cover cursor-pointer"
|
||||
onClick={() => navigate(`/watch/${item.id}`)}
|
||||
onMouseEnter={() => handleImageEnter(item, index)}
|
||||
onMouseLeave={handleImageLeave}
|
||||
/>
|
||||
|
||||
{hoveredItem === item.id + index && window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition}
|
||||
${
|
||||
tooltipHorizontalPosition === "left-1/2"
|
||||
? "translate-x-[-100px]"
|
||||
: "translate-x-[-200px]"
|
||||
}
|
||||
z-[100000] transform transition-all duration-300 ease-in-out
|
||||
${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (hoverTimeout) clearTimeout(hoverTimeout);
|
||||
}}
|
||||
onMouseLeave={handleImageLeave}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col ml-4 space-y-2 w-full">
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="w-full line-clamp-2 text-[1em] font-[500] hover:cursor-pointer hover:text-[#ffbade] transform transition-all ease-out max-[1200px]:text-[14px]"
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
<div className="flex items-center flex-wrap w-fit space-x-1">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[4px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">{item.tvInfo.sub}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[4px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">{item.tvInfo.dub}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center w-fit pl-1 gap-x-1">
|
||||
<div className="dot"></div>
|
||||
<p className="text-[14px] text-[#D2D2D3]">
|
||||
{item.tvInfo.showType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
to={`/${path}`}
|
||||
className="flex w-fit items-baseline rounded-3xl gap-x-2 group"
|
||||
>
|
||||
<p className="text-white text-[17px] h-fit leading-4 group-hover:text-[#ffbade] transform transition-all ease-out">
|
||||
View more
|
||||
</p>
|
||||
<FaChevronRight className="text-white text-[10px] group-hover:text-[#ffbade] transform transition-all ease-out" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Cart;
|
||||
27
src/components/categorycard/CategoryCard.css
Normal file
27
src/components/categorycard/CategoryCard.css
Normal file
@@ -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;
|
||||
}
|
||||
340
src/components/categorycard/CategoryCard.jsx
Normal file
340
src/components/categorycard/CategoryCard.jsx
Normal file
@@ -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 (
|
||||
<div className={`w-full ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px] capitalize">
|
||||
{label}
|
||||
</h1>
|
||||
{showViewMore && (
|
||||
<Link
|
||||
to={`/${path}`}
|
||||
className="flex w-fit items-baseline h-fit rounded-3xl gap-x-1 group"
|
||||
>
|
||||
<p className="text-white text-[12px] font-semibold h-fit leading-0 group-hover:text-[#ffbade] transition-all ease-out">
|
||||
View more
|
||||
</p>
|
||||
<FaChevronRight className="text-white text-[10px] group-hover:text-[#ffbade] transition-all ease-out" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
{categoryPage && (
|
||||
<div
|
||||
className={`grid grid-cols-4 gap-x-3 gap-y-8 transition-all duration-300 ease-in-out ${
|
||||
categoryPage && itemsToRender.firstRow.length > 0
|
||||
? "mt-8 max-[758px]:hidden"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{itemsToRender.firstRow.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col transition-transform duration-300 ease-in-out"
|
||||
style={{ height: "fit-content" }}
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<div
|
||||
className="w-full relative group hover:cursor-pointer"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${
|
||||
path === "top-upcoming"
|
||||
? `/${item.id}`
|
||||
: `/watch/${item.id}`
|
||||
}`
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => handleMouseEnter(item, index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hoveredItem === item.id + index && showPlay && (
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[40px] text-white absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[10000]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overlay"></div>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className={`w-full h-[320px] object-cover max-[1200px]:h-[35vw] max-[758px]:h-[45vw] max-[478px]:h-[60vw] group-hover:blur-[7px] transform transition-all duration-300 ease-in-out ultra-wide:h-[400px] ${cardStyle}`}
|
||||
/>
|
||||
</div>
|
||||
{(item.tvInfo?.rating === "18+" ||
|
||||
item?.adultContent === true) && (
|
||||
<div className="text-white px-2 rounded-md bg-[#FF5700] absolute top-2 left-2 flex items-center justify-center text-[14px] font-bold">
|
||||
18+
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 bottom-3 flex items-center justify-center w-fit space-x-1 z-[100] max-[270px]:flex-col max-[270px]:gap-y-[3px]">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[2px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[2px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.eps && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#a9a6b16f] rounded-[2px] px-[8px] text-white py-[2px]">
|
||||
<p className="text-[12px] font-extrabold">
|
||||
{item.tvInfo.eps}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hoveredItem === item.id + index &&
|
||||
window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition} z-[100000] transform transition-all duration-300 ease-in-out ${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="text-white font-semibold mt-1 item-title hover:text-[#FFBADE] hover:cursor-pointer line-clamp-1"
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
{item.description && (
|
||||
<div className="line-clamp-3 text-[13px] font-extralight text-[#b1b0b0] max-[1200px]:hidden">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-x-2 w-full mt-2 overflow-hidden">
|
||||
<div className="text-gray-400 text-[14px] text-nowrap overflow-hidden text-ellipsis">
|
||||
{item.tvInfo.showType.split(" ").shift()}
|
||||
</div>
|
||||
<div className="dot"></div>
|
||||
<div className="text-gray-400 text-[14px] text-nowrap overflow-hidden text-ellipsis">
|
||||
{item.tvInfo?.duration === "m" ||
|
||||
item.tvInfo?.duration === "?" ||
|
||||
item.duration === "m" ||
|
||||
item.duration === "?"
|
||||
? "N/A"
|
||||
: item.tvInfo?.duration || item.duration || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-6 gap-x-3 gap-y-8 mt-6 transition-all duration-300 ease-in-out max-[1400px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-2">
|
||||
{itemsToRender.remainingItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col transition-transform duration-300 ease-in-out"
|
||||
style={{ height: "fit-content" }}
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<div
|
||||
className="w-full relative group hover:cursor-pointer"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${
|
||||
path === "top-upcoming"
|
||||
? `/${item.id}`
|
||||
: `/watch/${item.id}`
|
||||
}`
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => handleMouseEnter(item, index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hoveredItem === item.id + index && showPlay && (
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[40px] text-white absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[10000]"
|
||||
/>
|
||||
)}
|
||||
<div className="overlay"></div>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className={`w-full h-[250px] object-cover max-[1200px]:h-[35vw] max-[758px]:h-[45vw] max-[478px]:h-[60vw] ${cardStyle} group-hover:blur-[7px] transform transition-all duration-300 ease-in-out `}
|
||||
/>
|
||||
</div>
|
||||
{(item.tvInfo?.rating === "18+" ||
|
||||
item?.adultContent === true) && (
|
||||
<div className="text-white px-2 rounded-md bg-[#FF5700] absolute top-2 left-2 flex items-center justify-center text-[14px] font-bold">
|
||||
18+
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 bottom-4 flex items-center justify-center w-fit space-x-1 z-[100] max-[270px]:flex-col max-[270px]:gap-y-[3px]">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[2px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[2px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hoveredItem === item.id + index &&
|
||||
window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition} z-[100000] transform transition-all duration-300 ease-in-out ${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="text-white font-semibold mt-1 item-title hover:text-[#FFBADE] hover:cursor-pointer line-clamp-1"
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-x-2 w-full mt-2 overflow-hidden">
|
||||
<div className="text-gray-400 text-[14px] text-nowrap overflow-hidden text-ellipsis">
|
||||
{item.tvInfo.showType.split(" ").shift()}
|
||||
</div>
|
||||
<div className="dot"></div>
|
||||
<div className="text-gray-400 text-[14px] text-nowrap overflow-hidden text-ellipsis">
|
||||
{item.tvInfo?.duration === "m" ||
|
||||
item.tvInfo?.duration === "?" ||
|
||||
item.duration === "m" ||
|
||||
item.duration === "?"
|
||||
? "N/A"
|
||||
: item.tvInfo?.duration || item.duration || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CategoryCard.displayName = "CategoryCard";
|
||||
|
||||
export default CategoryCard;
|
||||
132
src/components/continue/ContinueWatching.jsx
Normal file
132
src/components/continue/ContinueWatching.jsx
Normal file
@@ -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 (
|
||||
<div className="mt-6 max-[1200px]:px-6 max-md:px-0">
|
||||
<div className="flex items-center justify-between max-md:pl-4">
|
||||
<div className="flex items-center gap-x-2 justify-center">
|
||||
<FaHistory className="text-[#ffbade]" />
|
||||
<h1 className="text-[#ffbade] text-2xl font-bold max-[450px]:text-xl max-[450px]:mb-1 max-[350px]:text-lg">
|
||||
Continue Watching
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2 pr-2 max-[350px]:hidden">
|
||||
<button className="btn-prev bg-gray-700 text-white p-3 rounded-full hover:bg-gray-500 transition max-[768px]:p-2">
|
||||
<FaChevronLeft className="text-xs" />
|
||||
</button>
|
||||
<button className="btn-next bg-gray-700 text-white p-3 rounded-full hover:bg-gray-500 transition max-[768px]:p-2">
|
||||
<FaChevronRight className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto overflow-hidden z-[1] mt-6 max-[450px]:mt-3">
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
className="w-full h-full"
|
||||
slidesPerView={3}
|
||||
spaceBetween={15}
|
||||
breakpoints={{
|
||||
640: { slidesPerView: 4, spaceBetween: 15 },
|
||||
768: { slidesPerView: 4, spaceBetween: 15 },
|
||||
1024: { slidesPerView: 5, spaceBetween: 15 },
|
||||
1300: { slidesPerView: 6, spaceBetween: 15 },
|
||||
1600: { slidesPerView: 7, spaceBetween: 20 },
|
||||
}}
|
||||
modules={[Navigation]}
|
||||
navigation={{
|
||||
nextEl: ".btn-next",
|
||||
prevEl: ".btn-prev",
|
||||
}}
|
||||
>
|
||||
{memoizedWatchList.map((item, index) => (
|
||||
<SwiperSlide
|
||||
key={index}
|
||||
className="text-center flex justify-center items-center"
|
||||
>
|
||||
<div className="w-full h-auto pb-[140%] relative inline-block overflow-hidden">
|
||||
<button
|
||||
className="absolute top-2 right-2 bg-black text-white px-3 py-2 bg-opacity-60 rounded-full text-sm z-10 font-extrabold hover:bg-white hover:text-black transition-all"
|
||||
onClick={() => removeFromWatchList(item.episodeId)}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to={`/watch/${item?.id}?ep=${item.episodeId}`}
|
||||
className="inline-block bg-[#2a2c31] absolute left-0 top-0 w-full h-full group"
|
||||
>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item?.poster}`}
|
||||
alt={item?.title}
|
||||
className="block w-full h-full object-cover transition-all duration-300 ease-in-out group-hover:blur-[4px]"
|
||||
title={item?.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[50px] text-white absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[10000] max-[450px]:text-[36px]"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
{item?.adultContent === true && (
|
||||
<div className="text-white px-2 rounded-md bg-[#FF5700] absolute top-2 left-2 flex items-center justify-center text-[14px] font-bold">
|
||||
18+
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 flex flex-col gap-y-2 right-0 p-2 bg-gradient-to-t from-black via-black/80 to-transparent max-[450px]:gap-y-1">
|
||||
<p className="text-white text-md font-bold text-left truncate max-[450px]:text-sm">
|
||||
{language === "EN"
|
||||
? item?.title
|
||||
: item?.japanese_title}
|
||||
</p>
|
||||
<p className="text-gray-300 text-sm font-semibold text-left max-[450px]:text-[12px]">
|
||||
Episode {item.episodeNum}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContinueWatching;
|
||||
15
src/components/episodelist/Episodelist.css
Normal file
15
src/components/episodelist/Episodelist.css
Normal file
@@ -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;
|
||||
}
|
||||
303
src/components/episodelist/Episodelist.jsx
Normal file
303
src/components/episodelist/Episodelist.jsx
Normal file
@@ -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 (
|
||||
<div className="relative flex flex-col w-full h-full max-[1200px]:max-h-[500px]">
|
||||
<div className="sticky top-0 z-10 flex flex-col gap-y-[5px] justify-start px-3 py-4 bg-[#0D0D15]">
|
||||
<h1 className="text-[13px] font-bold">List of episodes:</h1>
|
||||
{totalEpisodes > 100 && (
|
||||
<div className="w-full flex gap-x-4 items-center max-[1200px]:justify-between">
|
||||
<div className="min-w-fit flex text-[13px]">
|
||||
<div
|
||||
onClick={() => setShowDropDown((prev) => !prev)}
|
||||
className="text-white w-fit mt-1 text-[13px] relative cursor-pointer bg-[#0D0D15] flex justify-center items-center"
|
||||
ref={dropDownRef}
|
||||
>
|
||||
<FontAwesomeIcon icon={faList} />
|
||||
<div className="w-fit flex justify-center items-center gap-x-2 ml-4">
|
||||
<p className="text-white text-[12px]">
|
||||
EPS: {selectedRange[0]}-{selectedRange[1]}
|
||||
</p>
|
||||
<FontAwesomeIcon
|
||||
icon={faAngleDown}
|
||||
className="mt-[2px] text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
{showDropDown && (
|
||||
<div className="absolute flex flex-col top-full mt-[10px] left-0 z-30 bg-white w-[150px] max-h-[200px] overflow-y-auto rounded-l-[8px]">
|
||||
{generateRangeOptions(totalEpisodes).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleRangeSelect(item);
|
||||
setActiveRange(item);
|
||||
}}
|
||||
className={`hover:bg-gray-200 cursor-pointer text-black ${
|
||||
item === activeRange ? "bg-[#EFF0F4]" : ""
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold text-[12px] p-3 flex justify-between items-center">
|
||||
EPS: {item}
|
||||
{item === activeRange ? (
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-[1px] border-[#ffffff34] rounded-sm py-[4px] px-[8px] flex items-center gap-x-[10px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-transparent focus:outline-none rounded-sm text-[13px] font-bold placeholder:text-[12px] placeholder:font-medium"
|
||||
placeholder="Number of Ep"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={listContainerRef} className="w-full h-full overflow-y-auto">
|
||||
<div
|
||||
className={`${
|
||||
totalEpisodes > 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 (
|
||||
<div
|
||||
key={item?.id}
|
||||
ref={isActive ? activeEpisodeRef : null}
|
||||
className={`flex items-center justify-center rounded-[3px] h-[30px] text-[13.5px] font-medium cursor-pointer group ${
|
||||
item?.filler
|
||||
? isActive
|
||||
? "bg-[#ffbade]"
|
||||
: "bg-gradient-to-r from-[#5a4944] to-[#645a4b]"
|
||||
: ""
|
||||
} md:hover:bg-[#67686F]
|
||||
md:hover:text-white
|
||||
${
|
||||
isActive
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-[#35373D] text-gray-400"
|
||||
} ${isSearched ? "glow-animation" : ""} `}
|
||||
onClick={() => {
|
||||
if (episodeNumber) {
|
||||
onEpisodeClick(episodeNumber);
|
||||
setActiveEpisodeId(episodeNumber);
|
||||
setSearchedEpisode(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
item?.filler
|
||||
? "text-white md:group-hover:text-[#ffbade]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{index + selectedRange[0]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: episodes?.map((item, index) => {
|
||||
const episodeNumber = item?.id.match(/ep=(\d+)/)?.[1];
|
||||
const isActive =
|
||||
activeEpisodeId === episodeNumber ||
|
||||
currentEpisode === episodeNumber;
|
||||
const isSearched = searchedEpisode === item?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?.id}
|
||||
ref={isActive ? activeEpisodeRef : null}
|
||||
className={`w-full pl-5 pr-2 py-3 flex items-center justify-start gap-x-8 cursor-pointer ${
|
||||
(index + 1) % 2 && !isActive
|
||||
? "bg-[#201F2D] text-gray-400"
|
||||
: "bg-none"
|
||||
} group md:hover:bg-[#2B2A42] ${
|
||||
isActive ? "text-[#ffbade] bg-[#2B2A42]" : ""
|
||||
} ${isSearched ? "glow-animation" : ""}`}
|
||||
onClick={() => {
|
||||
if (episodeNumber) {
|
||||
onEpisodeClick(episodeNumber);
|
||||
setActiveEpisodeId(episodeNumber);
|
||||
setSearchedEpisode(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-[14px] font-medium">{index + 1}</p>
|
||||
<div className="w-full flex items-center justify-between gap-x-[5px]">
|
||||
<h1 className="line-clamp-1 text-[15px] font-light group-hover:text-[#ffbade]">
|
||||
{language === "EN" ? item?.title : item?.japanese_title}
|
||||
</h1>
|
||||
{isActive && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCirclePlay}
|
||||
className="w-[20px] h-[20px] text-[#ffbade]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Episodelist;
|
||||
21
src/components/error/Error.jsx
Normal file
21
src/components/error/Error.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FaChevronLeft } from "react-icons/fa"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
||||
function Error({ error }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="bg-[#201F31] w-full h-screen flex justify-center items-center">
|
||||
<div className="flex flex-col w-fit h-fit items-center justify-center">
|
||||
<img src="https://s1.gifyu.com/images/SBlOe.png" alt="" className="w-[300px] h-[300px] max-[500px]:w-[200px] max-[500px]:h-[200px]" />
|
||||
<h1 className="text-white text-[35px] leading-5 mt-7">{error === "404" ? "404 Error" : "Error"}</h1>
|
||||
<p className="mt-5">Oops! We couldn't find this page.</p>
|
||||
<button className="bg-[#ffbade] py-2 px-4 w-fit rounded-3xl text-black text-light flex items-center gap-x-2 mt-7">
|
||||
<FaChevronLeft className="text-[#ffbade] w-[20px] h-[20px] rounded-full p-1 bg-black" />
|
||||
<p onClick={() => navigate('/home')} className="text-[18px]">Back to homepage</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Error
|
||||
62
src/components/footer/Footer.jsx
Normal file
62
src/components/footer/Footer.jsx
Normal file
@@ -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 (
|
||||
<footer className="flex flex-col w-full mt-[100px] px-4 max-[500px]:px-0">
|
||||
<div
|
||||
style={{ borderBottom: "1px solid rgba(255, 255, 255, .075)" }}
|
||||
className="w-full text-left max-[500px]:hidden"
|
||||
>
|
||||
<img
|
||||
src="https://i.postimg.cc/SsKY6Y9f/2H76i57.png"
|
||||
alt={logoTitle}
|
||||
className="w-[200px] h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex py-5 flex-col w-full space-y-4 max-md:items-center max-[500px]:bg-[#373646]">
|
||||
<div className="flex w-fit items-center space-x-6 max-[500px]:hidden">
|
||||
<p className="text-2xl font-bold max-md:text-lg">A-Z LIST</p>
|
||||
<p
|
||||
style={{ borderLeft: "1px solid rgba(255, 255, 255, 0.6)" }}
|
||||
className="text-md font-semibold pl-6"
|
||||
>
|
||||
Searching anime order by alphabet name A to Z
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-x-[7px] flex-wrap justify-start gap-y-2 max-md:justify-start max-[500px]:hidden">
|
||||
{[
|
||||
"All",
|
||||
"#",
|
||||
"0-9",
|
||||
...Array.from({ length: 26 }, (_, i) =>
|
||||
String.fromCharCode(65 + i)
|
||||
),
|
||||
].map((item, index) => (
|
||||
<Link
|
||||
to={`az-list/${item === "All" ? "" : item}`}
|
||||
key={index}
|
||||
className="text-lg bg-[#373646] px-2 rounded-md font-bold hover:text-black hover:bg-[#FFBADE] hover:cursor-pointer transition-all ease-out"
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col w-full text-left space-y-2 pt-4 max-md:items-center max-[470px]:px-[5px]">
|
||||
<p className="text-[#9B9BA3] text-[16px] max-md:text-center max-md:text-[12px]">
|
||||
{website_name} does not host any files, it merely pulls streams from
|
||||
3rd party services. Legal issues should be taken up with the file
|
||||
hosts and providers. {website_name} is not responsible for any media
|
||||
files shown by the video providers.
|
||||
</p>
|
||||
<p className="text-[#9B9BA3] max-md:text-[14px]">
|
||||
© {website_name}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
54
src/components/genres/Genre.jsx
Normal file
54
src/components/genres/Genre.jsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col w-full">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade]">Genres</h1>
|
||||
<div className="bg-[#373646] py-6 px-4 mt-6 max-[478px]:bg-transparent max-[478px]:px-0">
|
||||
<div className="grid grid-cols-3 grid-rows-2 gap-x-4 gap-y-3 w-full max-[478px]:flex max-[478px]:flex-wrap max-[478px]:gap-2">
|
||||
{data &&
|
||||
(showAll ? data : data.slice(0, 24)).map((item, index) => {
|
||||
const textColor = colors[index % colors.length];
|
||||
return (
|
||||
<Link
|
||||
to={`/genre/${item}`}
|
||||
key={index}
|
||||
className="rounded-[4px] py-2 px-3 hover:bg-[#555462] hover:cursor-pointer max-[478px]:bg-[#373646] max-[478px]:py-[6px]"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<div className="overflow-hidden text-left text-ellipsis text-nowrap font-bold">
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="w-full bg-[#555462d3] py-3 mt-4 hover:bg-[#555462] rounded-md font-bold transform transition-all ease-out"
|
||||
onClick={toggleGenres}
|
||||
>
|
||||
{showAll ? "Show less" : "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Genre);
|
||||
147
src/components/navbar/Navbar.jsx
Normal file
147
src/components/navbar/Navbar.jsx
Normal file
@@ -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 (
|
||||
<SearchProvider>
|
||||
<nav
|
||||
className={`fixed top-0 left-0 w-full h-16 z-[1000000] flex p-4 py-8 items-center justify-between transition-all duration-300 ease-in-out ${
|
||||
isNotHomePage ? "bg-[#201F31]" : "bg-opacity-0"
|
||||
} ${
|
||||
isScrolled ? "bg-[#2D2B44] bg-opacity-90 backdrop-blur-md" : ""
|
||||
} max-[600px]:h-fit max-[600px]:flex-col max-[1200px]:bg-opacity-100 max-[600px]:py-2`}
|
||||
>
|
||||
<div className="flex gap-x-6 items-center w-fit max-lg:w-full max-lg:justify-between">
|
||||
<div className="flex gap-x-6 items-center w-fit">
|
||||
<FontAwesomeIcon
|
||||
icon={faBars}
|
||||
className="text-2xl text-white mt-1 cursor-pointer"
|
||||
onClick={handleHamburgerClick}
|
||||
/>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-4xl font-bold max-[575px]:text-3xl cursor-pointer"
|
||||
>
|
||||
{logoTitle.slice(0, 3)}
|
||||
<span className="text-[#FFBADE]">{logoTitle.slice(3, 4)}</span>
|
||||
{logoTitle.slice(4)}
|
||||
</Link>
|
||||
</div>
|
||||
<WebSearch />
|
||||
</div>
|
||||
<div className="flex gap-x-7 items-center max-lg:hidden">
|
||||
{[
|
||||
{ icon: faRandom, label: "Random", path: "/random" },
|
||||
{ icon: faFilm, label: "Movie", path: "/movie" },
|
||||
{ icon: faStar, label: "Popular", path: "/most-popular" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={
|
||||
item.path === "/random"
|
||||
? location.pathname === "/random"
|
||||
? "#"
|
||||
: "/random"
|
||||
: item.path
|
||||
}
|
||||
onClick={item.path === "/random" ? handleRandomClick : undefined}
|
||||
className="flex flex-col gap-y-1 items-center cursor-pointer"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={item.icon}
|
||||
className="text-[#ffbade] text-xl font-bold"
|
||||
/>
|
||||
<p className="text-[15px]">{item.label}</p>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col gap-y-1 items-center w-auto">
|
||||
<div className="flex">
|
||||
{["EN", "JP"].map((lang, index) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => toggleLanguage(lang)}
|
||||
className={`px-1 py-[1px] text-xs font-bold ${
|
||||
index === 0 ? "rounded-l-[3px]" : "rounded-r-[3px]"
|
||||
} ${
|
||||
language === lang
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-gray-600 text-white"
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="whitespace-nowrap text-[15px]">Anime name</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="https://t.me/zenime_discussion"
|
||||
className="flex flex-col gap-y-1 items-center cursor-pointer"
|
||||
>
|
||||
<FaTelegramPlane
|
||||
// icon={faTelegram}
|
||||
className="text-xl font-bold text-[#ffbade]"
|
||||
/>
|
||||
<p className="text-[15px] mb-[1px] text-white">Join Telegram</p>
|
||||
</Link>
|
||||
</div>
|
||||
<MobileSearch />
|
||||
</nav>
|
||||
<Sidebar isOpen={isSidebarOpen} onClose={handleCloseSidebar} />
|
||||
</SearchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
76
src/components/pageslider/PageSlider.jsx
Normal file
76
src/components/pageslider/PageSlider.jsx
Normal file
@@ -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) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handlePageChange(p)}
|
||||
className={`w-[40px] text-[15px] mx-1 flex justify-center items-center p-2 rounded-full font-bold ${page === p ? 'bg-[#ffbade] text-[#2B2A3C] cursor-default' : 'bg-[#2B2A3C] text-[#999] hover:text-[#ffbade]'} ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} `}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
));
|
||||
};
|
||||
return (
|
||||
<div className={`w-full flex ${start ? "justify-start" : "justify-center"} items-center mt-12 overflow-hidden`} style={style}>
|
||||
<div className="flex justify-center mt-4 w-fit">
|
||||
{page > 1 && totalPages > 2 && (
|
||||
<button
|
||||
onClick={() => handlePageChange(1)}
|
||||
className={`w-[40px] mx-1 p-2 ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} rounded-full text-[#999] text-[8px] hover:text-[#ffbade]`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDoubleLeft} />
|
||||
</button>
|
||||
)}
|
||||
{page > 1 && (
|
||||
<button
|
||||
onClick={() => { if (page > 0) handlePageChange(page - 1) }}
|
||||
className={`w-[40px] mx-1 p-2 ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} rounded-full text-[#999] text-[8px] hover:text-[#ffbade]`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</button>
|
||||
)}
|
||||
{renderPageNumbers()}
|
||||
{page < totalPages && (
|
||||
<button
|
||||
onClick={() => { if (page < totalPages) handlePageChange(page + 1) }}
|
||||
className={`w-[40px] mx-1 p-2 ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} rounded-full text-[#999] text-[8px] hover:text-[#ffbade]`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
)}
|
||||
{page < totalPages && totalPages > 2 && (
|
||||
<button
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
className={`w-[40px] mx-1 p-2 ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} rounded-full text-[#999] text-[8px] hover:text-[#ffbade]`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDoubleRight} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageSlider
|
||||
148
src/components/player/IframePlayer.jsx
Normal file
148
src/components/player/IframePlayer.jsx
Normal file
@@ -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 (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
{/* Loader Overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 flex justify-center items-center bg-black bg-opacity-50 z-10 transition-opacity duration-500 ${
|
||||
loading ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<BouncingLoader />
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
key={`${episodeId}-${servertype}-${serverName}-${iframeSrc}`}
|
||||
src={iframeSrc}
|
||||
allowFullScreen
|
||||
className={`w-full h-full transition-opacity duration-500 ${
|
||||
iframeLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => {
|
||||
setIframeLoaded(true);
|
||||
setTimeout(() => setLoading(false), 1000);
|
||||
}}
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/player/Player.css
Normal file
59
src/components/player/Player.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
494
src/components/player/Player.jsx
Normal file
494
src/components/player/Player.jsx
Normal file
@@ -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 <div ref={artRef} className="w-full h-full"></div>;
|
||||
}
|
||||
103
src/components/player/PlayerIcons.jsx
Normal file
103
src/components/player/PlayerIcons.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
const backward10Icon = `<svg viewBox="-5 -10 75 75" xmlns="http://www.w3.org/2000/svg" width="35" height="35">
|
||||
<path d="M11.9199 45H7.20508V26.5391L2.60645 28.3154V24.3975L11.4219 20.7949H11.9199V45ZM30.1013 35.0059C30.1013 38.3483 29.4926 40.9049 28.2751 42.6758C27.0687 44.4466 25.3422 45.332 23.0954 45.332C20.8708 45.332 19.1498 44.4743 17.9323 42.7588C16.726 41.0322 16.1006 38.5641 16.0564 35.3545V30.7891C16.0564 27.4577 16.6596 24.9121 17.8659 23.1523C19.0723 21.3815 20.8044 20.4961 23.0622 20.4961C25.32 20.4961 27.0521 21.3704 28.2585 23.1191C29.4649 24.8678 30.0792 27.3636 30.1013 30.6064V35.0059ZM25.3864 30.1084C25.3864 28.2048 25.1983 26.777 24.822 25.8252C24.4457 24.8734 23.8591 24.3975 23.0622 24.3975C21.5681 24.3975 20.7933 26.1406 20.738 29.627V35.6533C20.738 37.6012 20.9262 39.0511 21.3025 40.0029C21.6898 40.9548 22.2875 41.4307 23.0954 41.4307C23.8591 41.4307 24.4236 40.988 24.7888 40.1025C25.1651 39.2061 25.3643 37.8392 25.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M11.9894 5.45398V0L2 7.79529L11.9894 15.5914V10.3033H47.0886V40.1506H33.2442V45H52V5.45398H11.9894Z" fill="white"/>
|
||||
</svg>`;
|
||||
|
||||
const forward10Icon = `
|
||||
<svg viewBox="-5 -10 75 75" xmlns="http://www.w3.org/2000/svg" width="35" height="35">
|
||||
<path d="M29.9199 45H25.2051V26.5391L20.6064 28.3154V24.3975L29.4219 20.7949H29.9199V45ZM48.1013 35.0059C48.1013 38.3483 47.4926 40.9049 46.2751 42.6758C45.0687 44.4466 43.3422 45.332 41.0954 45.332C38.8708 45.332 37.1498 44.4743 35.9323 42.7588C34.726 41.0322 34.1006 38.5641 34.0564 35.3545V30.7891C34.0564 27.4577 34.6596 24.9121 35.8659 23.1523C37.0723 21.3815 38.8044 20.4961 41.0622 20.4961C43.32 20.4961 45.0521 21.3704 46.2585 23.1191C47.4649 24.8678 48.0792 27.3636 48.1013 30.6064V35.0059ZM43.3864 30.1084C43.3864 28.2048 43.1983 26.777 42.822 25.8252C42.4457 24.8734 41.8591 24.3975 41.0622 24.3975C39.5681 24.3975 38.7933 26.1406 38.738 29.627V35.6533C38.738 37.6012 38.9262 39.0511 39.3025 40.0029C39.6898 40.9548 40.2875 41.4307 41.0954 41.4307C41.8591 41.4307 42.4236 40.988 42.7888 40.1025C43.1651 39.2061 43.3643 37.8392 43.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M40.0106 5.45398V0L50 7.79529L40.0106 15.5914V10.3033H4.9114V40.1506H18.7558V45H2.01875e-06V5.45398H40.0106Z" fill="white"/>
|
||||
</svg>`;
|
||||
|
||||
const forwardIcon = `<svg viewBox="0 0 512 512" width="30" height="30">
|
||||
<path d="M500.5 231.4l-192-160C287.9 54.3 256 68.6 256 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2zm-256 0l-192-160C31.9 54.3 0 68.6 0 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2z"/>
|
||||
</svg>`;
|
||||
|
||||
const backwardIcon = `<svg viewBox="0 0 512 512" width="30" height="30" transform="scale(-1, 1)">
|
||||
<path d="M500.5 231.4l-192-160C287.9 54.3 256 68.6 256 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2zm-256 0l-192-160C31.9 54.3 0 68.6 0 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2z"/>
|
||||
</svg>`;
|
||||
|
||||
const volumeIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24"><path d="M116.5,42.8v154.4c0,2.8-1.7,3.6-3.8,1.7l-54.1-48H29c-2.8,0-5.2-2.3-5.2-5.2V94.3c0-2.8,2.3-5.2,5.2-5.2h29.6l54.1-48C114.8,39.2,116.5,39.9,116.5,42.8z"/><path d="M136.2,160v-20c11.1,0,20-8.9,20-20s-8.9-20-20-20V80c22.1,0,40,17.9,40,40S158.3,160,136.2,160z"/><path d="M216.2,120c0-44.2-35.8-80-80-80v20c33.1,0,60,26.9,60,60s-26.9,60-60,60v20C180.4,199.9,216.1,164.1,216.2,120z" fill="#fff"/></svg>`;
|
||||
|
||||
const muteIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24">
|
||||
<path d="M116.4,42.8v154.5c0,2.8-1.7,3.6-3.8,1.7l-54.1-48.1H28.9c-2.8,0-5.2-2.3-5.2-5.2V94.2c0-2.8,2.3-5.2,5.2-5.2h29.6l54.1-48.1C114.6,39.1,116.4,39.9,116.4,42.8z M212.3,96.4l-14.6-14.6l-23.6,23.6l-23.6-23.6l-14.6,14.6l23.6,23.6l-23.6,23.6l14.6,14.6l23.6-23.6l23.6,23.6l14.6-14.6L188.7,120L212.3,96.4z"
|
||||
fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const captionIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 16 240 240" width="28" height="28">
|
||||
<path d="M215,40H25c-2.7,0-5,2.2-5,5v150c0,2.7,2.2,5,5,5h190c2.7,0,5-2.2,5-5V45C220,42.2,217.8,40,215,40z M108.1,137.7c0.7-0.7,1.5-1.5,2.4-2.3l6.6,7.8c-2.2,2.4-5,4.4-8,5.8c-8,3.5-17.3,2.4-24.3-2.9c-3.9-3.6-5.9-8.7-5.5-14v-25.6c0-2.7,0.5-5.3,1.5-7.8c0.9-2.2,2.4-4.3,4.2-5.9c5.7-4.5,13.2-6.2,20.3-4.6c3.3,0.5,6.3,2,8.7,4.3c1.3,1.3,2.5,2.6,3.5,4.2l-7.1,6.9c-2.4-3.7-6.5-5.9-10.9-5.9c-2.4-0.2-4.8,0.7-6.6,2.3c-1.7,1.7-2.5,4.1-2.4,6.5v25.6C90.4,141.7,102,143.5,108.1,137.7z M152.9,137.7c0.7-0.7,1.5-1.5,2.4-2.3l6.6,7.8c-2.2,2.4-5,4.4-8,5.8c-8,3.5-17.3,2.4-24.3-2.9c-3.9-3.6-5.9-8.7-5.5-14v-25.6c0-2.7,0.5-5.3,1.5-7.8c0.9-2.2,2.4-4.3,4.2-5.9c5.7-4.5,13.2-6.2,20.3-4.6c3.3,0.5,6.3,2,8.7,4.3c1.3,1.3,2.5,2.6,3.5,4.2l-7.1,6.9c-2.4-3.7-6.5-5.9-10.9-5.9c-2.4-0.2-4.8,0.7-6.6,2.3c-1.7,1.7-2.5,4.1-2.4,6.5v25.6C135.2,141.7,146.8,143.5,152.9,137.7z"
|
||||
fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
const captionOffIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 16 240 240" width="28" height="28"><path d="M99.4,97.8c-2.4-0.2-4.8,0.7-6.6,2.3c-1.7,1.7-2.5,4.1-2.4,6.5v25.6c0,9.6,11.6,11.4,17.7,5.5c0.7-0.7,1.5-1.5,2.4-2.3l6.6,7.8c-2.2,2.4-5,4.4-8,5.8c-8,3.5-17.3,2.4-24.3-2.9c-3.9-3.6-5.9-8.7-5.5-14v-25.6c0-2.7,0.5-5.3,1.5-7.8c0.9-2.2,2.4-4.3,4.2-5.9c5.7-4.5,13.2-6.2,20.3-4.6c3.3,0.5,6.3,2,8.7,4.3c1.3,1.3,2.5,2.6,3.5,4.2l-7.1,6.9C107.9,100,103.8,97.8,99.4,97.8z M144.1,97.8c-2.4-0.2-4.8,0.7-6.6,2.3c-1.7,1.7-2.5,4.1-2.4,6.5v25.6c0,9.6,11.6,11.4,17.7,5.5c0.7-0.7,1.5-1.5,2.4-2.3l6.6,7.8c-2.2,2.4-5,4.4-8,5.8c-8,3.5-17.3,2.4-24.3-2.9c-3.9-3.6-5.9-8.7-5.5-14v-25.6c0-2.7,0.5-5.3,1.5-7.8c0.9-2.2,2.4-4.3,4.2-5.9c5.7-4.5,13.2-6.2,20.3-4.6c3.3,0.5,6.3,2,8.7,4.3c1.3,1.3,2.5,2.6,3.5,4.2l-7.1,6.9C152.6,100,148.5,97.8,144.1,97.8L144.1,97.8z M200,60v120H40V60H200 M215,40H25c-2.7,0-5,2.2-5,5v150c0,2.7,2.2,5,5,5h190c2.7,0,5-2.2,5-5V45C220,42.2,217.8,40,215,40z" fill="#fff"/></svg>`;
|
||||
|
||||
const pipOffIcon = `<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20 5.75V9.75H22V4.78C22 4.21116 21.5389 3.75 20.97 3.75H2.03C1.46116 3.75 1 4.21113 1 4.78V17.72C1 18.2889 1.46119 18.75 2.03 18.75H12V16.75H3V5.75H20ZM14 13.25C14 12.6977 14.4477 12.25 15 12.25H22C22.5523 12.25 23 12.6977 23 13.25V19.25C23 19.8023 22.5523 20.25 22 20.25H15C14.4477 20.25 14 19.8023 14 19.25V13.25ZM10 9.25L8.20711 11.0429L10.7071 13.5429L9.29289 14.9571L6.79289 12.4571L5 14.25V9.25H10Z" fill="#fff"/>
|
||||
</svg>`;
|
||||
|
||||
const loadingIcon = `<svg width="80" height="80" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_l9ve{animation:spinner_rcyq 1.2s cubic-bezier(0.52,.6,.25,.99) infinite}.spinner_cMYp{animation-delay:.4s}.spinner_gHR3{animation-delay:.8s}@keyframes spinner_rcyq{0%{transform:translate(12px,12px) scale(0);opacity:1}100%{transform:translate(0,0) scale(1);opacity:0}}</style><path class="spinner_l9ve" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" transform="translate(12, 12) scale(0)"/><path class="spinner_l9ve spinner_cMYp" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" transform="translate(12, 12) scale(0)"/><path class="spinner_l9ve spinner_gHR3" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" transform="translate(12, 12) scale(0)"/></svg>`;
|
||||
|
||||
const pipIcon = `<svg width="24" height="24" viewBox="0 0 24 24" style="margin-bottom: 3px; vertical-align: middle;" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 5.125V9.125H22V4.155C22 3.58616 21.5389 3.125 20.97 3.125H2.03C1.46116 3.125 1 3.58613 1 4.155V17.095C1 17.6639 1.46119 18.125 2.03 18.125H12V16.125H3V5.125H20ZM14 11.875C14 11.3227 14.4477 10.875 15 10.875H22C22.5523 10.875 23 11.3227 23 11.875V17.875C23 18.4273 22.5523 18.875 22 18.875H15C14.4477 18.875 14 18.4273 14 17.875V11.875ZM6 12.375L7.79289 10.5821L5.29288 8.0821L6.7071 6.66788L9.20711 9.16789L11 7.375V12.375H6Z" fill="white"/>
|
||||
</svg>`;
|
||||
|
||||
const playIconLg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="80" height="80"><path d="M62.8,199.5c-1,0.8-2.4,0.6-3.3-0.4c-0.4-0.5-0.6-1.1-0.5-1.8V42.6c-0.2-1.3,0.7-2.4,1.9-2.6c0.7-0.1,1.3,0.1,1.9,0.4l154.7,77.7c2.1,1.1,2.1,2.8,0,3.8L62.8,199.5z" fill="white"/></svg>`;
|
||||
|
||||
const playIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24"><path d="M62.8,199.5c-1,0.8-2.4,0.6-3.3-0.4c-0.4-0.5-0.6-1.1-0.5-1.8V42.6c-0.2-1.3,0.7-2.4,1.9-2.6c0.7-0.1,1.3,0.1,1.9,0.4l154.7,77.7c2.1,1.1,2.1,2.8,0,3.8L62.8,199.5z"/></svg>`;
|
||||
|
||||
const pauseIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24"><path d="M100,194.9c0.2,2.6-1.8,4.8-4.4,5c-0.2,0-0.4,0-0.6,0H65c-2.6,0.2-4.8-1.8-5-4.4c0-0.2,0-0.4,0-0.6V45c-0.2-2.6,1.8-4.8,4.4-5c0.2,0,0.4,0,0.6,0h30c2.6-0.2,4.8,1.8,5,4.4c0,0.2,0,0.4,0,0.6V194.9z M180,45.1c0.2-2.6-1.8-4.8-4.4-5c-0.2,0-0.4,0-0.6,0h-30c-2.6-0.2-4.8,1.8-5,4.4c0,0.2,0,0.4,0,0.6V195c-0.2,2.6,1.8,4.8,4.4,5c0.2,0,0.4,0,0.6,0h30c2.6,0.2,4.8-1.8,5-4.4c0-0.2,0-0.4,0-0.6V45.1z"/></svg>`;
|
||||
|
||||
const uploadIcon = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
style="vertical-align: middle;">
|
||||
<path fill-rule="evenodd" d="M8 0a5.53 5.53 0 0 0-3.594 1.342c-.766.66-1.321 1.52-1.464 2.383C1.266 4.095 0 5.555 0 7.318 0 9.366 1.708 11 3.781 11H7.5V5.707L5.354 7.854a.5.5 0 1 1-.708-.708l3-3a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 5.707V11h4.188C14.502 11 16 9.57 16 7.773c0-1.636-1.242-2.969-2.834-3.194C12.923 1.999 10.69 0 8 0m-.5 14.5V11h1v3.5a.5.5 0 0 1-1 0"/>
|
||||
</svg>`;
|
||||
|
||||
const settingsIcon = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 240 240"
|
||||
width="24"
|
||||
height="24"
|
||||
style="margin-bottom: 5px; vertical-align: middle;">
|
||||
<path d="M204,145l-25-14c0.8-3.6,1.2-7.3,1-11c0.2-3.7-0.2-7.4-1-11l25-14c2.2-1.6,3.1-4.5,2-7l-16-26c-1.2-2.1-3.8-2.9-6-2l-25,14c-6-4.2-12.3-7.9-19-11V35c0.2-2.6-1.8-4.8-4.4-5c-0.2,0-0.4,0-0.6,0h-30c-2.6-0.2-4.8,1.8-5,4.4c0,0.2,0,0.4,0,0.6v28c-6.7,3.1-13,6.7-19,11L56,60c-2.2-0.9-4.8-0.1-6,2L35,88c-1.6,2.2-1.3,5.3,0.9,6.9c0,0,0.1,0,0.1,0.1l25,14c-0.8,3.6-1.2,7.3-1,11c-0.2,3.7,0.2,7.4,1,11l-25,14c-2.2,1.6-3.1,4.5-2,7l16,26c1.2,2.1,3.8,2.9,6,2l25-14c5.7,4.6,12.2,8.3,19,11v28c-0.2,2.6,1.8,4.8,4.4,5c0.2,0,0.4,0,0.6,0h30c2.6,0.2,4.8-1.8,5-4.4c0-0.2,0-0.4,0-0.6v-28c7-2.3,13.5-6,19-11l25,14c2.5,1.3,5.6,0.4,7-2l15-26C206.7,149.4,206,146.7,204,145z M120,149.9c-16.5,0-30-13.4-30-30s13.4-30,30-30s30,13.4,30,30c0.3,16.3-12.6,29.7-28.9,30C120.7,149.9,120.4,149.9,120,149.9z"/>
|
||||
</svg>`;
|
||||
|
||||
const fullScreenOnIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24" style="margin-bottom: 5px; vertical-align: middle;"><path d="M96.3,186.1c1.9,1.9,1.3,4-1.4,4.4l-50.6,8.4c-1.8,0.5-3.7-0.6-4.2-2.4c-0.2-0.6-0.2-1.2,0-1.7l8.4-50.6c0.4-2.7,2.4-3.4,4.4-1.4l14.5,14.5l28.2-28.2l14.3,14.3l-28.2,28.2L96.3,186.1z M195.8,39.1l-50.6,8.4c-2.7,0.4-3.4,2.4-1.4,4.4l14.5,14.5l-28.2,28.2l14.3,14.3l28.2-28.2l14.5,14.5c1.9,1.9,4,1.3,4.4-1.4l8.4-50.6c0.5-1.8-0.6-3.6-2.4-4.2C197,39,196.4,39,195.8,39.1L195.8,39.1z" fill="#fff"/></svg>`;
|
||||
|
||||
const fullScreenOffIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240"width="24" height="24" style="margin-bottom: 5px; vertical-align: middle;"><path d="M109.2,134.9l-8.4,50.1c-0.4,2.7-2.4,3.3-4.4,1.4L82,172l-27.9,27.9l-14.2-14.2l27.9-27.9l-14.4-14.4c-1.9-1.9-1.3-3.9,1.4-4.4l50.1-8.4c1.8-0.5,3.6,0.6,4.1,2.4C109.4,133.7,109.4,134.3,109.2,134.9L109.2,134.9z M172.1,82.1L200,54.2L185.8,40l-27.9,27.9l-14.4-14.4c-1.9-1.9-3.9-1.3-4.4,1.4l-8.4,50.1c-0.5,1.8,0.6,3.6,2.4,4.1c0.5,0.2,1.2,0.2,1.7,0l50.1-8.4c2.7-0.4,3.3-2.4,1.4-4.4L172.1,82.1z"/></svg>`;
|
||||
|
||||
const logo = `<p style="display: flex; gap: 7px; align-items: center; background-color:#1F2020; padding:5px;padding-inline:7px; border-radius:5px">
|
||||
<b style="color: #ffbade;">Powered by</b>
|
||||
<span style="font-size: 14px;">
|
||||
Zen<span style="color: #ffbade;">!</span>me
|
||||
</span>
|
||||
</p>
|
||||
`;
|
||||
|
||||
export {
|
||||
backward10Icon,
|
||||
forward10Icon,
|
||||
backwardIcon,
|
||||
forwardIcon,
|
||||
playIcon,
|
||||
playIconLg,
|
||||
pauseIcon,
|
||||
loadingIcon,
|
||||
uploadIcon,
|
||||
settingsIcon,
|
||||
pipIcon,
|
||||
pipOffIcon,
|
||||
volumeIcon,
|
||||
muteIcon,
|
||||
captionIcon,
|
||||
captionOffIcon,
|
||||
fullScreenOnIcon,
|
||||
fullScreenOffIcon,
|
||||
logo,
|
||||
};
|
||||
72
src/components/player/artPlayerPluginVttThumbnail.js
Normal file
72
src/components/player/artPlayerPluginVttThumbnail.js
Normal file
@@ -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",
|
||||
};
|
||||
};
|
||||
}
|
||||
211
src/components/player/artPlayerPluinChaper.js
Normal file
211
src/components/player/artPlayerPluinChaper.js
Normal file
@@ -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 = `
|
||||
<div class="art-chapter">
|
||||
<div class="art-chapter-inner">
|
||||
<div class="art-progress-hover"></div>
|
||||
<div class="art-progress-loaded"></div>
|
||||
<div class="art-progress-played"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let titleTimer = null;
|
||||
let $chapters = [];
|
||||
|
||||
const $progress = art.query(".art-control-progress");
|
||||
const $inner = art.query(".art-control-progress-inner");
|
||||
const $control = append($inner, '<div class="art-chapters"></div>');
|
||||
const $title = append($inner, '<div class="art-chapter-title"></div>');
|
||||
|
||||
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;
|
||||
}
|
||||
49
src/components/player/artplayerPluginUploadSubtitle.js
Normal file
49
src/components/player/artplayerPluginUploadSubtitle.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { uploadIcon } from "./PlayerIcons";
|
||||
|
||||
export default function artplayerPluginUploadSubtitle() {
|
||||
return (art) => {
|
||||
const { getExt } = art.constructor.utils;
|
||||
|
||||
art.setting.add({
|
||||
html: `
|
||||
<div class="subtitle-upload-wrapper" style="position: relative;">
|
||||
<input
|
||||
type="file"
|
||||
name="subtitle-upload"
|
||||
id="subtitle-upload"
|
||||
style="display: none;"
|
||||
/>
|
||||
<label
|
||||
for="subtitle-upload"
|
||||
class="subtitle-upload-label"
|
||||
style="cursor: pointer; user-select: none;"
|
||||
>
|
||||
Upload Subtitle
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
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;
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
74
src/components/player/autoSkip.js
Normal file
74
src/components/player/autoSkip.js
Normal file
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
82
src/components/player/getChapterStyle.js
Normal file
82
src/components/player/getChapterStyle.js
Normal file
@@ -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;
|
||||
}
|
||||
101
src/components/player/getVttArray.js
Normal file
101
src/components/player/getVttArray.js
Normal file
@@ -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;
|
||||
}
|
||||
55
src/components/player/pluginChapterStyle.js
Normal file
55
src/components/player/pluginChapterStyle.js
Normal file
@@ -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);
|
||||
}
|
||||
`;
|
||||
102
src/components/producer/Producer.jsx
Normal file
102
src/components/producer/Producer.jsx
Normal file
@@ -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 <Loader type="producer" />;
|
||||
if (error) {
|
||||
navigate("/error-page");
|
||||
return <Error />;
|
||||
}
|
||||
if (!producerInfo) {
|
||||
navigate("/404-not-found-page");
|
||||
return null;
|
||||
}
|
||||
const handlePageChange = (newPage) => {
|
||||
setSearchParams({ page: newPage });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-y-4 mt-[100px] max-md:mt-[50px]">
|
||||
{producerInfo ? (
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex max-[1200px]:flex-col max-[1200px]:gap-y-10">
|
||||
{page > totalPages ? (
|
||||
<p className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px] max-[300px]:leading-6">
|
||||
You came a long way, go back <br className="max-[300px]:hidden" />
|
||||
nothing is here
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{producerInfo && (
|
||||
<CategoryCard
|
||||
label={
|
||||
(id.charAt(0).toUpperCase() + id.slice(1))
|
||||
.split("-")
|
||||
.join(" ") + " Anime"
|
||||
}
|
||||
data={producerInfo}
|
||||
showViewMore={false}
|
||||
className={"mt-0"}
|
||||
categoryPage={true}
|
||||
/>
|
||||
)}
|
||||
<PageSlider
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-y-10">
|
||||
{homeInfoLoading ? (
|
||||
<SidecardLoader />
|
||||
) : (
|
||||
<>
|
||||
{homeInfo && homeInfo.topten && (
|
||||
<Topten data={homeInfo.topten} className="mt-0" />
|
||||
)}
|
||||
{homeInfo?.genres && <Genre data={homeInfo.genres} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Error />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default Producer;
|
||||
159
src/components/qtip/Qtip.jsx
Normal file
159
src/components/qtip/Qtip.jsx
Normal file
@@ -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 (
|
||||
<div className="w-[320px] h-fit rounded-xl p-4 flex justify-center items-center bg-[#3e3c50] bg-opacity-70 backdrop-blur-[10px] z-50">
|
||||
{loading || error || !qtip ? (
|
||||
<BouncingLoader />
|
||||
) : (
|
||||
<div className="w-full flex flex-col justify-start gap-y-2">
|
||||
<h1 className="text-xl font-semibold text-white text-[13px] leading-6">
|
||||
{qtip.title}
|
||||
</h1>
|
||||
<div className="w-full flex items-center relative mt-2">
|
||||
{qtip?.rating && (
|
||||
<div className="flex gap-x-2 items-center">
|
||||
<FontAwesomeIcon icon={faStar} className="text-[#ffc107]" />
|
||||
<p className="text-[#b7b7b8]">{qtip.rating}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex ml-4 gap-x-[1px] overflow-hidden rounded-md items-center h-fit">
|
||||
{qtip?.quality && (
|
||||
<div className="bg-[#ffbade] px-[7px] w-fit flex justify-center items-center py-[1px] text-black">
|
||||
<p className="text-[12px] font-semibold">{qtip.quality}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-x-[1px] w-fit items-center py-[1px]">
|
||||
{qtip?.subCount && (
|
||||
<div className="flex gap-x-1 justify-center items-center bg-[#B0E3AF] px-[7px] text-black">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
<p className="text-[13px] font-semibold">{qtip.subCount}</p>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.dubCount && (
|
||||
<div className="flex gap-x-1 justify-center items-center bg-[#B9E7FF] px-[7px] text-black">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
<p className="text-[13px] font-semibold">{qtip.dubCount}</p>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.episodeCount && (
|
||||
<div className="flex gap-x-1 justify-center items-center bg-[#a199a3] px-[7px] text-black">
|
||||
<p className="text-[13px] font-semibold">
|
||||
{qtip.episodeCount}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{qtip?.type && (
|
||||
<div className="absolute right-0 top-0 justify-center items-center rounded-sm bg-[#ffbade] px-[6px] text-black">
|
||||
<p className="font-semibold text-[13px]">{qtip.type}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{qtip?.description && (
|
||||
<p className="text-[#d7d7d8] text-[13px] leading-4 font-light line-clamp-3 mt-1">
|
||||
{qtip.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col mt-1">
|
||||
{qtip?.japaneseTitle && (
|
||||
<div className="leading-4">
|
||||
<span className="text-[#b7b7b8] text-[13px]">
|
||||
Japanese:
|
||||
</span>
|
||||
<span className="text-[13px]">{qtip.japaneseTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.Synonyms && (
|
||||
<div className="leading-4">
|
||||
<span className="text-[#b7b7b8] text-[13px]">
|
||||
Synonyms:
|
||||
</span>
|
||||
<span className="text-[13px]">{qtip.Synonyms}</span>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.airedDate && (
|
||||
<div className="leading-4">
|
||||
<span className="text-[#b7b7b8] text-[13px]">Aired: </span>
|
||||
<span className="text-[13px]">{qtip.airedDate}</span>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.status && (
|
||||
<div className="leading-4">
|
||||
<span className="text-[#b7b7b8] text-[13px]">
|
||||
Status:
|
||||
</span>
|
||||
<span className="text-[13px]">{qtip.status}</span>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.genres && (
|
||||
<div className="leading-4 flex flex-wrap text-wrap">
|
||||
<span className="text-[#b7b7b8] text-[13px]">
|
||||
Genres:
|
||||
</span>
|
||||
{qtip.genres.map((genre, index) => (
|
||||
<Link
|
||||
to={`/genre/${genre}`}
|
||||
key={index}
|
||||
className="text-[13px] hover:text-[#ffbade]"
|
||||
>
|
||||
<span>
|
||||
{genre}
|
||||
{index === qtip.genres.length - 1 ? "" : ","}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={qtip.watchLink}
|
||||
className="w-[80%] flex mt-4 justify-center items-center gap-x-2 bg-[#ffbade] py-[9px] rounded-3xl"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlay} className="text-[14px] text-black" />
|
||||
<p className="text-[14px] font-semibold text-black">Watch Now</p>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Qtip;
|
||||
241
src/components/schedule/Schedule.jsx
Normal file
241
src/components/schedule/Schedule.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div className="w-full mt-[60px] max-[480px]:mt-[40px]">
|
||||
<div className="flex items-center justify-between max-[570px]:flex-col max-[570px]:items-start max-[570px]:gap-y-2">
|
||||
<div className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px]">
|
||||
Estimated Schedule
|
||||
</div>
|
||||
<p className="leading-[28px] px-[10px] bg-white text-black rounded-full my-[6px] text-[16px] font-bold max-[478px]:text-[12px] max-[275px]:text-[10px]">
|
||||
({GMTOffset}) {currentTime.toLocaleDateString()}{" "}
|
||||
{currentTime.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full overflow-x-scroll space-x-4 scrollbar-hide pt-10 px-6 max-[480px]:px-4 max-[478px]:pt-4">
|
||||
<div className="relative w-full">
|
||||
<Swiper
|
||||
slidesPerView={3}
|
||||
spaceBetween={2}
|
||||
breakpoints={{
|
||||
250: { slidesPerView: 3, spaceBetween: 10 },
|
||||
640: { slidesPerView: 4, spaceBetween: 10 },
|
||||
768: { slidesPerView: 5, spaceBetween: 10 },
|
||||
1024: { slidesPerView: 7, spaceBetween: 10 },
|
||||
1300: { slidesPerView: 7, spaceBetween: 15 },
|
||||
}}
|
||||
modules={[Pagination, Navigation]}
|
||||
navigation={{
|
||||
nextEl: ".next",
|
||||
prevEl: ".prev",
|
||||
}}
|
||||
onSwiper={(swiper) => (swiperRef.current = swiper)}
|
||||
>
|
||||
{dates &&
|
||||
dates.map((date, index) => (
|
||||
<SwiperSlide key={index}>
|
||||
<div
|
||||
ref={(el) => (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"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[18px] font-bold max-[400px]:text-[14px] max-[350px]:text-[12px]">
|
||||
{date.dayname}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[14px] max-[400px]:text-[12px] ${
|
||||
currentActiveIndex === index
|
||||
? "text-black"
|
||||
: "text-gray-400"
|
||||
} max-[350px]:text-[10px]`}
|
||||
>
|
||||
{date.monthName} {date.day}
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
<button className="next absolute top-1/2 right-[-15px] transform -translate-y-1/2 flex justify-center items-center cursor-pointer">
|
||||
<FaChevronRight className="text-[12px]" />
|
||||
</button>
|
||||
<button className="prev absolute top-1/2 left-[-15px] transform -translate-y-1/2 flex justify-center items-center cursor-pointer">
|
||||
<FaChevronLeft className="text-[12px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="w-full h-[70px] flex justify-center items-center">
|
||||
<BouncingLoader />
|
||||
</div>
|
||||
) : !scheduleData || scheduleData.length === 0 ? (
|
||||
<div className="w-full h-[70px] flex justify-center items-center mt-5 text-xl">
|
||||
No data to display
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="w-full h-[70px] flex justify-center items-center mt-5 text-xl">
|
||||
Something went wrong
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col mt-5 items-start">
|
||||
{(showAll
|
||||
? scheduleData
|
||||
: Array.isArray(scheduleData)
|
||||
? scheduleData.slice(0, 7)
|
||||
: []
|
||||
).map((item, idx) => (
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
key={idx}
|
||||
className="w-full flex justify-between py-4 border-[#FFFFFF0D] border-b-[1px] group cursor-pointer max-[325px]:py-2"
|
||||
>
|
||||
<div className="flex items-center max-w-[500px] gap-x-7 max-[400px]:gap-x-2">
|
||||
<div className="text-lg font-semibold text-[#ffffff59] group-hover:text-[#ffbade] transition-all duration-300 ease-in-out max-[600px]:text-[14px] max-[275px]:text-[12px]">
|
||||
{item.time || "N/A"}
|
||||
</div>
|
||||
<h3 className="text-[17px] font-semibold line-clamp-1 group-hover:text-[#ffbade] transition-all duration-300 ease-in-out max-[600px]:text-[14px] max-[275px]:text-[12px]">
|
||||
{item.title || "N/A"}
|
||||
</h3>
|
||||
</div>
|
||||
<button className="max-w-[150px] flex items-center py-1 px-4 rounded-lg gap-x-2 group-hover:bg-[#ffbade] transition-all duration-300 ease-in-out">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="mt-[1px] text-[10px] max-[320px]:text-[8px] group-hover:text-black transition-all duration-300 ease-in-out"
|
||||
/>
|
||||
<p className="text-[14px] text-white group-hover:text-black transition-all duration-300 ease-in-out max-[275px]:text-[12px]">
|
||||
Episode {item.episode_no || "N/A"}
|
||||
</p>
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
{scheduleData.length > 7 && (
|
||||
<button
|
||||
onClick={toggleShowAll}
|
||||
className="text-white py-4 hover:text-[#ffbade] font-semibold transition-all duration-300 ease-in-out max-sm:text-[13px]"
|
||||
>
|
||||
{showAll ? "Show Less" : "Show More"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Schedule;
|
||||
11
src/components/schedule/schedule.css
Normal file
11
src/components/schedule/schedule.css
Normal file
@@ -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;
|
||||
}
|
||||
73
src/components/searchbar/MobileSearch.jsx
Normal file
73
src/components/searchbar/MobileSearch.jsx
Normal file
@@ -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 && (
|
||||
<div className="flex w-full mt-2 relative custom-md:hidden ">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-white px-4 py-2 text-black focus:outline-none w-full rounded-l-md"
|
||||
placeholder="Search anime..."
|
||||
value={searchValue}
|
||||
onChange={(e) => 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="flex items-center justify-center p-2 bg-white rounded-r-md"
|
||||
onClick={handleSearchClick}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className="text-black text-lg"
|
||||
/>
|
||||
</button>
|
||||
{searchValue.trim() && isFocused && (
|
||||
<div
|
||||
ref={addSuggestionRef}
|
||||
className="absolute z-[100000] top-full w-full"
|
||||
>
|
||||
<Suggestion keyword={debouncedValue} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileSearch;
|
||||
77
src/components/searchbar/WebSearch.jsx
Normal file
77
src/components/searchbar/WebSearch.jsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center relative w-[380px] max-[600px]:w-fit">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-white px-4 py-2 text-black focus:outline-none w-full max-[600px]:hidden"
|
||||
placeholder="Search anime..."
|
||||
value={searchValue}
|
||||
onChange={(e) => 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)}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="bg-white p-2 max-[600px]:bg-transparent focus:outline-none max-[600px]:p-0"
|
||||
onClick={handleSearchClick}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className="text-lg text-black hover:text-[#ffbade] max-[600px]:text-white max-[600px]:text-2xl max-[575px]:text-xl max-[600px]:mt-[7px]"
|
||||
/>
|
||||
</button>
|
||||
{searchValue.trim() && isFocused && (
|
||||
<div
|
||||
ref={addSuggestionRef}
|
||||
className="absolute z-[100000] top-full w-full"
|
||||
>
|
||||
<Suggestion keyword={debouncedValue} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebSearch;
|
||||
9
src/components/servers/Servers.css
Normal file
9
src/components/servers/Servers.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.servers {
|
||||
border-bottom: 1px dashed #35373d;
|
||||
}
|
||||
.servers:only-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.servers:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
187
src/components/servers/Servers.jsx
Normal file
187
src/components/servers/Servers.jsx
Normal file
@@ -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 (
|
||||
<div className="relative bg-[#11101A] p-4 w-full min-h-[100px] flex justify-center items-center max-[1200px]:bg-[#14151A]">
|
||||
{serverLoading ? (
|
||||
<div className="w-full h-full rounded-lg flex justify-center items-center max-[600px]:rounded-none">
|
||||
<BouncingLoader />
|
||||
</div>
|
||||
) : servers ? (
|
||||
<div className="w-full h-full rounded-lg grid grid-cols-[minmax(0,30%),minmax(0,70%)] overflow-hidden max-[800px]:grid-cols-[minmax(0,40%),minmax(0,60%)] max-[600px]:flex max-[600px]:flex-col max-[600px]:rounded-none">
|
||||
<div className="h-full bg-[#ffbade] px-6 text-black flex flex-col justify-center items-center gap-y-2 max-[600px]:bg-transparent max-[600px]:h-1/2 max-[600px]:text-white max-[600px]:mb-4">
|
||||
<p className="text-center leading-5 font-medium text-[14px]">
|
||||
You are watching <br />
|
||||
<span className="font-semibold max-[600px]:text-[#ffbade]">
|
||||
Episode {activeEpisodeNum}
|
||||
</span>
|
||||
</p>
|
||||
<p className="leading-5 text-[14px] font-medium text-center">
|
||||
If the current server doesn't work, please try other servers
|
||||
beside.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#201F31] flex flex-col max-[600px]:h-full">
|
||||
{rawServers.length > 0 && (
|
||||
<div
|
||||
className={`servers px-2 flex items-center flex-wrap ml-2 max-[600px]:py-2 ${
|
||||
dubServers.length === 0 || subServers.length === 0
|
||||
? "h-1/2"
|
||||
: "h-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faFile}
|
||||
className="text-[#ffbade] text-[13px]"
|
||||
/>
|
||||
<p className="font-bold text-[14px]">RAW:</p>
|
||||
</div>
|
||||
<div className="flex gap-x-[7px] ml-8 flex-wrap">
|
||||
{rawServers.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`px-6 py-[5px] rounded-lg cursor-pointer ${
|
||||
activeServerId === item?.data_id
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-[#373646] text-white"
|
||||
} max-[700px]:px-3`}
|
||||
onClick={() => handleServerSelect(item)}
|
||||
>
|
||||
<p className="text-[13px] font-semibold">
|
||||
{item.serverName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subServers.length > 0 && (
|
||||
<div
|
||||
className={`servers px-2 flex items-center flex-wrap ml-2 max-[600px]:py-2 ${
|
||||
dubServers.length === 0 ? "h-1/2" : "h-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[#ffbade] text-[13px]"
|
||||
/>
|
||||
<p className="font-bold text-[14px]">SUB:</p>
|
||||
</div>
|
||||
<div className="flex gap-x-[7px] ml-8 flex-wrap">
|
||||
{subServers.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`px-6 py-[5px] rounded-lg cursor-pointer ${
|
||||
activeServerId === item?.data_id
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-[#373646] text-white"
|
||||
} max-[700px]:px-3`}
|
||||
onClick={() => handleServerSelect(item)}
|
||||
>
|
||||
<p className="text-[13px] font-semibold">
|
||||
{item.serverName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dubServers.length > 0 && (
|
||||
<div
|
||||
className={`servers px-2 flex items-center flex-wrap ml-2 max-[600px]:py-2 ${
|
||||
subServers.length === 0 ? "h-1/2 " : "h-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[#ffbade] text-[13px]"
|
||||
/>
|
||||
<p className="font-bold text-[14px]">DUB:</p>
|
||||
</div>
|
||||
<div className="flex gap-x-[7px] ml-8 flex-wrap">
|
||||
{dubServers.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`px-6 py-[5px] rounded-lg cursor-pointer ${
|
||||
activeServerId === item?.data_id
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-[#373646] text-white"
|
||||
} max-[700px]:px-3`}
|
||||
onClick={() => handleServerSelect(item)}
|
||||
>
|
||||
<p className="text-[13px] font-semibold">
|
||||
{item.serverName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center font-medium text-[15px] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10 pointer-events-none">
|
||||
Could not load servers <br />
|
||||
Either reload or try again after sometime
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Servers;
|
||||
141
src/components/sidebar/Sidebar.jsx
Normal file
141
src/components/sidebar/Sidebar.jsx
Normal file
@@ -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 && (
|
||||
<div
|
||||
className={`fixed top-0 left-0 bottom-0 right-0 w-screen h-screen transform transition-all duration-400 ease-in-out ${
|
||||
isOpen ? "backdrop-blur-lg" : "backdrop-blur-none"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
style={{ zIndex: 1000000, background: "rgba(32, 31, 49, .8)" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`fixed h-full top-0 left-0 z-50 flex transition-transform duration-300 ease-in-out ${
|
||||
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
style={{ zIndex: 1000200 }}
|
||||
>
|
||||
<div
|
||||
className="bg-white/10 w-[260px] py-8 h-full flex flex-col items-start max-[575px]:w-56 overflow-y-auto sidebar"
|
||||
style={{
|
||||
zIndex: 300,
|
||||
borderRight: "1px solid rgba(0, 0, 0, .1)",
|
||||
}}
|
||||
>
|
||||
<div className="px-4 w-full">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full text-white flex items-baseline h-fit gap-x-1 z-[100] px-3 py-2 bg-[#4f4d6e] rounded-3xl"
|
||||
>
|
||||
<FaChevronLeft className="text-sm font-bold" />
|
||||
<p>Close menu</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-x-7 w-full py-3 justify-center px-auto mt-8 bg-black/10 max-[575px]:gap-x-4 lg:hidden">
|
||||
{[
|
||||
{ icon: faRandom, label: "Random" },
|
||||
{ icon: faFilm, label: "Movie" },
|
||||
].map((item, index) => (
|
||||
<Link
|
||||
to={`/${item.label}`}
|
||||
key={index}
|
||||
className="flex flex-col gap-y-1 items-center"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={item.icon}
|
||||
className="text-[#ffbade] text-xl font-bold max-[575px]:text-[15px]"
|
||||
/>
|
||||
<p className="text-[15px] max-[575px]:text-[13px]">
|
||||
{item.label}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col gap-y-1 items-center w-auto justify-center">
|
||||
<div className="flex">
|
||||
{["EN", "JP"].map((lang, index) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => toggleLanguage(lang)}
|
||||
className={`px-1 py-[1px] text-xs font-bold ${
|
||||
index === 0 ? "rounded-l-[3px]" : "rounded-r-[3px]"
|
||||
} ${
|
||||
language === lang
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-gray-600 text-white"
|
||||
} max-[575px]:text-[9px] max-[575px]:py-0`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="whitespace-nowrap text-[15px] max-[575px]:text-[13px]">
|
||||
Anime name
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="text-white mt-8 w-full">
|
||||
{[
|
||||
{ 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) => (
|
||||
<li
|
||||
key={index}
|
||||
className="py-4 w-full font-semibold"
|
||||
style={{ borderBottom: "1px solid rgba(255, 255, 255, .08)" }}
|
||||
>
|
||||
<Link
|
||||
to={item.path}
|
||||
className="px-4 hover:text-[#ffbade] hover:cursor-pointer w-fit line-clamp-1"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
142
src/components/sidecard/Sidecard.jsx
Normal file
142
src/components/sidecard/Sidecard.jsx
Normal file
@@ -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 (
|
||||
<div className={`flex flex-col space-y-6 ${className}`}>
|
||||
<h1 className="font-bold text-2xl text-[#ffbade]">{label}</h1>
|
||||
<div className="flex flex-col space-y-4 bg-[#2B2A3C] p-4 pt-8">
|
||||
{data &&
|
||||
displayedData.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-x-4"
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderBottom:
|
||||
index + 1 < displayedData.length
|
||||
? "1px solid rgba(255, 255, 255, .075)"
|
||||
: "none",
|
||||
}}
|
||||
className="flex pb-4 relative container items-center"
|
||||
>
|
||||
{hoveredItem === item.id + index &&
|
||||
window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition} ${
|
||||
tooltipPosition === "top-1/2"
|
||||
? "translate-y-[50px]"
|
||||
: "translate-y-[-50px]"
|
||||
} z-[100000] transform transition-all duration-300 ease-in-out ${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="flex-shrink-0 w-[60px] h-[75px] rounded-md object-cover cursor-pointer"
|
||||
onClick={() => navigate(`/watch/${item.id}`)}
|
||||
onMouseEnter={() => handleMouseEnter(item, index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
<div className="flex flex-col ml-4 space-y-2">
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="text-[1em] font-[500] hover:cursor-pointer hover:text-[#ffbade] transform transition-all ease-out line-clamp-1 max-[478px]:line-clamp-2 max-[478px]:text-[14px]"
|
||||
onClick={() =>
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center w-fit space-x-1 max-[320px]:gap-y-2">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[4px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[4px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.showType && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="dot ml-[4px]"></div>
|
||||
<p className="text-[15px] font-light">
|
||||
{item.tvInfo.showType}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!limit && data.length > 6 && (
|
||||
<button
|
||||
className="w-full bg-[#555462d3] py-3 mt-4 hover:bg-[#555462] rounded-md font-bold transform transition-all ease-out"
|
||||
onClick={toggleShowAll}
|
||||
>
|
||||
{showAll ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Sidecard);
|
||||
227
src/components/splashscreen/SplashScreen.css
Normal file
227
src/components/splashscreen/SplashScreen.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
107
src/components/splashscreen/SplashScreen.jsx
Normal file
107
src/components/splashscreen/SplashScreen.jsx
Normal file
@@ -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 (
|
||||
<div className="splash-container">
|
||||
<div className="splash-overlay"></div>
|
||||
<div className="content-wrapper">
|
||||
<div className="logo-container">
|
||||
<img src="/logo.png" alt={logoTitle} className="logo" />
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search anime..."
|
||||
className="search-input"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
className="search-button"
|
||||
onClick={handleSearchSubmit}
|
||||
aria-label="Search"
|
||||
>
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link to="/home" className="enter-button">
|
||||
Enter Homepage →
|
||||
</Link>
|
||||
|
||||
<div className="faq-section">
|
||||
<h2 className="faq-title">Frequently Asked Questions</h2>
|
||||
<div className="faq-list">
|
||||
{FAQ_ITEMS.map((item, index) => (
|
||||
<div key={index} className="faq-item">
|
||||
<button
|
||||
className="faq-question"
|
||||
onClick={() => toggleFaq(index)}
|
||||
>
|
||||
<span>{item.question}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className={`faq-toggle ${expandedFaq === index ? 'rotate' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{expandedFaq === index && (
|
||||
<div className="faq-answer">
|
||||
{item.answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SplashScreen;
|
||||
68
src/components/spotlight/Spotlight.css
Normal file
68
src/components/spotlight/Spotlight.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
54
src/components/spotlight/Spotlight.jsx
Normal file
54
src/components/spotlight/Spotlight.jsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div className="relative h-[600px] max-[1390px]:h-[530px] max-[1300px]:h-[500px] max-md:h-[420px]">
|
||||
<div className="absolute right-[10px] bottom-0 flex flex-col space-y-2 z-10 max-[575px]:hidden">
|
||||
<div className="button-next"></div>
|
||||
<div className="button-prev"></div>
|
||||
</div>
|
||||
{spotlights && spotlights.length > 0 ? (
|
||||
<>
|
||||
<Swiper
|
||||
spaceBetween={0}
|
||||
slidesPerView={1}
|
||||
loop={true}
|
||||
allowTouchMove={false}
|
||||
navigation={{
|
||||
nextEl: ".button-next",
|
||||
prevEl: ".button-prev",
|
||||
}}
|
||||
autoplay={{
|
||||
delay: 3000,
|
||||
disableOnInteraction: false,
|
||||
}}
|
||||
modules={[Navigation, Autoplay]}
|
||||
className="h-[600px] max-[1390px]:h-full"
|
||||
style={{
|
||||
"--swiper-pagination-bullet-inactive-color": "#ffffff",
|
||||
"--swiper-pagination-bullet-inactive-opacity": "1",
|
||||
}}
|
||||
>
|
||||
{spotlights.map((item, index) => (
|
||||
<SwiperSlide className="text-black relative" key={index}>
|
||||
<Banner item={item} index={index} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</>
|
||||
) : (
|
||||
<p>No spotlights to show.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spotlight;
|
||||
115
src/components/suggestion/Suggestion.jsx
Normal file
115
src/components/suggestion/Suggestion.jsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`bg-[#2d2b44] ${className} flex ${
|
||||
loading ? "justify-center py-7" : "justify-start"
|
||||
} ${!suggestion ? "p-3" : "justify-start"} items-center`}
|
||||
style={{ boxShadow: "0 20px 20px rgba(0, 0, 0, .3)" }}
|
||||
>
|
||||
{loading ? (
|
||||
<BouncingLoader />
|
||||
) : error && !suggestion ? (
|
||||
<div>Error loading suggestions</div>
|
||||
) : suggestion && hasFetched ? (
|
||||
<div className="w-full flex flex-col pt-2 overflow-y-auto">
|
||||
{suggestion.map((item, index) => (
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
key={index}
|
||||
className="group py-2 flex items-start gap-x-3 hover:bg-[#3c3a5e] cursor-pointer px-[10px]"
|
||||
style={{
|
||||
borderBottom:
|
||||
index === suggestion.length - 1
|
||||
? "none"
|
||||
: "1px dashed rgba(255, 255, 255, .075)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
className="w-[50px] h-[75px] flex-shrink-0 object-cover"
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-y-[2px]">
|
||||
{item?.title && (
|
||||
<h1 className="line-clamp-1 leading-5 font-bold text-[15px] group-hover:text-[#ffbade]">
|
||||
{item.title || "N/A"}
|
||||
</h1>
|
||||
)}
|
||||
{item?.japanese_title && (
|
||||
<h1 className="line-clamp-1 leading-5 text-[13px] font-light text-[#aaaaaa]">
|
||||
{item.japanese_title || "N/A"}
|
||||
</h1>
|
||||
)}
|
||||
{(item?.releaseDate || item?.showType || item?.duration) && (
|
||||
<div className="flex gap-x-[5px] items-center w-full justify-start mt-[4px]">
|
||||
<p className="leading-5 text-[13px] font-light text-[#aaaaaa]">
|
||||
{item.releaseDate || "N/A"}
|
||||
</p>
|
||||
<span className="dot"></span>
|
||||
<p className="leading-5 text-[13px] font-medium group-hover:text-[#ffbade]">
|
||||
{item.showType || "N/A"}
|
||||
</p>
|
||||
<span className="dot"></span>
|
||||
<p className="leading-5 text-[13px] font-light text-[#aaaaaa]">
|
||||
{item.duration || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{!loading && hasFetched && (
|
||||
<Link
|
||||
className="w-full flex py-4 justify-center items-center bg-[#ffbade]"
|
||||
to={`/search?keyword=${encodeURIComponent(keyword)}`}
|
||||
>
|
||||
<div className="flex w-fit items-center gap-x-2">
|
||||
<p className="text-[17px] font-light text-black">
|
||||
View all results
|
||||
</p>
|
||||
<FaChevronRight className="text-black text-[12px] font-black mt-[2px]" />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : hasFetched ? (
|
||||
<p className="text-[17px]">No results found!</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Suggestion;
|
||||
176
src/components/topten/Topten.jsx
Normal file
176
src/components/topten/Topten.jsx
Normal file
@@ -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 (
|
||||
<div className={`flex flex-col space-y-6 ${className}`}>
|
||||
<div className="flex justify-between items-center max-[350px]:flex-col max-[350px]:gap-y-2 max-[350px]:items-start">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade]">Top 10</h1>
|
||||
<ul className="flex justify-between w-fit bg-[#373646] rounded-[4px] text-sm font-bold">
|
||||
{["today", "week", "month"].map((period) => (
|
||||
<li
|
||||
key={period}
|
||||
className={`cursor-pointer p-2 px-3 ${
|
||||
activePeriod === period
|
||||
? "bg-[#ffbade] text-[#555462]"
|
||||
: "text-white hover:text-[#ffbade]"
|
||||
} ${period === "today" ? "rounded-l-[4px]" : ""} ${
|
||||
period === "month" ? "rounded-r-[4px]" : ""
|
||||
}`}
|
||||
onClick={() => handlePeriodChange(period)}
|
||||
>
|
||||
{period.charAt(0).toUpperCase() + period.slice(1)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 bg-[#2B2A3C] p-4 pt-8">
|
||||
{currentData &&
|
||||
currentData.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-x-4"
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<h1
|
||||
className={`font-bold text-2xl ${
|
||||
index < 3
|
||||
? "pb-1 text-white border-b-[3px] border-[#ffbade]"
|
||||
: "text-[#777682]"
|
||||
} max-[350px]:hidden`}
|
||||
>
|
||||
{`${index + 1 < 10 ? "0" : ""}${index + 1}`}
|
||||
</h1>
|
||||
<div
|
||||
style={{
|
||||
borderBottom:
|
||||
index + 1 < 10
|
||||
? "1px solid rgba(255, 255, 255, .075)"
|
||||
: "none",
|
||||
}}
|
||||
className="flex pb-4 relative container items-center"
|
||||
>
|
||||
{/* Image with tooltip behavior */}
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="w-[60px] h-[75px] rounded-md object-cover flex-shrink-0 cursor-pointer"
|
||||
onClick={() => navigate(`/watch/${item.id}`)}
|
||||
onMouseEnter={() => handleMouseEnter(item, index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Tooltip positioned near image */}
|
||||
{hoveredItem === item.id + index &&
|
||||
window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition}
|
||||
${
|
||||
tooltipPosition === "top-1/2"
|
||||
? "translate-y-[50px]"
|
||||
: "translate-y-[-50px]"
|
||||
}
|
||||
z-[100000] transform transition-all duration-300 ease-in-out
|
||||
${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (hoverTimeout) clearTimeout(hoverTimeout);
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col ml-4 space-y-2">
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="text-[1em] font-[500] hover:cursor-pointer hover:text-[#ffbade] transform transition-all ease-out line-clamp-1 max-[478px]:line-clamp-2 max-[478px]:text-[14px]"
|
||||
onClick={() => handleNavigate(item.id)}
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center w-fit space-x-1 max-[350px]:gap-y-[3px]">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[4px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[4px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Topten);
|
||||
77
src/components/trending/Trending.jsx
Normal file
77
src/components/trending/Trending.jsx
Normal file
@@ -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 (
|
||||
<div className="mt-6 max-[1200px]:px-4 max-md:px-0">
|
||||
<h1 className="text-[#ffbade] text-2xl font-bold max-md:pl-4">
|
||||
Trending
|
||||
</h1>
|
||||
<div className="pr-[60px] relative mx-auto overflow-hidden z-[1] mt-6 max-[759px]:pr-0">
|
||||
<Swiper
|
||||
className="w-full h-full"
|
||||
slidesPerView={3}
|
||||
spaceBetween={2}
|
||||
breakpoints={{
|
||||
479: { spaceBetween: 15 },
|
||||
575: { spaceBetween: 15 },
|
||||
640: { slidesPerView: 3, spaceBetween: 15 },
|
||||
900: { slidesPerView: 4, spaceBetween: 15 },
|
||||
1300: { slidesPerView: 6, spaceBetween: 15 },
|
||||
}}
|
||||
modules={[Pagination, Navigation]}
|
||||
navigation={{
|
||||
nextEl: ".btn-next",
|
||||
prevEl: ".btn-prev",
|
||||
}}
|
||||
>
|
||||
{trending &&
|
||||
trending.map((item, idx) => (
|
||||
<SwiperSlide
|
||||
key={idx}
|
||||
className="text-center flex text-[18px] justify-center items-center"
|
||||
onClick={() => navigate(`/watch/${item.id}`)}
|
||||
>
|
||||
<div className="w-full h-auto pb-[115%] relative inline-block overflow-hidden max-[575px]:pb-[150%]">
|
||||
<div className="absolute left-0 top-0 bottom-0 overflow-hidden w-[40px] text-center font-semibold bg-[#201F31] max-[575px]:top-0 max-[575px]:h-[30px] max-[575px]:z-[9] max-[575px]:bg-white">
|
||||
<span className="absolute left-0 right-0 bottom-0 text-[24px] leading-[1.1em] text-center z-[9] transform -rotate-90 max-[575px]:transform max-[575px]:rotate-0 max-[575px]:text-[#111] max-[575px]:text-[18px] max-[575px]:leading-[30px]">
|
||||
{item.number}
|
||||
</span>
|
||||
<div className="w-[150px] h-fit text-left transform -rotate-90 absolute bottom-[100px] left-[-55px] leading-[40px] text-ellipsis whitespace-nowrap overflow-hidden text-white text-[16px] font-medium">
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="inline-block bg-[#2a2c31] absolute w-auto left-[40px] right-0 top-0 bottom-0 max-[575px]:left-0 max-[575px]:top-0 max-[575px]:bottom-0"
|
||||
>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="block w-full h-full object-cover hover:cursor-pointer"
|
||||
title={item.title}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[45px] flex flex-col space-y-2 max-[759px]:hidden">
|
||||
<div className="btn-next bg-[#383747] h-[50%] flex justify-center items-center rounded-[8px] cursor-pointer transition-all duration-300 ease-out hover:bg-[#ffbade] hover:text-[#383747]">
|
||||
<FaChevronRight />
|
||||
</div>
|
||||
<div className="btn-prev bg-[#383747] h-[50%] flex justify-center items-center rounded-[8px] cursor-pointer transition-all duration-300 ease-out hover:bg-[#ffbade] hover:text-[#383747]">
|
||||
<FaChevronLeft />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Trending;
|
||||
23
src/components/ui/Skeleton/Skeleton.css
Normal file
23
src/components/ui/Skeleton/Skeleton.css
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
16
src/components/ui/Skeleton/Skeleton.jsx
Normal file
16
src/components/ui/Skeleton/Skeleton.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import './Skeleton.css';
|
||||
|
||||
function Skeleton({ className, animation=true, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-gray-400 rounded-3xl",
|
||||
animation ? "shimmer-effect" : "",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
45
src/components/ui/bouncingloader/Bouncingloader.css
Normal file
45
src/components/ui/bouncingloader/Bouncingloader.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
12
src/components/ui/bouncingloader/Bouncingloader.jsx
Normal file
12
src/components/ui/bouncingloader/Bouncingloader.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import "./Bouncingloader.css"
|
||||
const BouncingLoader = () => {
|
||||
return (
|
||||
<div className="bouncing-loading flex gap-x-[5px]">
|
||||
<div className="span1"></div>
|
||||
<div className="span2"></div>
|
||||
<div className="span3"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BouncingLoader;
|
||||
100
src/components/voiceactor/Voiceactor.jsx
Normal file
100
src/components/voiceactor/Voiceactor.jsx
Normal file
@@ -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 (
|
||||
<div className={`w-full mt-8 flex flex-col gap-y-4 ${className}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px] capitalize">
|
||||
Characters & Voice Actors
|
||||
</h1>
|
||||
<button className="flex w-fit items-baseline h-fit rounded-3xl gap-x-1 group">
|
||||
<p
|
||||
className="text-white text-[12px] font-semibold h-fit leading-0"
|
||||
onClick={() => {
|
||||
setShowVoiceActors(true);
|
||||
}}
|
||||
>
|
||||
View more
|
||||
</p>
|
||||
<FaChevronRight className="text-white text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-3 max-[1024px]:grid-cols-2 max-[758px]:grid-cols-1 gap-4">
|
||||
{animeInfo.charactersVoiceActors.slice(0, 6).map((character, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex justify-between items-center px-3 py-4 rounded-md bg-[#373646]"
|
||||
>
|
||||
{character.character && (
|
||||
<div className="w-[50%] float-left overflow-hidden max-[350px]:w-[45%]">
|
||||
<div className="w-full flex gap-x-3">
|
||||
{character.character.poster && (
|
||||
<img
|
||||
src={character.character.poster}
|
||||
title={character.character.name || "Character"}
|
||||
alt={character.character.name || "Character"}
|
||||
onError={(e) => {
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-center flex-col">
|
||||
{character.character.name && (
|
||||
<h4 className="text-[13px] text-left leading-[1.3em] font-[400] mb-0 overflow-hidden -webkit-box -webkit-line-clamp-2 -webkit-box-orient-vertical">
|
||||
{character.character.name}
|
||||
</h4>
|
||||
)}
|
||||
{character.character.cast && (
|
||||
<p className="text-[11px] mt-[3px]">
|
||||
{character.character.cast}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{character.voiceActors.length > 0 && character.voiceActors[0] && (
|
||||
<div className="w-[50%] float-right overflow-hidden max-[350px]:w-[45%]">
|
||||
<div className="w-full flex justify-end gap-x-2">
|
||||
<div className="flex flex-col justify-center ">
|
||||
{character.voiceActors[0].name && (
|
||||
<span className="text-[13px] text-right leading-[1.3em] font-[400] mb-0 overflow-hidden -webkit-box -webkit-line-clamp-2 -webkit-box-orient-vertical w-fit">
|
||||
{character.voiceActors[0].name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{character.voiceActors[0].poster && (
|
||||
<img
|
||||
src={character.voiceActors[0].poster}
|
||||
title={character.voiceActors[0].name || "Voice Actor"}
|
||||
alt={character.voiceActors[0].name || "Voice Actor"}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showVoiceActors && (
|
||||
<VoiceactorList
|
||||
id={animeInfo.id}
|
||||
isOpen={showVoiceActors}
|
||||
onClose={() => setShowVoiceActors(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Voiceactor;
|
||||
175
src/components/voiceactorlist/VoiceactorList.jsx
Normal file
175
src/components/voiceactorlist/VoiceactorList.jsx
Normal file
@@ -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 <Error />;
|
||||
}
|
||||
if (!VoiceactorList) {
|
||||
navigate("/404-not-found-page");
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 left-0 w-screen h-screen overflow-y-auto bg-black/80 z-50 flex justify-center py-10 max-[575px]:py-3"
|
||||
style={{
|
||||
zIndex: 1000000,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-[920px] h-fit flex flex-col relative backdrop-blur-[10px] rounded-lg p-6 bg-white/10 ${
|
||||
loading ? "h-fit" : ""
|
||||
} max-[1000px]:w-[80vw] max-md:w-[90vw] max-[480px]:p-3`}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{!loading && (
|
||||
<h2 className="text-2xl font-bold col-span-2 max-[480px]:text-lg">
|
||||
Characters & Voice Actors
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<VoiceActorlistLoader />
|
||||
) : (
|
||||
<div className="w-full grid grid-cols-2 gap-4 mt-5 max-[1000px]:grid-cols-1">
|
||||
{VoiceactorList.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex p-4 items-center justify-between py-2 bg-[#444445] rounded-lg h-[80px] max-[480px]:p-1 max-[480px]:bg-transparent max-[480px]:rounded-none max-[480px]:border-b-[1px] border-dotted max-[480px]:h-[60px] max-[480px]:pb-4"
|
||||
>
|
||||
<div className="flex gap-x-2 items-center w-[50%] overflow-hidden">
|
||||
<img
|
||||
src={item.character.poster}
|
||||
className="w-[45px] h-[45px] rounded-full flex-shrink-0 object-cover hover:cursor-pointer max-[480px]:w-[30px] max-[480px]:h-[30px]"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col text-left gap-y-1 w-full">
|
||||
{item.character.name && (
|
||||
<h1 className="text-[13px] font-semibold max-[480px]:text-[11px]">
|
||||
{item.character.name}
|
||||
</h1>
|
||||
)}
|
||||
{item.character.cast && (
|
||||
<p className="text-[12px] font-light max-[480px]:text-[10px]">
|
||||
{item.character.cast}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.voiceActors &&
|
||||
item.voiceActors.length > 0 &&
|
||||
(item.voiceActors.length > 1 ? (
|
||||
<div className="flex flex-wrap gap-x-[4px] items-center justify-end w-[50%] max-sm:flex-nowrap max-sm:overflow-auto max-[350px]:justify-start max-sm:py-3">
|
||||
{item.voiceActors.map((data, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={data.poster}
|
||||
className="w-[41px] h-[41px] opacity-70 cursor-pointer rounded-full flex-shrink-0 object-cover grayscale hover:grayscale-0 hover:opacity-100 max-[480px]:w-[30px] max-[480px]:h-[30px] transition-all duration-300 ease-in-out"
|
||||
title={data.name}
|
||||
style={{
|
||||
border: "4px solid rgba(105, 108, 117, 0.8)",
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-x-2 w-[50%] overflow-hidden max-[480px]:flex-wrap max-[480px]:flex-col-reverse max-[480px]:items-end max-[480px]:gap-y-1">
|
||||
{item?.voiceActors[0]?.name && (
|
||||
<p className="text-right text-[13px] max-[480px]:text-[11px]">
|
||||
{item.voiceActors[0].name}
|
||||
</p>
|
||||
)}
|
||||
<img
|
||||
src={item.voiceActors[0].poster}
|
||||
alt=""
|
||||
title={item.voiceActors.name}
|
||||
loading="lazy"
|
||||
className="w-[45px] h-[45px] rounded-full opacity-70 flex-shrink-0 object-cover grayscale hover:grayscale-0 hover:opacity-100 max-[480px]:w-[30px] max-[480px]:h-[30px] transition-all duration-300 ease-in-out"
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="bg-white w-[30px] h-[30px] p-2 rounded-full text-3xl absolute z-[1000] top-[-14px] right-[-14px] hover:text-[#FFBADE] cursor-pointer transform transition-all ease-in-out duration-300 flex items-center justify-center hover:bg-[#ffbade] max-md:top-0 max-md:right-0 max-md:rounded-none max-md:rounded-bl-lg max-md:rounded-tr-lg"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button className="text-black mb-[6px] font-semibold">×</button>
|
||||
</div>
|
||||
|
||||
<PageSlider
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={setPage}
|
||||
start={true}
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VoiceactorList;
|
||||
97
src/components/watchcontrols/Watchcontrols.jsx
Normal file
97
src/components/watchcontrols/Watchcontrols.jsx
Normal file
@@ -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 }) => (
|
||||
<button className="flex gap-x-2" onClick={onClick}>
|
||||
<h1 className="capitalize text-[13px]">{label}</h1>
|
||||
<span
|
||||
className={`capitalize text-[13px] ${
|
||||
isActive ? "text-[#ffbade]" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{isActive ? "on" : "off"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="bg-[#11101A] w-full flex justify-between flex-wrap px-4 pt-4 max-[1200px]:bg-[#14151A] max-[375px]:flex-col max-[375px]:gap-y-2">
|
||||
<div className="flex gap-x-4 flex-wrap">
|
||||
<ToggleButton
|
||||
label="auto play"
|
||||
isActive={autoPlay}
|
||||
onClick={() => setAutoPlay((prev) => !prev)}
|
||||
/>
|
||||
<ToggleButton
|
||||
label="auto skip intro"
|
||||
isActive={autoSkipIntro}
|
||||
onClick={() => setAutoSkipIntro((prev) => !prev)}
|
||||
/>
|
||||
<ToggleButton
|
||||
label="auto next"
|
||||
isActive={autoNext}
|
||||
onClick={() => setAutoNext((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-x-6 max-[575px]:gap-x-4 max-[375px]:justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentEpisodeIndex > 0) {
|
||||
onButtonClick(
|
||||
episodes[currentEpisodeIndex - 1].id.match(/ep=(\d+)/)?.[1]
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={currentEpisodeIndex <= 0}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faBackward}
|
||||
className="text-[20px] max-[575px]:text-[16px] text-white"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentEpisodeIndex < episodes?.length - 1) {
|
||||
onButtonClick(
|
||||
episodes[currentEpisodeIndex + 1].id.match(/ep=(\d+)/)?.[1]
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={currentEpisodeIndex >= episodes?.length - 1}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faForward}
|
||||
className="text-[20px] max-[575px]:text-[16px] text-white"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/config/logoTitle.js
Normal file
3
src/config/logoTitle.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const logoTitle="Zen!me"
|
||||
|
||||
export default logoTitle;
|
||||
3
src/config/website.js
Normal file
3
src/config/website.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const website_name = "JustAnime";
|
||||
|
||||
export default website_name;
|
||||
31
src/context/HomeInfoContext.jsx
Normal file
31
src/context/HomeInfoContext.jsx
Normal file
@@ -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 (
|
||||
<HomeInfoContext.Provider value={{ homeInfo, homeInfoLoading, error }}>
|
||||
{children}
|
||||
</HomeInfoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useHomeInfo = () => useContext(HomeInfoContext);
|
||||
27
src/context/LanguageContext.jsx
Normal file
27
src/context/LanguageContext.jsx
Normal file
@@ -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 (
|
||||
<LanguageContext.Provider value={{ language, toggleLanguage }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLanguage = () => {
|
||||
return useContext(LanguageContext);
|
||||
};
|
||||
13
src/context/SearchContext.jsx
Normal file
13
src/context/SearchContext.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
|
||||
const SearchContext = createContext();
|
||||
export function SearchProvider({ children }) {
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={{ isSearchVisible, setIsSearchVisible }}>
|
||||
{children}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
}
|
||||
export const useSearchContext = () => useContext(SearchContext);
|
||||
32
src/helper/toggleScrollbar.js
Normal file
32
src/helper/toggleScrollbar.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
64
src/hooks/useSearch.js
Normal file
64
src/hooks/useSearch.js
Normal file
@@ -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;
|
||||
49
src/hooks/useToolTipPosition.js
Normal file
49
src/hooks/useToolTipPosition.js
Normal file
@@ -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;
|
||||
269
src/hooks/useWatch.js
Normal file
269
src/hooks/useWatch.js
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
34
src/hooks/useWatchControl.js
Normal file
34
src/hooks/useWatchControl.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
126
src/index.css
Normal file
126
src/index.css
Normal file
@@ -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;
|
||||
}
|
||||
13
src/main.jsx
Normal file
13
src/main.jsx
Normal file
@@ -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(
|
||||
<LanguageProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</LanguageProvider>
|
||||
);
|
||||
82
src/pages/Home/Home.jsx
Normal file
82
src/pages/Home/Home.jsx
Normal file
@@ -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 <Loader type="home" />;
|
||||
if (error) return <Error />;
|
||||
if (!homeInfo) return <Error error="404" />;
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 w-full max-[1200px]:px-0">
|
||||
<Spotlight spotlights={homeInfo.spotlights} />
|
||||
<ContinueWatching />
|
||||
<Trending trending={homeInfo.trending} />
|
||||
<div className="mt-10 flex gap-6 max-[1200px]:px-4 max-[1200px]:grid max-[1200px]:grid-cols-2 max-[1200px]:mt-12 max-[1200px]:gap-y-10 max-[680px]:grid-cols-1">
|
||||
<Cart
|
||||
label="Top Airing"
|
||||
data={homeInfo.top_airing}
|
||||
path="top-airing"
|
||||
/>
|
||||
<Cart
|
||||
label="Most Popular"
|
||||
data={homeInfo.most_popular}
|
||||
path="most-popular"
|
||||
/>
|
||||
<Cart
|
||||
label="Most Favorite"
|
||||
data={homeInfo.most_favorite}
|
||||
path="most-favorite"
|
||||
/>
|
||||
<Cart
|
||||
label="Latest Completed"
|
||||
data={homeInfo.latest_completed}
|
||||
path="completed"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col max-[1200px]:px-4">
|
||||
<div>
|
||||
<CategoryCard
|
||||
label="Latest Episode"
|
||||
data={homeInfo.latest_episode}
|
||||
className={"mt-[60px]"}
|
||||
path="recently-updated"
|
||||
limit={12}
|
||||
/>
|
||||
<CategoryCard
|
||||
label={`New On ${website_name}`}
|
||||
data={homeInfo.recently_added}
|
||||
className={"mt-[60px]"}
|
||||
path="recently-added"
|
||||
limit={12}
|
||||
/>
|
||||
<Schedule />
|
||||
<CategoryCard
|
||||
label="Top Upcoming"
|
||||
data={homeInfo.top_upcoming}
|
||||
className={"mt-[30px]"}
|
||||
path="top-upcoming"
|
||||
limit={12}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full mt-[60px]">
|
||||
<Genre data={homeInfo.genres} />
|
||||
<Topten data={homeInfo.topten} className={"mt-12"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
118
src/pages/a2z/AtoZ.jsx
Normal file
118
src/pages/a2z/AtoZ.jsx
Normal file
@@ -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 <Loader type="AtoZ" />;
|
||||
if (error) {
|
||||
return <Error />;
|
||||
}
|
||||
if (!categoryInfo) {
|
||||
return null;
|
||||
}
|
||||
const handlePageChange = (newPage) => {
|
||||
setSearchParams({ page: newPage });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[1260px] mx-auto px-[15px] flex flex-col mt-[64px] max-md:mt-[50px]">
|
||||
<ul className="flex gap-x-2 mt-[50px] items-center w-fit max-[1200px]:hidden">
|
||||
<li className="flex gap-x-3 items-center">
|
||||
<Link to="/home" className="text-white hover:text-[#FFBADE] text-[17px]">
|
||||
Home
|
||||
</Link>
|
||||
<div className="dot mt-[1px] bg-white"></div>
|
||||
</li>
|
||||
<li className="font-light">A-Z List</li>
|
||||
</ul>
|
||||
<div className="flex flex-col gap-y-5 mt-6">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px]">
|
||||
Sort By Letters
|
||||
</h1>
|
||||
<div className="flex gap-x-[7px] flex-wrap justify-start gap-y-2 max-md:justify-start">
|
||||
{[
|
||||
"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 (
|
||||
<Link
|
||||
to={`/az-list/${linkPath}`}
|
||||
key={index}
|
||||
className={`text-md bg-[#373646] py-1 px-4 rounded-md font-bold hover:text-black hover:bg-[#FFBADE] hover:cursor-pointer transition-all ease-out ${
|
||||
isActive ? "text-black bg-[#FFBADE]" : ""
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-y-8">
|
||||
<div>
|
||||
{categoryInfo && categoryInfo.length > 0 && (
|
||||
<CategoryCard
|
||||
data={categoryInfo}
|
||||
limit={categoryInfo.length}
|
||||
showViewMore={false}
|
||||
className="mt-0"
|
||||
cardStyle="max-[1400px]:h-[35vw]"
|
||||
/>
|
||||
)}
|
||||
<PageSlider
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AtoZ;
|
||||
416
src/pages/animeInfo/AnimeInfo.jsx
Normal file
416
src/pages/animeInfo/AnimeInfo.jsx
Normal file
@@ -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 && (
|
||||
<div className="text-[14px] font-bold">
|
||||
{`${label}: `}
|
||||
<span className="font-light">
|
||||
{Array.isArray(value) ? (
|
||||
value.map((item, index) =>
|
||||
isProducer ? (
|
||||
<Link
|
||||
to={`/producer/${item
|
||||
.replace(/[&'"^%$#@!()+=<>:;,.?/\\|{}[\]`~*_]/g, "")
|
||||
.split(" ")
|
||||
.join("-")
|
||||
.replace(/-+/g, "-")}`}
|
||||
key={index}
|
||||
className="cursor-pointer hover:text-[#ffbade]"
|
||||
>
|
||||
{item}
|
||||
{index < value.length - 1 && ", "}
|
||||
</Link>
|
||||
) : (
|
||||
<span key={index} className="cursor-pointer">
|
||||
{item}
|
||||
</span>
|
||||
)
|
||||
)
|
||||
) : isProducer ? (
|
||||
<Link
|
||||
to={`/producer/${value
|
||||
.replace(/[&'"^%$#@!()+=<>:;,.?/\\|{}[\]`~*_]/g, "")
|
||||
.split(" ")
|
||||
.join("-")
|
||||
.replace(/-+/g, "-")}`}
|
||||
className="cursor-pointer hover:text-[#ffbade]"
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="cursor-pointer">{value}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function Tag({ bgColor, index, icon, text }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex space-x-1 justify-center items-center px-[4px] py-[1px] text-black font-bold text-[13px] ${
|
||||
index === 0 ? "rounded-l-[4px]" : "rounded-none"
|
||||
}`}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{icon && <FontAwesomeIcon icon={icon} className="text-[12px]" />}
|
||||
<p className="text-[12px]">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Loader type="animeInfo" />;
|
||||
if (error) {
|
||||
return <Error />;
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<div className="relative grid grid-cols-[minmax(0,75%),minmax(0,25%)] h-fit w-full overflow-hidden text-white mt-[64px] max-[1200px]:flex max-[1200px]:flex-col max-md:mt-[50px]">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${poster}`}
|
||||
alt={`${title} Poster`}
|
||||
className="absolute inset-0 object-cover w-full h-full filter grayscale blur-lg z-[-900]"
|
||||
/>
|
||||
<div className="flex items-start z-10 px-14 py-[70px] bg-[#252434] bg-opacity-70 gap-x-8 max-[1024px]:px-6 max-[1024px]:py-10 max-[1024px]:gap-x-4 max-[575px]:flex-col max-[575px]:items-center max-[575px]:justify-center">
|
||||
<div className="relative w-[180px] h-[270px] max-[575px]:w-[140px] max-[575px]:h-[200px] flex-shrink-0">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${poster}`}
|
||||
alt={`${title} Poster`}
|
||||
className="w-full h-full object-cover object-center flex-shrink-0"
|
||||
/>
|
||||
{animeInfo.adultContent && (
|
||||
<div className="text-white px-2 rounded-md bg-[#FF5700] absolute top-2 left-2 flex items-center justify-center text-[14px] font-bold">
|
||||
18+
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col ml-4 gap-y-5 max-[575px]:items-center max-[575px]:justify-center max-[575px]:mt-6 max-[1200px]:ml-0">
|
||||
<ul className="flex gap-x-2 items-center w-fit max-[1200px]:hidden">
|
||||
{[
|
||||
["Home", "home"],
|
||||
[info.tvInfo?.showType, info.tvInfo?.showType],
|
||||
].map(([text, link], index) => (
|
||||
<li key={index} className="flex gap-x-3 items-center">
|
||||
<Link
|
||||
to={`/${link}`}
|
||||
className="text-white hover:text-[#FFBADE] text-[15px] font-semibold"
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
<div className="dot mt-[1px] bg-white"></div>
|
||||
</li>
|
||||
))}
|
||||
<p className="font-light text-[15px] text-gray-300 line-clamp-1 max-[575px]:leading-5">
|
||||
{language === "EN" ? title : japanese_title}
|
||||
</p>
|
||||
</ul>
|
||||
<h1 className="text-4xl font-semibold max-[1200px]:text-3xl max-[575px]:text-2xl max-[575px]:text-center max-[575px]:leading-7">
|
||||
{language === "EN" ? title : japanese_title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap w-fit gap-x-[2px] mt-3 max-[575px]:mx-auto max-[575px]:mt-0 gap-y-[3px] max-[320px]:justify-center">
|
||||
{tags.map(
|
||||
({ condition, icon, bgColor, text }, index) =>
|
||||
condition && (
|
||||
<Tag
|
||||
key={index}
|
||||
index={index}
|
||||
bgColor={bgColor}
|
||||
icon={icon}
|
||||
text={text}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div className="flex w-fit items-center ml-1">
|
||||
{[info.tvInfo?.showType, info.tvInfo?.duration].map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<div
|
||||
key={index}
|
||||
className="px-1 h-fit flex items-center gap-x-2 w-fit"
|
||||
>
|
||||
<div className="dot mt-[2px]"></div>
|
||||
<p className="text-[14px]">{item}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{animeInfo?.animeInfo?.Status?.toLowerCase() !== "not-yet-aired" ? (
|
||||
<Link
|
||||
to={`/watch/${animeInfo.id}`}
|
||||
className="flex gap-x-2 px-6 py-2 bg-[#FFBADE] w-fit text-black items-center rounded-3xl mt-5"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[14px] mt-[1px]"
|
||||
/>
|
||||
<p className="text-lg font-medium">Watch Now</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex gap-x-2 px-6 py-2 bg-[#FFBADE] w-fit text-black items-center rounded-3xl mt-5">
|
||||
<p className="text-lg font-medium">Not released</p>
|
||||
</div>
|
||||
)}
|
||||
{info?.Overview && (
|
||||
<div className="text-[14px] mt-2 max-[575px]:hidden">
|
||||
{info.Overview.length > 270 ? (
|
||||
<>
|
||||
{isFull
|
||||
? info.Overview
|
||||
: `${info.Overview.slice(0, 270)}...`}
|
||||
<span
|
||||
className="text-[13px] font-bold hover:cursor-pointer"
|
||||
onClick={() => setIsFull(!isFull)}
|
||||
>
|
||||
{isFull ? "- Less" : "+ More"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
info.Overview
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[14px] max-[575px]:hidden">
|
||||
{`${website_name} is the best site to watch `}
|
||||
<span className="font-bold">{title}</span>
|
||||
{` SUB online, or you can even watch `}
|
||||
<span className="font-bold">{title}</span>
|
||||
{` DUB in HD quality.`}
|
||||
</p>
|
||||
<div className="flex gap-x-4 items-center mt-4 max-[575px]:w-full max-[575px]:justify-center max-[320px]:hidden">
|
||||
<img
|
||||
src="https://i.postimg.cc/d34WWyNQ/share-icon.gif"
|
||||
alt="Share Anime"
|
||||
className="w-[60px] h-auto rounded-full max-[1024px]:w-[40px]"
|
||||
/>
|
||||
<div className="flex flex-col w-fit">
|
||||
<p className="text-[15px] font-bold text-[#FFBADE]">
|
||||
Share Anime
|
||||
</p>
|
||||
<p className="text-[16px] text-white">to your friends</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#4c4b57c3] flex items-center px-8 max-[1200px]:py-10 max-[1200px]:bg-[#363544e0] max-[575px]:p-4">
|
||||
<div className="w-full flex flex-col h-fit gap-y-3">
|
||||
{info?.Overview && (
|
||||
<div className="custom-xl:hidden max-h-[150px] overflow-hidden">
|
||||
<p className="text-[13px] font-bold">Overview:</p>
|
||||
<div className="max-h-[110px] mt-2 overflow-y-scroll">
|
||||
<p className="text-[14px] font-light">{info.Overview}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{[
|
||||
{ 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) => (
|
||||
<InfoItem
|
||||
key={index}
|
||||
label={label}
|
||||
value={value}
|
||||
isProducer={false}
|
||||
/>
|
||||
))}
|
||||
{info?.Genres && (
|
||||
<div className="flex gap-x-2 py-2 custom-xl:border-t custom-xl:border-b custom-xl:border-white/20 max-[1200px]:border-none">
|
||||
<p>Genres:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.Genres.map((genre, index) => (
|
||||
<Link
|
||||
to={`/genre/${genre.split(" ").join("-")}`}
|
||||
key={index}
|
||||
className="text-[14px] font-semibold px-2 py-[1px] border border-gray-400 rounded-2xl hover:text-[#ffbade]"
|
||||
>
|
||||
{genre}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{[
|
||||
{ label: "Studios", value: info?.Studios },
|
||||
{ label: "Producers", value: info?.Producers },
|
||||
].map(({ label, value }, index) => (
|
||||
<InfoItem key={index} label={label} value={value} />
|
||||
))}
|
||||
<p className="text-[14px] mt-4 custom-xl:hidden">
|
||||
{`${website_name} is the best site to watch `}
|
||||
<span className="font-bold">{title}</span>
|
||||
{` SUB online, or you can even watch `}
|
||||
<span className="font-bold">{title}</span>
|
||||
{` DUB in HD quality.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col">
|
||||
<div>
|
||||
{seasons?.length > 0 && (
|
||||
<div className="flex flex-col gap-y-7 mt-8">
|
||||
<h1 className="w-fit text-2xl text-[#ffbade] max-[478px]:text-[18px] font-bold">
|
||||
More Seasons
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-4 max-[575px]:grid max-[575px]:grid-cols-3 max-[575px]:gap-3 max-[480px]:grid-cols-2">
|
||||
{seasons.map((season, index) => (
|
||||
<Link
|
||||
to={`/${season.id}`}
|
||||
key={index}
|
||||
className={`relative w-[20%] h-[60px] rounded-lg overflow-hidden cursor-pointer group ${
|
||||
currentId === String(season.id)
|
||||
? "border border-[#ffbade]"
|
||||
: ""
|
||||
} max-[1200px]:w-[140px] max-[575px]:w-full`}
|
||||
>
|
||||
<p
|
||||
className={`text-[13px] text-center font-bold absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full px-2 z-30 line-clamp-2 group-hover:text-[#ffbade] ${
|
||||
currentId === String(season.id)
|
||||
? "text-[#ffbade]"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{season.season}
|
||||
</p>
|
||||
<div className="absolute inset-0 z-10 bg-[url('https://i.postimg.cc/pVGY6RXd/thumb.png')] bg-repeat"></div>
|
||||
<img
|
||||
src={season.season_poster}
|
||||
alt=""
|
||||
className="w-full h-full object-cover blur-[3px] opacity-50"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{animeInfo?.charactersVoiceActors.length > 0 && (
|
||||
<Voiceactor animeInfo={animeInfo} />
|
||||
)}
|
||||
{animeInfo.recommended_data.length > 0 && (
|
||||
<CategoryCard
|
||||
label="Recommended for you"
|
||||
data={animeInfo.recommended_data}
|
||||
limit={animeInfo.recommended_data.length}
|
||||
showViewMore={false}
|
||||
className={"mt-8"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{animeInfo.related_data.length > 0 && (
|
||||
<Sidecard
|
||||
label="Related Anime"
|
||||
data={animeInfo.related_data}
|
||||
className="mt-8"
|
||||
/>
|
||||
)}
|
||||
{homeInfo && homeInfo.most_popular && (
|
||||
<Sidecard
|
||||
label="Most Popular"
|
||||
data={homeInfo.most_popular.slice(0, 10)}
|
||||
className="mt-[40px]"
|
||||
limit={10}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnimeInfo;
|
||||
111
src/pages/category/Category.jsx
Normal file
111
src/pages/category/Category.jsx
Normal file
@@ -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 <Loader type="category" />;
|
||||
if (error) {
|
||||
navigate("/error-page");
|
||||
return <Error />;
|
||||
}
|
||||
if (!categoryInfo) {
|
||||
navigate("/404-not-found-page");
|
||||
return null;
|
||||
}
|
||||
const handlePageChange = (newPage) => {
|
||||
setSearchParams({ page: newPage });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-y-4 mt-[64px] max-md:mt-[50px]">
|
||||
<div className="w-full flex gap-x-4 items-center bg-[#191826] p-5 max-[575px]:px-3 max-[320px]:hidden">
|
||||
<img
|
||||
src="https://i.postimg.cc/d34WWyNQ/share-icon.gif"
|
||||
alt="Share Anime"
|
||||
className="w-[60px] h-auto rounded-full max-[1024px]:w-[40px] max-[575px]:hidden"
|
||||
/>
|
||||
<div className="flex flex-col w-fit">
|
||||
<p className="text-[15px] font-bold text-[#FFBADE]">Share Anime</p>
|
||||
<p className="text-[16px] text-white">to your friends</p>
|
||||
</div>
|
||||
</div>
|
||||
{categoryInfo ? (
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex max-[1200px]:flex-col max-[1200px]:gap-y-10">
|
||||
{page > totalPages ? (
|
||||
<p className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px] max-[300px]:leading-6">
|
||||
You came a long way, go back <br className="max-[300px]:hidden" />
|
||||
nothing is here
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{categoryInfo && categoryInfo.length > 0 && (
|
||||
<CategoryCard
|
||||
label={label.split("/").pop()}
|
||||
data={categoryInfo}
|
||||
showViewMore={false}
|
||||
className={"mt-0"}
|
||||
categoryPage={true}
|
||||
path={path}
|
||||
/>
|
||||
)}
|
||||
<PageSlider
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-y-10">
|
||||
{homeInfoLoading ? (
|
||||
<SidecardLoader />
|
||||
) : (
|
||||
<>
|
||||
{homeInfo && homeInfo.topten && (
|
||||
<Topten data={homeInfo.topten} className="mt-0" />
|
||||
)}
|
||||
{homeInfo?.genres && <Genre data={homeInfo.genres} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Error />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Category;
|
||||
74
src/pages/search/Search.jsx
Normal file
74
src/pages/search/Search.jsx
Normal file
@@ -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 (
|
||||
<div className='w-full px-4 mt-[128px] grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex max-[1200px]:flex-col max-[1200px]:gap-y-10 max-custom-md:mt-[80px] max-[478px]:mt-[60px]'>
|
||||
{loading ? (
|
||||
<CategoryCardLoader className={"max-[478px]:mt-2"} />
|
||||
) : page > totalPages ? <p className='font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px] max-[300px]:leading-6'>You came a long way, go back <br className='max-[300px]:hidden' />nothing is here</p> : searchData && searchData.length > 0 ? (
|
||||
<div>
|
||||
<CategoryCard
|
||||
label={`Search results for: ${keyword}`}
|
||||
data={searchData}
|
||||
showViewMore={false}
|
||||
className={"mt-0"}
|
||||
/>
|
||||
<PageSlider page={page} totalPages={totalPages} handlePageChange={handlePageChange} />
|
||||
</div>
|
||||
) : error ? <p className='font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px]'>Couldn't get search result please try again</p> : (
|
||||
<h1 className='font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px]'>{`Search results for: ${keyword}`}</h1>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-y-10">
|
||||
{homeInfoLoading ? (
|
||||
<SidecardLoader />
|
||||
) : (
|
||||
<>
|
||||
{homeInfo?.most_popular && <Sidecard data={homeInfo.most_popular} className="mt-0" label="Most Popular" />}
|
||||
{homeInfo?.genres && <Genre data={homeInfo.genres} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Search;
|
||||
541
src/pages/watch/Watch.jsx
Normal file
541
src/pages/watch/Watch.jsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`flex space-x-1 justify-center items-center px-[4px] py-[1px] text-black font-semibold text-[13px] ${
|
||||
index === 0 ? "rounded-l-[4px]" : "rounded-none"
|
||||
}`}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{icon && <FontAwesomeIcon icon={icon} className="text-[12px]" />}
|
||||
<p className="text-[12px]">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="w-full h-fit flex flex-col justify-center items-center relative">
|
||||
<div className="w-full relative max-[1400px]:px-[30px] max-[1200px]:px-[80px] max-[1024px]:px-0">
|
||||
<img
|
||||
src={
|
||||
!animeInfoLoading
|
||||
? `https://wsrv.nl/?url=${animeInfo?.poster}`
|
||||
: "https://i.postimg.cc/rFZnx5tQ/2-Kn-Kzog-md.webp"
|
||||
}
|
||||
alt={`${animeInfo?.title} Poster`}
|
||||
className="absolute inset-0 w-full h-full object-cover filter grayscale z-[-900]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[#3a3948] bg-opacity-80 backdrop-blur-md z-[-800]"></div>
|
||||
<div className="relative z-10 px-4 pb-[50px] grid grid-cols-[minmax(0,75%),minmax(0,25%)] w-full h-full mt-[128px] max-[1400px]:flex max-[1400px]:flex-col max-[1200px]:mt-[64px] max-[1024px]:px-0 max-md:mt-[50px]">
|
||||
{animeInfo && (
|
||||
<ul className="flex absolute left-4 top-[-40px] gap-x-2 items-center w-fit max-[1200px]:hidden">
|
||||
{[
|
||||
["Home", "home"],
|
||||
[animeInfo?.showType, animeInfo?.showType],
|
||||
].map(([text, link], index) => (
|
||||
<li key={index} className="flex gap-x-3 items-center">
|
||||
<Link
|
||||
to={`/${link}`}
|
||||
className="text-white hover:text-[#FFBADE] text-[15px] font-semibold"
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
<div className="dot mt-[1px] bg-white"></div>
|
||||
</li>
|
||||
))}
|
||||
<p className="font-light text-[15px] text-gray-300 line-clamp-1 max-[575px]:leading-5">
|
||||
Watching{" "}
|
||||
{language === "EN"
|
||||
? animeInfo?.title
|
||||
: animeInfo?.japanese_title}
|
||||
</p>
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex w-full min-h-fit max-[1200px]:flex-col-reverse">
|
||||
<div className="episodes w-[35%] bg-[#191826] flex justify-center items-center max-[1400px]:w-[380px] max-[1200px]:w-full max-[1200px]:h-full max-[1200px]:min-h-[100px]">
|
||||
{!episodes ? (
|
||||
<BouncingLoader />
|
||||
) : (
|
||||
<Episodelist
|
||||
episodes={episodes}
|
||||
currentEpisode={episodeId}
|
||||
onEpisodeClick={(id) => setEpisodeId(id)}
|
||||
totalEpisodes={totalEpisodes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="player w-full h-fit bg-black flex flex-col">
|
||||
<div className="w-full relative h-[480px] max-[1400px]:h-[40vw] max-[1200px]:h-[48vw] max-[1024px]:h-[58vw] max-[600px]:h-[65vw]">
|
||||
{!buffering ? (( activeServerName.toLowerCase()==="hd-1" || activeServerName.toLowerCase()==="hd-2" || activeServerName.toLowerCase()==="hd-3" || activeServerName.toLowerCase()==="hd-4") ?
|
||||
<IframePlayer
|
||||
animeId={animeId}
|
||||
episodeId={episodeId}
|
||||
servertype={activeServerType}
|
||||
serverName={activeServerName}
|
||||
animeInfo={animeInfo}
|
||||
episodeNum={activeEpisodeNum}
|
||||
episodes={episodes}
|
||||
playNext={(id) => setEpisodeId(id)}
|
||||
autoNext={autoNext}
|
||||
/>:<Player
|
||||
streamUrl={streamUrl}
|
||||
subtitles={subtitles}
|
||||
intro={intro}
|
||||
outro={outro}
|
||||
serverName={activeServerName.toLowerCase()}
|
||||
thumbnail={thumbnail}
|
||||
autoSkipIntro={autoSkipIntro}
|
||||
autoPlay={autoPlay}
|
||||
autoNext={autoNext}
|
||||
episodeId={episodeId}
|
||||
episodes={episodes}
|
||||
playNext={(id) => setEpisodeId(id)}
|
||||
animeInfo={animeInfo}
|
||||
episodeNum={activeEpisodeNum}
|
||||
streamInfo={streamInfo}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex justify-center items-center bg-black bg-opacity-50">
|
||||
<BouncingLoader />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-center underline font-medium text-[15px] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{!buffering && !activeServerType ? (
|
||||
servers ? (
|
||||
<>
|
||||
Probably this server is down, try other servers
|
||||
<br />
|
||||
Either reload or try again after sometime
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Probably streaming server is down
|
||||
<br />
|
||||
Either reload or try again after sometime
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!buffering && (
|
||||
<Watchcontrols
|
||||
autoPlay={autoPlay}
|
||||
setAutoPlay={setAutoPlay}
|
||||
autoSkipIntro={autoSkipIntro}
|
||||
setAutoSkipIntro={setAutoSkipIntro}
|
||||
autoNext={autoNext}
|
||||
setAutoNext={setAutoNext}
|
||||
episodes={episodes}
|
||||
totalEpisodes={totalEpisodes}
|
||||
episodeId={episodeId}
|
||||
onButtonClick={(id) => setEpisodeId(id)}
|
||||
/>
|
||||
)}
|
||||
<Servers
|
||||
servers={servers}
|
||||
activeEpisodeNum={activeEpisodeNum}
|
||||
activeServerId={activeServerId}
|
||||
setActiveServerId={setActiveServerId}
|
||||
serverLoading={serverLoading}
|
||||
setActiveServerType={setActiveServerType}
|
||||
activeServerType={activeServerType}
|
||||
setActiveServerName={setActiveServerName}
|
||||
/>
|
||||
{seasons?.length > 0 && (
|
||||
<div className="flex flex-col gap-y-2 bg-[#11101A] p-4">
|
||||
<h1 className="w-fit text-lg max-[478px]:text-[18px] font-semibold">
|
||||
Watch more seasons of this anime
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-4 max-[575px]:grid max-[575px]:grid-cols-3 max-[575px]:gap-3 max-[480px]:grid-cols-2">
|
||||
{seasons.map((season, index) => (
|
||||
<Link
|
||||
to={`/${season.id}`}
|
||||
key={index}
|
||||
className={`relative w-[20%] h-[60px] rounded-lg overflow-hidden cursor-pointer group ${
|
||||
animeId === String(season.id)
|
||||
? "border border-[#ffbade]"
|
||||
: ""
|
||||
} max-[1200px]:w-[140px] max-[575px]:w-full`}
|
||||
>
|
||||
<p
|
||||
className={`text-[13px] text-center font-bold absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-full px-2 z-30 line-clamp-2 group-hover:text-[#ffbade] ${
|
||||
animeId === String(season.id)
|
||||
? "text-[#ffbade]"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{season.season}
|
||||
</p>
|
||||
<div className="absolute inset-0 z-10 bg-[url('https://i.postimg.cc/pVGY6RXd/thumb.png')] bg-repeat"></div>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${season.season_poster}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover blur-[3px] opacity-50"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{nextEpisodeSchedule?.nextEpisodeSchedule &&
|
||||
showNextEpisodeSchedule && (
|
||||
<div className="p-4">
|
||||
<div className="w-full px-4 rounded-md bg-[#0088CC] flex items-center justify-between gap-x-2">
|
||||
<div className="w-full h-fit">
|
||||
<span className="text-[18px]">🚀</span>
|
||||
{" Estimated the next episode will come at "}
|
||||
<span className="text-[13.4px] font-medium">
|
||||
{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,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[25px] h-fit font-extrabold text-[#80C4E6] mb-1 cursor-pointer"
|
||||
onClick={() => setShowNextEpisodeSchedule(false)}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4 items-start ml-8 max-[1400px]:ml-0 max-[1400px]:mt-10 max-[1400px]:flex-row max-[1400px]:gap-x-6 max-[1024px]:px-[30px] max-[1024px]:mt-8 max-[500px]:mt-4 max-[500px]:px-4">
|
||||
{animeInfo && animeInfo?.poster ? (
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${animeInfo?.poster}`}
|
||||
alt=""
|
||||
className="w-[100px] h-[150px] object-cover max-[500px]:w-[70px] max-[500px]:h-[90px]"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="w-[100px] h-[150px] rounded-none" />
|
||||
)}
|
||||
<div className="flex flex-col gap-y-4 justify-start">
|
||||
{animeInfo && animeInfo?.title ? (
|
||||
<p className="text-[26px] font-medium leading-6 max-[500px]:text-[18px]">
|
||||
{language ? animeInfo?.title : animeInfo?.japanese_title}
|
||||
</p>
|
||||
) : (
|
||||
<Skeleton className="w-[170px] h-[20px] rounded-xl" />
|
||||
)}
|
||||
<div className="flex flex-wrap w-fit gap-x-[2px] gap-y-[3px]">
|
||||
{animeInfo ? (
|
||||
tags.map(
|
||||
({ condition, icon, bgColor, text }, index) =>
|
||||
condition && (
|
||||
<Tag
|
||||
key={index}
|
||||
index={index}
|
||||
bgColor={bgColor}
|
||||
icon={icon}
|
||||
text={text}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<Skeleton className="w-[70px] h-[20px] rounded-xl" />
|
||||
)}
|
||||
<div className="flex w-fit items-center ml-1">
|
||||
{[
|
||||
animeInfo?.animeInfo?.tvInfo?.showType,
|
||||
animeInfo?.animeInfo?.tvInfo?.duration,
|
||||
].map(
|
||||
(item, index) =>
|
||||
item && (
|
||||
<div
|
||||
key={index}
|
||||
className="px-1 h-fit flex items-center gap-x-2 w-fit"
|
||||
>
|
||||
<div className="dot mt-[2px]"></div>
|
||||
<p className="text-[14px]">{item}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{animeInfo ? (
|
||||
animeInfo?.animeInfo?.Overview && (
|
||||
<div className="max-h-[150px] overflow-hidden">
|
||||
<div className="max-h-[110px] mt-2 overflow-y-auto">
|
||||
<p className="text-[14px] font-[400]">
|
||||
{animeInfo?.animeInfo?.Overview.length > 270 ? (
|
||||
<>
|
||||
{isFullOverview
|
||||
? animeInfo?.animeInfo?.Overview
|
||||
: `${animeInfo?.animeInfo?.Overview.slice(
|
||||
0,
|
||||
270
|
||||
)}...`}
|
||||
<span
|
||||
className="text-[13px] font-bold hover:cursor-pointer"
|
||||
onClick={() => setIsFullOverview(!isFullOverview)}
|
||||
>
|
||||
{isFullOverview ? "- Less" : "+ More"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
animeInfo?.animeInfo?.Overview
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Skeleton className="w-[200px] h-[10px] rounded-xl" />
|
||||
<Skeleton className="w-[160px] h-[10px] rounded-xl" />
|
||||
<Skeleton className="w-[100px] h-[10px] rounded-xl" />
|
||||
<Skeleton className="w-[80px] h-[10px] rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[14px] max-[575px]:hidden">
|
||||
{`${website_name} is the best site to watch `}
|
||||
<span className="font-bold">
|
||||
{language ? animeInfo?.title : animeInfo?.japanese_title}
|
||||
</span>
|
||||
{` SUB online, or you can even watch `}
|
||||
<span className="font-bold">
|
||||
{language ? animeInfo?.title : animeInfo?.japanese_title}
|
||||
</span>
|
||||
{` DUB in HD quality.`}
|
||||
</p>
|
||||
<Link
|
||||
to={`/${animeId}`}
|
||||
className="w-fit text-[13px] bg-white rounded-[12px] px-[10px] py-1 text-black"
|
||||
>
|
||||
View detail
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex gap-x-4 items-center bg-[#191826] p-5 max-[575px]:px-3 max-[320px]:hidden">
|
||||
<img
|
||||
src="https://i.postimg.cc/d34WWyNQ/share-icon.gif"
|
||||
alt="Share Anime"
|
||||
className="w-[60px] h-auto rounded-full max-[1024px]:w-[40px] max-[575px]:hidden"
|
||||
/>
|
||||
<div className="flex flex-col w-fit">
|
||||
<p className="text-[15px] font-bold text-[#FFBADE]">Share Anime</p>
|
||||
<p className="text-[16px] text-white">to your friends</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col">
|
||||
<div className="mt-[15px] flex flex-col gap-y-7">
|
||||
{animeInfo?.charactersVoiceActors.length > 0 && (
|
||||
<Voiceactor animeInfo={animeInfo} className="!mt-0" />
|
||||
)}
|
||||
{animeInfo?.recommended_data.length > 0 ? (
|
||||
<CategoryCard
|
||||
label="Recommended for you"
|
||||
data={animeInfo?.recommended_data}
|
||||
limit={animeInfo?.recommended_data.length}
|
||||
showViewMore={false}
|
||||
/>
|
||||
) : (
|
||||
<CategoryCardLoader className={"mt-[15px]"} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{animeInfo && animeInfo.related_data ? (
|
||||
<Sidecard
|
||||
label="Related Anime"
|
||||
data={animeInfo.related_data}
|
||||
className="mt-[15px]"
|
||||
/>
|
||||
) : (
|
||||
<SidecardLoader className={"mt-[25px]"} />
|
||||
)}
|
||||
{homeInfo && homeInfo.most_popular && (
|
||||
<Sidecard
|
||||
label="Most Popular"
|
||||
data={homeInfo.most_popular.slice(0, 10)}
|
||||
className="mt-[15px]"
|
||||
limit={10}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/utils/category.utils.js
Normal file
89
src/utils/category.utils.js
Normal file
@@ -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",
|
||||
];
|
||||
18
src/utils/getAnimeInfo.utils.js
Normal file
18
src/utils/getAnimeInfo.utils.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
14
src/utils/getCategoryInfo.utils.js
Normal file
14
src/utils/getCategoryInfo.utils.js
Normal file
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user