mirror of
https://github.com/JustAnimeCore/JustAnime.git
synced 2026-04-17 22:01:45 +00:00
Landing page
This commit is contained in:
17
.env.example
17
.env.example
@@ -1,2 +1,15 @@
|
|||||||
# Your Self Hosted AniWatch API URL - replace with your own API endpoint
|
#Refer https://github.com/itzzzme/anime-api to host your backend API
|
||||||
ANIWATCH_API=https://your-api-url.com/api/v2/hianime
|
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
|
||||||
|
|||||||
158
.gitignore
vendored
158
.gitignore
vendored
@@ -1,41 +1,133 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Logs
|
||||||
|
logs
|
||||||
# dependencies
|
*.log
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
.env
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
# vercel
|
# Runtime data
|
||||||
.vercel
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
# typescript
|
# 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
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
|
||||||
|
# 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.*
|
||||||
60
LICENSE
60
LICENSE
@@ -1,47 +1,21 @@
|
|||||||
Business Source License 1.1
|
MIT License
|
||||||
|
|
||||||
Terms
|
Copyright (c) 2024 Sayan
|
||||||
|
|
||||||
The Licensor hereby grants you the right to copy, modify, create derivative
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
works, redistribute, and make non-production use of the Licensed Work. The
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
Licensor may make an Additional Use Grant, above, permitting limited
|
in the Software without restriction, including without limitation the rights
|
||||||
production use.
|
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:
|
||||||
|
|
||||||
Effective on the Change Date, or the fourth anniversary of the first publicly
|
The above copyright notice and this permission notice shall be included in all
|
||||||
available distribution of a specific version of the Licensed Work under this
|
copies or substantial portions of the Software.
|
||||||
License, whichever comes first, the Licensor hereby grants you rights under
|
|
||||||
the terms of the Change License, and the rights granted in the paragraph
|
|
||||||
above terminate.
|
|
||||||
|
|
||||||
If your use of the Licensed Work does not comply with the requirements
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
currently in effect as described in this License, you must purchase a
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
commercial license from the Licensor, its affiliated entities, or authorized
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
resellers, or you must refrain from using the Licensed Work.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
All copies of the original and modified Licensed Work, and derivative works
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
of the Licensed Work, are subject to this License. This License applies
|
SOFTWARE.
|
||||||
separately for each version of the Licensed Work and the Change Date may vary
|
|
||||||
for each version of the Licensed Work released by Licensor.
|
|
||||||
|
|
||||||
You must conspicuously display this License on each original or modified copy
|
|
||||||
of the Licensed Work. If you receive the Licensed Work in original or
|
|
||||||
modified form from a third party, the terms and conditions set forth in this
|
|
||||||
License apply to your use of that work.
|
|
||||||
|
|
||||||
Any use of the Licensed Work in violation of this License will automatically
|
|
||||||
terminate your rights under this License for the current and all other
|
|
||||||
versions of the Licensed Work.
|
|
||||||
|
|
||||||
This License does not grant you any right in any trademark or logo of
|
|
||||||
Licensor or its affiliates (provided that you may use a trademark or logo of
|
|
||||||
Licensor as expressly required by this License).
|
|
||||||
|
|
||||||
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
|
|
||||||
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
|
|
||||||
TITLE.
|
|
||||||
|
|
||||||
Change Date: 2027-04-01
|
|
||||||
|
|
||||||
On the date above, in accordance with the Business Source License, use of this software will be governed by the open source license GPL-3.0.
|
|
||||||
|
|||||||
151
README.md
151
README.md
@@ -1,74 +1,119 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://justanime.vercel.app">
|
<div align="center">
|
||||||
<img src="./public/Favicon.png" alt="JustAnime" width="160">
|
<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>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<h1 align="center"><b>JustAnime</b></h1>
|
</p>
|
||||||
|
|
||||||
<h4 align="center"><b>A sleek anime streaming platform with a modern UI</b></h4>
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#what-is-justanime">About</a> •
|
<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.
|
||||||
<a href="#features">Features</a> •
|
|
||||||
<a href="#quick-start">Quick Start</a> •
|
|
||||||
<a href="#development">Development</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<details>
|
||||||
<a href="https://github.com/tejaspanchall/JustAnime/stargazers">
|
<summary>View more Features</summary>
|
||||||
<img src="https://img.shields.io/github/stars/tejaspanchall/JustAnime?style=flat-square&color=yellow" alt="Stars" />
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/tejaspanchall/JustAnime/network/members">
|
|
||||||
<img src="https://img.shields.io/github/forks/tejaspanchall/JustAnime?style=flat-square&color=blue" alt="Forks" />
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/tejaspanchall/JustAnime/issues">
|
|
||||||
<img src="https://img.shields.io/github/issues/tejaspanchall/JustAnime?style=flat-square&color=red" alt="Issues" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## What is JustAnime?
|
### General
|
||||||
|
|
||||||
Welcome to **JustAnime**, your premier destination for all things anime! Explore a comprehensive collection of high-definition anime with a seamless and user-friendly interface powered by **[aniwatch-api](https://github.com/ghoshRitesh12/aniwatch-api)**.
|
- Sub Anime support
|
||||||
|
- Dub Anime support
|
||||||
|
- User-friendly interface
|
||||||
|
- Mobile responsive
|
||||||
|
- Fast page load
|
||||||
|
- Character & Voice Actors
|
||||||
|
|
||||||
Built using **Next.js** and **React**, JustAnime offers a cutting-edge, minimalist design that ensures both fast loading times and smooth navigation. Whether you're looking for the latest anime series or classic favorites, JustAnime has you covered with an ad-free streaming experience that supports both English subtitles and dubbed versions. Additionally, you can easily keep track of your watched episodes without the hassle of creating an account, making your viewing experience as convenient as possible.
|
### Watch Page
|
||||||
|
|
||||||
## Features
|
- Related Animes
|
||||||
|
- Recommended Animes
|
||||||
|
- Available seasons
|
||||||
|
- Estimated schedule of upcoming episodes
|
||||||
|
- **Player**
|
||||||
|
- Autoplay
|
||||||
|
- Autoskip intro/outro
|
||||||
|
- Autonext
|
||||||
|
|
||||||
### General:
|
</details>
|
||||||
|
|
||||||
* Sub/Dub Support - Switch between subbed and dubbed versions
|
## Previews
|
||||||
* Responsive Design - Optimized for all devices from mobile to desktop
|
|
||||||
* Continue Watching - Resume from where you left off
|
|
||||||
* Advanced Search - With real-time suggestions as you type
|
|
||||||
|
|
||||||
### Player Experience:
|
<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>
|
||||||
|
|
||||||
* Autoplay - Seamlessly continue to the next episode
|
## Installation and Local Development
|
||||||
* Quality Selection - Choose your preferred streaming quality
|
|
||||||
* Multiple Servers - Switch between different streaming servers
|
|
||||||
* Subtitles - Toggle subtitles on/off
|
|
||||||
* Playback Speed - Adjust video playback speed
|
|
||||||
* Audio Controls - Volume adjustment and audio boost option
|
|
||||||
|
|
||||||
## Quick Start
|
### 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
|
```bash
|
||||||
# Clone the repository & Navigate to projetc directory
|
git clone https://github.com/itzzzme/zenime.git
|
||||||
git clone https://github.com/tejaspanchall/JustAnime.git
|
cd zenime
|
||||||
cd JustAnime
|
npm install # or yarn
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Set up environment variables
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit [http://localhost:3000](http://localhost:3000) to see the application in action.
|
### 3. Refer the <a href="https://github.com/itzzzme/zenime/blob/main/.env.example">.env.example</a> to set your .env file up
|
||||||
|
|
||||||
## Development
|
## Start the server
|
||||||
|
|
||||||
Pull requests and stars are always welcome. If you encounter any bug or want to add a new feature to this api, consider creating a new [issue](https://github.com/tejaspanchall/JustAnime/issues).
|
```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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { dirname } from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [...compat.extends("next/core-web-vitals")];
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
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>
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
env: {
|
|
||||||
// Environment variables here
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
domains: [
|
|
||||||
'via.placeholder.com',
|
|
||||||
'gogocdn.net',
|
|
||||||
'cdnjs.cloudflare.com',
|
|
||||||
'img.zorores.com',
|
|
||||||
'poster.zoros.to',
|
|
||||||
'cdn.myanimelist.net',
|
|
||||||
's4.anilist.co',
|
|
||||||
'artworks.thetvdb.com',
|
|
||||||
'image.tmdb.org',
|
|
||||||
'justanimeapi.vercel.app',
|
|
||||||
'consumet.org',
|
|
||||||
'api.consumet.org',
|
|
||||||
'img.flixhq.to',
|
|
||||||
'img.bflix.to',
|
|
||||||
],
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: '**',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
unoptimized: true,
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
scrollRestoration: true,
|
|
||||||
},
|
|
||||||
serverExternalPackages: ['puppeteer-core'],
|
|
||||||
async rewrites() {
|
|
||||||
// Get the API URL from environment variable or use default
|
|
||||||
const apiUrl = process.env.ANIWATCH_API;
|
|
||||||
// Extract the base URL without the /api/v2/hianime path
|
|
||||||
const baseUrl = apiUrl.replace('/api/v2/hianime', '');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/api/v2/hianime/:path*',
|
|
||||||
destination: `${apiUrl}/:path*`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/api/anime/:path*',
|
|
||||||
destination: `${apiUrl}/anime/:path*`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: '/api/:path*',
|
|
||||||
headers: [
|
|
||||||
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
|
|
||||||
{ key: 'Access-Control-Allow-Origin', value: '*' },
|
|
||||||
{ key: 'Access-Control-Allow-Methods', value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT' },
|
|
||||||
{ key: 'Access-Control-Allow-Headers', value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization' },
|
|
||||||
{ key: 'Referrer-Policy', value: 'no-referrer-when-downgrade' },
|
|
||||||
{ key: 'Cross-Origin-Resource-Policy', value: 'cross-origin' },
|
|
||||||
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/:path*',
|
|
||||||
headers: [
|
|
||||||
{ key: 'Referrer-Policy', value: 'no-referrer-when-downgrade' },
|
|
||||||
{ key: 'Cross-Origin-Resource-Policy', value: 'cross-origin' },
|
|
||||||
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
},
|
|
||||||
webpack(config) {
|
|
||||||
config.module.rules.push({
|
|
||||||
test: /\.svg$/,
|
|
||||||
use: [{ loader: '@svgr/webpack', options: { icon: true } }],
|
|
||||||
});
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = nextConfig;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
env: {
|
|
||||||
// Environment variables here
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: '**',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
5182
package-lock.json
generated
5182
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
65
package.json
65
package.json
@@ -1,31 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "justanime",
|
"name": "justanime",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "vite build",
|
||||||
"start": "next start",
|
"lint": "eslint .",
|
||||||
"lint": "next lint"
|
"preview": "vite preview",
|
||||||
|
"host": "vite --host"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||||
"@vercel/analytics": "^1.5.0",
|
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||||
"@vercel/speed-insights": "^1.2.0",
|
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||||
"hls.js": "^1.5.7",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"next": "latest",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"proxy-from-env": "^1.1.0",
|
"artplayer": "^5.2.3",
|
||||||
"react": "latest",
|
"artplayer-plugin-chapter": "^1.0.0",
|
||||||
"react-dom": "latest",
|
"artplayer-plugin-hls-control": "^1.0.1",
|
||||||
"swiper": "^11.2.6"
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/js": "^9.9.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@types/react": "^18.3.3",
|
||||||
"autoprefixer": "latest",
|
"@types/react-dom": "^18.3.0",
|
||||||
"eslint": "^9",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"eslint-config-next": "15.2.5",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "latest",
|
"eslint": "^9.9.0",
|
||||||
"tailwindcss": "^4"
|
"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: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: ["@tailwindcss/postcss"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
Binary file not shown.
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
|
Before Width: | Height: | Size: 151 KiB 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;
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import React, { Suspense } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { fetchAnimeInfo } from '@/lib/api';
|
|
||||||
import AnimeDetails from '@/components/AnimeDetails.js';
|
|
||||||
|
|
||||||
// Loading state component
|
|
||||||
const LoadingState = () => (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
{/* Background Placeholder */}
|
|
||||||
<div className="h-[400px] bg-gray-800"></div>
|
|
||||||
|
|
||||||
{/* Content Placeholder */}
|
|
||||||
<div className="container mx-auto px-4 -mt-32">
|
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
|
||||||
{/* Poster Placeholder */}
|
|
||||||
<div className="w-full md:w-1/4">
|
|
||||||
<div className="bg-gray-700 rounded-lg aspect-[3/4]"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details Placeholder */}
|
|
||||||
<div className="w-full md:w-3/4">
|
|
||||||
<div className="h-8 bg-gray-700 rounded mb-4 w-3/4"></div>
|
|
||||||
<div className="h-4 bg-gray-700 rounded mb-2 w-1/2"></div>
|
|
||||||
<div className="h-4 bg-gray-700 rounded mb-6 w-1/3"></div>
|
|
||||||
<div className="h-28 bg-gray-700 rounded mb-4"></div>
|
|
||||||
<div className="h-10 bg-gray-700 rounded w-40"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Error state component
|
|
||||||
const ErrorState = ({ error }) => (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="text-center max-w-lg mx-auto p-6">
|
|
||||||
<h1 className="text-2xl font-bold text-white mb-4">Error Loading Anime</h1>
|
|
||||||
<p className="text-gray-400 mb-6">{error}</p>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="px-6 py-3 bg-[var(--primary)] text-[var(--background)] rounded-lg hover:opacity-90 transition-opacity block w-full mb-4"
|
|
||||||
>
|
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/home"
|
|
||||||
className="px-6 py-3 bg-gray-700 text-white rounded-lg hover:opacity-90 transition-opacity inline-block"
|
|
||||||
>
|
|
||||||
Go Back Home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Not found state component
|
|
||||||
const NotFoundState = () => (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-2xl font-bold text-white mb-4">Anime Not Found</h1>
|
|
||||||
<p className="text-gray-400 mb-6">The anime you're looking for doesn't exist or was removed.</p>
|
|
||||||
<Link href="/home" className="px-6 py-3 bg-[var(--primary)] text-[var(--background)] rounded-lg hover:opacity-90 transition-opacity">
|
|
||||||
Go Back Home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Main anime content component
|
|
||||||
const AnimeContent = async ({ id }) => {
|
|
||||||
try {
|
|
||||||
const anime = await fetchAnimeInfo(id);
|
|
||||||
|
|
||||||
if (!anime || !anime.info) {
|
|
||||||
return <NotFoundState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen pb-12 mt-1.5">
|
|
||||||
<AnimeDetails anime={anime} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return <ErrorState error={error.message || 'An error occurred while loading the anime.'} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main page component with Suspense
|
|
||||||
export default function AnimeInfoPage({ params }) {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<LoadingState />}>
|
|
||||||
<AnimeContent id={id} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function AnimeLayout({ children }) {
|
|
||||||
return <SharedLayout>{children}</SharedLayout>;
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function ContactsPage() {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
subject: '',
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [submitStatus, setSubmitStatus] = useState(null);
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
// Simulate form submission
|
|
||||||
try {
|
|
||||||
// In a real application, you would send this data to your backend/API
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
setSubmitStatus({ success: true, message: 'Your message has been sent successfully!' });
|
|
||||||
// Reset form
|
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
subject: '',
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setSubmitStatus({ success: false, message: 'There was an error sending your message. Please try again.' });
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SharedLayout>
|
|
||||||
<div className="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-6">Contact Us</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
|
||||||
<div>
|
|
||||||
<div className="mb-8">
|
|
||||||
<p className="text-gray-400">
|
|
||||||
Have questions, suggestions, or need assistance? We're here to help.
|
|
||||||
Fill out the form and we'll get back to you as soon as possible.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-medium mb-2">Email</h3>
|
|
||||||
<p className="text-gray-400">support@justanime.com</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-medium mb-2">Connect With Us</h3>
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<a href="#" className="text-gray-400 hover:text-white transition-colors duration-200">
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="#" className="text-gray-400 hover:text-white transition-colors duration-200">
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-300">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-gray-400 focus:border-gray-400 text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-gray-400 focus:border-gray-400 text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-300">
|
|
||||||
Subject
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="subject"
|
|
||||||
name="subject"
|
|
||||||
value={formData.subject}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-gray-400 focus:border-gray-400 text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-gray-300">
|
|
||||||
Message
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="message"
|
|
||||||
name="message"
|
|
||||||
rows={5}
|
|
||||||
value={formData.message}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="mt-1 block w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-gray-400 focus:border-gray-400 text-white"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="w-full flex justify-center py-2 px-4 border border-gray-600 rounded-md shadow-sm text-sm font-medium text-white bg-gray-800 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 disabled:opacity-50 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Sending...' : 'Send Message'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{submitStatus && (
|
|
||||||
<div className={`text-sm ${submitStatus.success ? 'text-gray-300' : 'text-gray-400'}`}>
|
|
||||||
{submitStatus.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SharedLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function DmcaPage() {
|
|
||||||
return (
|
|
||||||
<SharedLayout>
|
|
||||||
<div className="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-8">DMCA Policy</h1>
|
|
||||||
|
|
||||||
<div className="prose prose-invert prose-lg max-w-none">
|
|
||||||
<div className="space-y-6 text-gray-400">
|
|
||||||
<p>
|
|
||||||
We take the intellectual property rights of others seriously and require that our Users do the same.
|
|
||||||
The Digital Millennium Copyright Act (DMCA) established a process for addressing claims of copyright infringement.
|
|
||||||
If you own a copyright or have authority to act on behalf of a copyright owner and want to report a claim that a third party is
|
|
||||||
infringing that material on or through JustAnime's services, please submit a DMCA report as outlined below, and we will take appropriate action.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-white mt-8 mb-4">DMCA Report Requirements</h2>
|
|
||||||
<ul className="list-disc pl-6 space-y-2">
|
|
||||||
<li>A description of the copyrighted work that you claim is being infringed;</li>
|
|
||||||
<li>A description of the material you claim is infringing and that you want removed or access to which you want disabled and the URL or other location of that material;</li>
|
|
||||||
<li>Your name, title (if acting as an agent), address, telephone number, and email address;</li>
|
|
||||||
<li>The following statement: "I have a good faith belief that the use of the copyrighted material I am complaining of is not authorized by the copyright owner, its agent, or the law (e.g., as a fair use)";</li>
|
|
||||||
<li>The following statement: "The information in this notice is accurate and, under penalty of perjury, I am the owner, or authorized to act on behalf of the owner, of the copyright or of an exclusive right that is allegedly infringed";</li>
|
|
||||||
<li>An electronic or physical signature of the owner of the copyright or a person authorized to act on the owner's behalf.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<p>
|
|
||||||
Your DMCA takedown request should be submitted through our <a href="/contacts" className="text-gray-300 hover:text-white transition-colors duration-200 underline">Contact page</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-4">
|
|
||||||
We will then review your DMCA request and take proper actions, including removal of the content from the website.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-800/40 p-6 rounded-lg mt-8 border border-gray-700">
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-4">Submit a DMCA Request</h3>
|
|
||||||
<p>
|
|
||||||
To submit a DMCA takedown request, please include all required information as listed above and
|
|
||||||
contact us through our <a href="/contacts" className="text-gray-300 hover:text-white transition-colors duration-200 underline">Contact page</a>.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4">
|
|
||||||
<a
|
|
||||||
href="/contacts"
|
|
||||||
className="inline-flex items-center px-5 py-2 border border-gray-600 text-sm font-medium rounded-md text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-700 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Go to Contact Page
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SharedLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,173 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ffffff;
|
|
||||||
--primary: #d1d1d1;
|
|
||||||
--secondary: #404040;
|
|
||||||
--accent: #808080;
|
|
||||||
--card: #131313;
|
|
||||||
--border: #2a2a2a;
|
|
||||||
--hover: #333333;
|
|
||||||
--text-muted: #8a8a8a;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-hover: var(--hover);
|
|
||||||
--color-text-muted: var(--text-muted);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar for IE, Edge and Firefox */
|
|
||||||
.scrollbar-hide {
|
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modern card styling */
|
|
||||||
.card-hover {
|
|
||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-hover:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gradient text */
|
|
||||||
.gradient-text {
|
|
||||||
background: linear-gradient(to right, var(--foreground), var(--text-muted));
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button styles */
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--foreground);
|
|
||||||
color: var(--background);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--foreground);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background-color: var(--hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation utilities */
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.5s ease-in forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
0% { opacity: 0; }
|
|
||||||
100% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-up {
|
|
||||||
animation: slideUp 0.5s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
0% { transform: translateY(20px); opacity: 0; }
|
|
||||||
100% { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Radial gradient for hero sections */
|
|
||||||
.bg-radial-gradient {
|
|
||||||
background: radial-gradient(circle at center, rgba(20, 20, 20, 0) 0%, rgba(0, 0, 0, 0.8) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grid Pattern for backgrounds */
|
|
||||||
.bg-grid-pattern {
|
|
||||||
background-size: 25px 25px;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbar while maintaining scroll functionality */
|
|
||||||
.hide-scrollbar {
|
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide-scrollbar::-webkit-scrollbar {
|
|
||||||
display: none; /* Chrome, Safari and Opera */
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function HomeLayout({ children }) {
|
|
||||||
return <SharedLayout>{children}</SharedLayout>;
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import AnimeCard from '@/components/AnimeCard';
|
|
||||||
import TopLists from '@/components/TopLists';
|
|
||||||
import AnimeCalendar from '@/components/AnimeCalendar';
|
|
||||||
import GenreBar from '@/components/GenreBar';
|
|
||||||
import SpotlightCarousel from '@/components/SpotlightCarousel';
|
|
||||||
import AnimeTabs from '@/components/AnimeTabs';
|
|
||||||
import TrendingList from '@/components/TrendingList';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {
|
|
||||||
fetchRecentEpisodes,
|
|
||||||
fetchMostFavorite,
|
|
||||||
fetchSpotlightAnime,
|
|
||||||
fetchTopToday,
|
|
||||||
fetchTopWeek,
|
|
||||||
fetchTopMonth,
|
|
||||||
fetchMostPopular,
|
|
||||||
fetchTopAiring,
|
|
||||||
fetchLatestCompleted,
|
|
||||||
fetchTrending
|
|
||||||
} from '@/lib/api';
|
|
||||||
|
|
||||||
// New unified section component with grid layout
|
|
||||||
const AnimeGridSection = ({ title, animeList = [], viewMoreLink, isRecent = false }) => {
|
|
||||||
if (!animeList || animeList.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="mb-10">
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<h2 className="text-xl font-semibold text-white">{title}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="bg-[var(--card)] rounded-lg p-12 text-center text-[var(--text-muted)] border border-[var(--border)]">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-6 w-32 bg-[var(--border)] rounded mx-auto mb-4"></div>
|
|
||||||
<div className="h-4 w-48 bg-[var(--border)] rounded mx-auto"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-10">
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<h2 className="text-xl font-semibold text-white">{title}</h2>
|
|
||||||
{viewMoreLink && (
|
|
||||||
<Link
|
|
||||||
href={viewMoreLink}
|
|
||||||
className="text-[var(--text-muted)] hover:text-white text-sm transition-colors flex items-center"
|
|
||||||
prefetch={false}
|
|
||||||
>
|
|
||||||
<span>View All</span>
|
|
||||||
<svg className="ml-1 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
|
||||||
{animeList.slice(0, 12).map((anime, index) => (
|
|
||||||
<AnimeCard
|
|
||||||
key={anime.id + '-' + index}
|
|
||||||
anime={anime}
|
|
||||||
isRecent={isRecent}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
async function HomePage() {
|
|
||||||
try {
|
|
||||||
console.log('[HomePage] Fetching home page data');
|
|
||||||
|
|
||||||
// Fetch all data in parallel
|
|
||||||
const [
|
|
||||||
spotlightData,
|
|
||||||
recentEpisodes,
|
|
||||||
mostFavorite,
|
|
||||||
topToday,
|
|
||||||
topWeek,
|
|
||||||
topMonth,
|
|
||||||
topAiring,
|
|
||||||
popular,
|
|
||||||
latestCompleted,
|
|
||||||
trending
|
|
||||||
] = await Promise.all([
|
|
||||||
fetchSpotlightAnime().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching spotlight anime:", err.message);
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
fetchRecentEpisodes().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching recent episodes:", err.message);
|
|
||||||
return { results: [] };
|
|
||||||
}),
|
|
||||||
fetchMostFavorite().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching most favorite:", err.message);
|
|
||||||
return { results: [] };
|
|
||||||
}),
|
|
||||||
fetchTopToday().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching top today:", err.message);
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
fetchTopWeek().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching top week:", err.message);
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
fetchTopMonth().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching top month:", err.message);
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
fetchTopAiring().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching top airing anime:", err.message);
|
|
||||||
return { results: [] };
|
|
||||||
}),
|
|
||||||
fetchMostPopular().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching popular anime:", err.message);
|
|
||||||
return { results: [] };
|
|
||||||
}),
|
|
||||||
fetchLatestCompleted().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching latest completed anime:", err.message);
|
|
||||||
return { results: [] };
|
|
||||||
}),
|
|
||||||
fetchTrending().catch(err => {
|
|
||||||
console.error("[HomePage] Error fetching trending anime:", err.message);
|
|
||||||
return { results: [] };
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log('[HomePage] Data fetched successfully');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="py-6 bg-[var(--background)] text-white">
|
|
||||||
<div className="w-full px-4 md:px-[4rem]">
|
|
||||||
{/* Spotlight Carousel */}
|
|
||||||
<SpotlightCarousel items={spotlightData} />
|
|
||||||
|
|
||||||
{/* Genre Bar */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<GenreBar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content + Sidebar Layout */}
|
|
||||||
<div className="flex flex-col lg:flex-row lg:gap-6">
|
|
||||||
{/* Main Content - 2/3 width on large screens */}
|
|
||||||
<div className="lg:w-3/4">
|
|
||||||
{/* Latest Episodes Grid */}
|
|
||||||
<AnimeGridSection
|
|
||||||
title="Latest Episodes"
|
|
||||||
animeList={recentEpisodes?.results || []}
|
|
||||||
viewMoreLink="/recent"
|
|
||||||
isRecent={true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Anime Tabs Section */}
|
|
||||||
<AnimeTabs
|
|
||||||
topAiring={topAiring?.results || []}
|
|
||||||
popular={popular?.results || []}
|
|
||||||
latestCompleted={latestCompleted?.results || []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar - 1/4 width on large screens */}
|
|
||||||
<div className="lg:w-1/4 mt-8 lg:mt-0">
|
|
||||||
{/* Trending List */}
|
|
||||||
<TrendingList trendingAnime={trending?.results || []} />
|
|
||||||
|
|
||||||
{/* Calendar Widget */}
|
|
||||||
<AnimeCalendar />
|
|
||||||
|
|
||||||
{/* Top Lists */}
|
|
||||||
<TopLists
|
|
||||||
topToday={topToday}
|
|
||||||
topWeek={topWeek}
|
|
||||||
topMonth={topMonth}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[HomePage] Error in HomePage:', error.message);
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
|
||||||
<div className="max-w-lg w-full bg-[var(--card)] border border-[var(--border)] p-6 rounded-lg text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-white mb-4">Unable to load content</h2>
|
|
||||||
<p className="text-[var(--text-muted)] mb-6">There was an error loading the home page content. Please try refreshing the page.</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="px-6 py-2 bg-[var(--primary)] text-white rounded-md hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
Refresh Page
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HomePage;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function LatestCompletedLayout({ children }) {
|
|
||||||
return <SharedLayout>{children}</SharedLayout>;
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import AnimeCard from '@/components/AnimeCard';
|
|
||||||
import AnimeFilters from '@/components/AnimeFilters';
|
|
||||||
import { fetchLatestCompleted } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function LatestCompletedPage() {
|
|
||||||
const [animeList, setAnimeList] = useState([]);
|
|
||||||
const [filteredList, setFilteredList] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
|
||||||
const [selectedGenre, setSelectedGenre] = useState(null);
|
|
||||||
const [yearFilter, setYearFilter] = useState('all');
|
|
||||||
const [sortOrder, setSortOrder] = useState('default');
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedSeasons, setSelectedSeasons] = useState([]);
|
|
||||||
const [selectedTypes, setSelectedTypes] = useState([]);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState([]);
|
|
||||||
const [selectedLanguages, setSelectedLanguages] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Current year for filtering
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
// Add ref to track if this is the first render
|
|
||||||
const initialRender = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchLatestCompleted(currentPage);
|
|
||||||
|
|
||||||
if (currentPage === 1) {
|
|
||||||
setAnimeList(data.results || []);
|
|
||||||
} else {
|
|
||||||
setAnimeList(prev => [...prev, ...(data.results || [])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasNextPage(data.hasNextPage || false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching latest completed anime:', error);
|
|
||||||
setError('Failed to load anime. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// Apply filters and sorting whenever the anime list or filter settings change
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip the initial render effect to avoid duplicate filtering
|
|
||||||
if (initialRender.current) {
|
|
||||||
initialRender.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!animeList.length) {
|
|
||||||
setFilteredList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = [...animeList];
|
|
||||||
|
|
||||||
// Search filter
|
|
||||||
if (searchQuery && searchQuery.trim() !== '') {
|
|
||||||
const query = searchQuery.toLowerCase().trim();
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const title = (anime.title || '').toLowerCase();
|
|
||||||
const otherNames = (anime.otherNames || '').toLowerCase();
|
|
||||||
return title.includes(query) || otherNames.includes(query);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by genre if selected
|
|
||||||
if (selectedGenre) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (anime.genres && Array.isArray(anime.genres)) {
|
|
||||||
return anime.genres.some(g =>
|
|
||||||
g.toLowerCase() === selectedGenre.toLowerCase() ||
|
|
||||||
(g.name && g.name.toLowerCase() === selectedGenre.toLowerCase())
|
|
||||||
);
|
|
||||||
} else if (anime.genre) {
|
|
||||||
return anime.genre.toLowerCase().includes(selectedGenre.toLowerCase());
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by season
|
|
||||||
if (selectedSeasons.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const season = getAnimeSeason(anime);
|
|
||||||
return selectedSeasons.includes(season);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by year
|
|
||||||
if (yearFilter !== 'all') {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const animeYear = parseInt(anime.year) || 0;
|
|
||||||
if (yearFilter === 'older') {
|
|
||||||
return animeYear < 2000;
|
|
||||||
} else {
|
|
||||||
return animeYear.toString() === yearFilter;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by type
|
|
||||||
if (selectedTypes.length > 0) {
|
|
||||||
result = result.filter(anime =>
|
|
||||||
selectedTypes.includes(anime.type)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status
|
|
||||||
if (selectedStatus.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const status = anime.status || getDefaultStatus(anime);
|
|
||||||
return selectedStatus.includes(status);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by language
|
|
||||||
if (selectedLanguages.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const language = anime.language || getDefaultLanguage(anime);
|
|
||||||
return selectedLanguages.includes(language);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
switch (sortOrder) {
|
|
||||||
case 'title-asc':
|
|
||||||
result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
|
||||||
break;
|
|
||||||
case 'title-desc':
|
|
||||||
result.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
|
||||||
break;
|
|
||||||
case 'year-desc':
|
|
||||||
result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0));
|
|
||||||
break;
|
|
||||||
case 'year-asc':
|
|
||||||
result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Default order from API
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredList(result);
|
|
||||||
}, [animeList, selectedGenre, yearFilter, sortOrder, searchQuery, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
setCurrentPage(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenreChange = (genre) => {
|
|
||||||
setSelectedGenre(genre);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearChange = (year) => {
|
|
||||||
setYearFilter(year);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (order) => {
|
|
||||||
setSortOrder(order);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (value) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeasonChange = (seasons) => {
|
|
||||||
setSelectedSeasons(seasons);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTypeChange = (types) => {
|
|
||||||
setSelectedTypes(types);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (status) => {
|
|
||||||
setSelectedStatus(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLanguageChange = (languages) => {
|
|
||||||
setSelectedLanguages(languages);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime season based on available data
|
|
||||||
const getAnimeSeason = (anime) => {
|
|
||||||
if (anime.season) return anime.season;
|
|
||||||
|
|
||||||
const seasons = ['Winter', 'Spring', 'Summer', 'Fall'];
|
|
||||||
// Use hash of ID to assign consistent season for demo purposes
|
|
||||||
const hash = anime.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
||||||
return seasons[hash % 4];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime status
|
|
||||||
const getDefaultStatus = (anime) => {
|
|
||||||
if (anime.status) return anime.status;
|
|
||||||
|
|
||||||
// Default logic - you may need to customize this based on your actual data
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
if (anime.year > currentYear) return 'Upcoming';
|
|
||||||
if (anime.totalEpisodes && anime.episodes && anime.episodes >= anime.totalEpisodes) return 'Completed';
|
|
||||||
return 'Ongoing';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime language
|
|
||||||
const getDefaultLanguage = (anime) => {
|
|
||||||
if (anime.language) return anime.language;
|
|
||||||
|
|
||||||
// Default to "Subbed" for all anime unless specifically marked
|
|
||||||
return anime.isDub ? 'Dubbed' : 'Subbed';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 md:px-[4rem] py-8">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold mb-6 text-white">Latest Completed Anime</h1>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<AnimeFilters
|
|
||||||
selectedGenre={selectedGenre}
|
|
||||||
onGenreChange={handleGenreChange}
|
|
||||||
yearFilter={yearFilter}
|
|
||||||
onYearChange={handleYearChange}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
onSortChange={handleSortChange}
|
|
||||||
showGenreFilter={true}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
selectedSeasons={selectedSeasons}
|
|
||||||
onSeasonChange={handleSeasonChange}
|
|
||||||
selectedTypes={selectedTypes}
|
|
||||||
onTypeChange={handleTypeChange}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
selectedLanguages={selectedLanguages}
|
|
||||||
onLanguageChange={handleLanguageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && animeList.length === 0 ? (
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{[...Array(14)].map((_, index) => (
|
|
||||||
<div key={index} className="bg-gray-800 rounded-lg overflow-hidden shadow animate-pulse h-64">
|
|
||||||
<div className="w-full h-40 bg-gray-700"></div>
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="h-4 bg-gray-700 rounded mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-700 rounded w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (filteredList.length > 0 || animeList.length > 0) ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{(filteredList.length > 0 ? filteredList : animeList).map((anime) => (
|
|
||||||
<AnimeCard key={anime.id} anime={anime} isRecent={true} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNextPage && (
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<button
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`px-6 py-3 bg-[var(--secondary)] text-white rounded-md ${
|
|
||||||
isLoading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-opacity-90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Load More'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="bg-gray-800 rounded-lg p-8 text-center">
|
|
||||||
<h3 className="text-xl font-medium text-white mb-2">No anime found</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
We couldn't find any anime matching your criteria. Please try different filters.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import { Analytics } from "@vercel/analytics/next";
|
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "JustAnime - Watch Anime Online",
|
|
||||||
description: "Watch the latest anime episodes for free. Stream all your favorite anime shows in HD quality.",
|
|
||||||
keywords: "anime, streaming, watch anime, free anime, anime online, just , justanime",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} bg-[#0a0a0a] min-h-screen flex flex-col antialiased`}>
|
|
||||||
<main className="flex-grow">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Analytics />
|
|
||||||
<SpeedInsights />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function MostPopularLayout({ children }) {
|
|
||||||
return <SharedLayout>{children}</SharedLayout>;
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import AnimeCard from '@/components/AnimeCard';
|
|
||||||
import AnimeFilters from '@/components/AnimeFilters';
|
|
||||||
import { fetchMostPopular } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function MostPopularPage() {
|
|
||||||
const [animeList, setAnimeList] = useState([]);
|
|
||||||
const [filteredList, setFilteredList] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
|
||||||
const [selectedGenre, setSelectedGenre] = useState(null);
|
|
||||||
const [yearFilter, setYearFilter] = useState('all');
|
|
||||||
const [sortOrder, setSortOrder] = useState('default');
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedSeasons, setSelectedSeasons] = useState([]);
|
|
||||||
const [selectedTypes, setSelectedTypes] = useState([]);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState([]);
|
|
||||||
const [selectedLanguages, setSelectedLanguages] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Current year for filtering
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
// Add ref to track if this is the first render
|
|
||||||
const initialRender = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchMostPopular(currentPage);
|
|
||||||
|
|
||||||
if (currentPage === 1) {
|
|
||||||
setAnimeList(data.results || []);
|
|
||||||
} else {
|
|
||||||
setAnimeList(prev => [...prev, ...(data.results || [])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasNextPage(data.hasNextPage || false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching most popular anime:', error);
|
|
||||||
setError('Failed to load anime. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// Apply filters and sorting whenever the anime list or filter settings change
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip the initial render effect to avoid duplicate filtering
|
|
||||||
if (initialRender.current) {
|
|
||||||
initialRender.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!animeList.length) {
|
|
||||||
setFilteredList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = [...animeList];
|
|
||||||
|
|
||||||
// Search filter
|
|
||||||
if (searchQuery && searchQuery.trim() !== '') {
|
|
||||||
const query = searchQuery.toLowerCase().trim();
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const title = (anime.title || '').toLowerCase();
|
|
||||||
const otherNames = (anime.otherNames || '').toLowerCase();
|
|
||||||
return title.includes(query) || otherNames.includes(query);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by genre if selected
|
|
||||||
if (selectedGenre) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (anime.genres && Array.isArray(anime.genres)) {
|
|
||||||
return anime.genres.some(g =>
|
|
||||||
g.toLowerCase() === selectedGenre.toLowerCase() ||
|
|
||||||
(g.name && g.name.toLowerCase() === selectedGenre.toLowerCase())
|
|
||||||
);
|
|
||||||
} else if (anime.genre) {
|
|
||||||
return anime.genre.toLowerCase().includes(selectedGenre.toLowerCase());
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by season
|
|
||||||
if (selectedSeasons.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const season = getAnimeSeason(anime);
|
|
||||||
return selectedSeasons.includes(season);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by year
|
|
||||||
if (yearFilter !== 'all') {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const animeYear = parseInt(anime.year) || 0;
|
|
||||||
if (yearFilter === 'older') {
|
|
||||||
return animeYear < 2000;
|
|
||||||
} else {
|
|
||||||
return animeYear.toString() === yearFilter;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by type
|
|
||||||
if (selectedTypes.length > 0) {
|
|
||||||
result = result.filter(anime =>
|
|
||||||
selectedTypes.includes(anime.type)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status
|
|
||||||
if (selectedStatus.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const status = anime.status || getDefaultStatus(anime);
|
|
||||||
return selectedStatus.includes(status);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by language
|
|
||||||
if (selectedLanguages.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const language = anime.language || getDefaultLanguage(anime);
|
|
||||||
return selectedLanguages.includes(language);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
switch (sortOrder) {
|
|
||||||
case 'title-asc':
|
|
||||||
result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
|
||||||
break;
|
|
||||||
case 'title-desc':
|
|
||||||
result.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
|
||||||
break;
|
|
||||||
case 'year-desc':
|
|
||||||
result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0));
|
|
||||||
break;
|
|
||||||
case 'year-asc':
|
|
||||||
result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Default order from API
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredList(result);
|
|
||||||
}, [animeList, selectedGenre, yearFilter, sortOrder, searchQuery, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
setCurrentPage(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenreChange = (genre) => {
|
|
||||||
setSelectedGenre(genre);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearChange = (year) => {
|
|
||||||
setYearFilter(year);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (order) => {
|
|
||||||
setSortOrder(order);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (value) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeasonChange = (seasons) => {
|
|
||||||
setSelectedSeasons(seasons);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTypeChange = (types) => {
|
|
||||||
setSelectedTypes(types);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (status) => {
|
|
||||||
setSelectedStatus(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLanguageChange = (languages) => {
|
|
||||||
setSelectedLanguages(languages);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime season based on available data
|
|
||||||
const getAnimeSeason = (anime) => {
|
|
||||||
if (anime.season) return anime.season;
|
|
||||||
|
|
||||||
const seasons = ['Winter', 'Spring', 'Summer', 'Fall'];
|
|
||||||
// Use hash of ID to assign consistent season for demo purposes
|
|
||||||
const hash = anime.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
||||||
return seasons[hash % 4];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime status
|
|
||||||
const getDefaultStatus = (anime) => {
|
|
||||||
if (anime.status) return anime.status;
|
|
||||||
|
|
||||||
// Default logic - you may need to customize this based on your actual data
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
if (anime.year > currentYear) return 'Upcoming';
|
|
||||||
if (anime.totalEpisodes && anime.episodes && anime.episodes >= anime.totalEpisodes) return 'Completed';
|
|
||||||
return 'Ongoing';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime language
|
|
||||||
const getDefaultLanguage = (anime) => {
|
|
||||||
if (anime.language) return anime.language;
|
|
||||||
|
|
||||||
// Default to "Subbed" for all anime unless specifically marked
|
|
||||||
return anime.isDub ? 'Dubbed' : 'Subbed';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 md:px-[4rem] py-8">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold mb-6 text-white">Most Popular Anime</h1>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<AnimeFilters
|
|
||||||
selectedGenre={selectedGenre}
|
|
||||||
onGenreChange={handleGenreChange}
|
|
||||||
yearFilter={yearFilter}
|
|
||||||
onYearChange={handleYearChange}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
onSortChange={handleSortChange}
|
|
||||||
showGenreFilter={true}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
selectedSeasons={selectedSeasons}
|
|
||||||
onSeasonChange={handleSeasonChange}
|
|
||||||
selectedTypes={selectedTypes}
|
|
||||||
onTypeChange={handleTypeChange}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
selectedLanguages={selectedLanguages}
|
|
||||||
onLanguageChange={handleLanguageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && animeList.length === 0 ? (
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{[...Array(14)].map((_, index) => (
|
|
||||||
<div key={index} className="bg-gray-800 rounded-lg overflow-hidden shadow animate-pulse h-64">
|
|
||||||
<div className="w-full h-40 bg-gray-700"></div>
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="h-4 bg-gray-700 rounded mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-700 rounded w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (filteredList.length > 0 || animeList.length > 0) ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{(filteredList.length > 0 ? filteredList : animeList).map((anime) => (
|
|
||||||
<AnimeCard key={anime.id} anime={anime} isRecent={true} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNextPage && (
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<button
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`px-6 py-3 bg-[var(--secondary)] text-white rounded-md ${
|
|
||||||
isLoading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-opacity-90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Load More'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="bg-gray-800 rounded-lg p-8 text-center">
|
|
||||||
<h3 className="text-xl font-medium text-white mb-2">No anime found</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
We couldn't find any anime matching your criteria. Please try different filters.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
321
src/app/page.jsx
321
src/app/page.jsx
@@ -1,321 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { fetchSearchSuggestions } from '@/lib/api';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
export default function LandingPage() {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [searchSuggestions, setSearchSuggestions] = useState([]);
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const suggestionRef = useRef(null);
|
|
||||||
const searchInputRef = useRef(null);
|
|
||||||
|
|
||||||
// For FAQ dropdowns
|
|
||||||
const [openFAQ, setOpenFAQ] = useState(null);
|
|
||||||
|
|
||||||
const toggleFAQ = (index) => {
|
|
||||||
setOpenFAQ(openFAQ === index ? null : index);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch search suggestions when search query changes
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSuggestions = async () => {
|
|
||||||
if (searchQuery.trim().length > 2) {
|
|
||||||
try {
|
|
||||||
const suggestions = await fetchSearchSuggestions(searchQuery);
|
|
||||||
// Update to use the same format as home page search
|
|
||||||
setSearchSuggestions(suggestions || []);
|
|
||||||
setShowSuggestions(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching search suggestions:', error);
|
|
||||||
setSearchSuggestions([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSearchSuggestions([]);
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debounceTimer = setTimeout(() => {
|
|
||||||
fetchSuggestions();
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
return () => clearTimeout(debounceTimer);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
// Close suggestions when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (
|
|
||||||
suggestionRef.current &&
|
|
||||||
!suggestionRef.current.contains(event.target) &&
|
|
||||||
!searchInputRef.current?.contains(event.target)
|
|
||||||
) {
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSearch = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
router.push(`/search?q=${encodeURIComponent(searchQuery)}`);
|
|
||||||
setSearchQuery('');
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestionClick = (suggestion) => {
|
|
||||||
// Updated to handle object-based suggestions
|
|
||||||
const query = suggestion.title || suggestion;
|
|
||||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
|
||||||
setSearchQuery('');
|
|
||||||
setShowSuggestions(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[var(--background)] flex flex-col relative">
|
|
||||||
{/* Background Image with Fade Effect */}
|
|
||||||
<div className="fixed inset-0 w-full h-full overflow-hidden z-0">
|
|
||||||
<Image
|
|
||||||
src="/LandingPage.jpg"
|
|
||||||
alt="Dark anime character background"
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
className="object-cover opacity-45"
|
|
||||||
sizes="100vw"
|
|
||||||
style={{ objectPosition: 'center' }}
|
|
||||||
/>
|
|
||||||
{/* Ultra-smooth gradient for fade from bottom */}
|
|
||||||
<div className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to top,
|
|
||||||
var(--background) 0%,
|
|
||||||
var(--background) 25%,
|
|
||||||
rgba(10,10,10,0.97) 35%,
|
|
||||||
rgba(10,10,10,0.95) 40%,
|
|
||||||
rgba(10,10,10,0.93) 42%,
|
|
||||||
rgba(10,10,10,0.90) 44%,
|
|
||||||
rgba(10,10,10,0.87) 46%,
|
|
||||||
rgba(10,10,10,0.84) 48%,
|
|
||||||
rgba(10,10,10,0.81) 50%,
|
|
||||||
rgba(10,10,10,0.78) 52%,
|
|
||||||
rgba(10,10,10,0.75) 54%,
|
|
||||||
rgba(10,10,10,0.72) 56%,
|
|
||||||
rgba(10,10,10,0.69) 58%,
|
|
||||||
rgba(10,10,10,0.66) 60%,
|
|
||||||
rgba(10,10,10,0.63) 62%,
|
|
||||||
rgba(10,10,10,0.60) 64%,
|
|
||||||
rgba(10,10,10,0.57) 66%,
|
|
||||||
rgba(10,10,10,0.54) 68%,
|
|
||||||
rgba(10,10,10,0.51) 70%,
|
|
||||||
rgba(10,10,10,0.48) 72%,
|
|
||||||
rgba(10,10,10,0.45) 74%,
|
|
||||||
rgba(10,10,10,0.42) 76%,
|
|
||||||
rgba(10,10,10,0.39) 78%,
|
|
||||||
rgba(10,10,10,0.36) 80%,
|
|
||||||
rgba(10,10,10,0.33) 82%,
|
|
||||||
rgba(10,10,10,0.30) 84%,
|
|
||||||
rgba(10,10,10,0.27) 86%,
|
|
||||||
rgba(10,10,10,0.24) 88%,
|
|
||||||
rgba(10,10,10,0.21) 90%,
|
|
||||||
rgba(10,10,10,0.18) 92%,
|
|
||||||
rgba(10,10,10,0.15) 94%,
|
|
||||||
rgba(10,10,10,0.12) 96%,
|
|
||||||
rgba(10,10,10,0.09) 98%,
|
|
||||||
rgba(10,10,10,0.06) 100%)`
|
|
||||||
}}>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unified Content Section */}
|
|
||||||
<section className="relative flex flex-col items-center text-center px-4 py-6 z-10">
|
|
||||||
{/* Hero Content */}
|
|
||||||
<div className="w-full max-w-3xl mx-auto flex flex-col items-center mb-20">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="mb-8 pt-16 md:pt-24 lg:pt-32">
|
|
||||||
<Image
|
|
||||||
src="/Logo.png"
|
|
||||||
alt="JustAnime Logo"
|
|
||||||
width={200}
|
|
||||||
height={60}
|
|
||||||
className="mx-auto"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="w-full max-w-xl mb-8 relative">
|
|
||||||
<form onSubmit={handleSearch} className="flex items-center">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Search anime..."
|
|
||||||
className="w-full px-5 py-4 pl-12 rounded-lg bg-[var(--card)] bg-opacity-80 backdrop-blur-sm border border-[var(--border)] text-white placeholder-[var(--text-muted)] focus:outline-none focus:ring-1 focus:ring-white focus:border-transparent"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onFocus={() => searchSuggestions.length > 0 && setShowSuggestions(true)}
|
|
||||||
/>
|
|
||||||
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 text-[var(--text-muted)]">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Search Suggestions Dropdown */}
|
|
||||||
{showSuggestions && searchSuggestions.length > 0 && (
|
|
||||||
<div
|
|
||||||
ref={suggestionRef}
|
|
||||||
className="absolute mt-2 w-full bg-[var(--card)] bg-opacity-90 backdrop-blur-sm rounded-md shadow-lg z-30 max-h-60 overflow-y-auto border border-[var(--border)]"
|
|
||||||
>
|
|
||||||
{searchSuggestions.map((suggestion, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="px-4 py-3 text-sm text-white hover:bg-[var(--hover)] cursor-pointer transition-colors duration-200 flex items-center gap-3"
|
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
|
||||||
>
|
|
||||||
{suggestion.image && (
|
|
||||||
<div className="w-8 h-12 flex-shrink-0 overflow-hidden rounded-sm">
|
|
||||||
<Image
|
|
||||||
src={suggestion.image}
|
|
||||||
alt={suggestion.title || "Anime"}
|
|
||||||
width={32}
|
|
||||||
height={48}
|
|
||||||
className="object-cover w-full h-full"
|
|
||||||
unoptimized
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<p className="font-medium leading-tight">{suggestion.title || suggestion}</p>
|
|
||||||
{suggestion.jname && (
|
|
||||||
<p className="text-xs text-[var(--text-muted)]">{suggestion.jname}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{suggestion.type && (
|
|
||||||
<span className="text-[10px] bg-[var(--border)] px-2 py-1 rounded-full text-[var(--text-muted)]">
|
|
||||||
{suggestion.type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enter Homepage Button */}
|
|
||||||
<Link
|
|
||||||
href="/home"
|
|
||||||
className="bg-white hover:bg-gray-200 text-[#0a0a0a] font-medium px-8 py-3 rounded-md max-w-[200px] text-center transition-colors border border-[var(--border)] flex items-center justify-center gap-2 whitespace-nowrap shadow-lg"
|
|
||||||
>
|
|
||||||
Enter Homepage <span>→</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FAQ Content */}
|
|
||||||
<div className="max-w-3xl mx-auto w-full px-4 sm:px-6 lg:px-8 pb-6">
|
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-center mb-6 text-white">Frequently Asked Questions</h2>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* FAQ Item 1 */}
|
|
||||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--card)]">
|
|
||||||
<button
|
|
||||||
className="w-full flex justify-between items-center p-3 sm:p-4 text-left hover:bg-opacity-90 transition-colors"
|
|
||||||
onClick={() => toggleFAQ(0)}
|
|
||||||
>
|
|
||||||
<h3 className="text-base sm:text-lg md:text-xl font-semibold text-white pr-2">Is JustAnime safe?</h3>
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0 transform transition-transform duration-300 ease-out ${openFAQ === 0 ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden bg-[var(--background)] transform transition-all duration-300 ease-out ${
|
|
||||||
openFAQ === 0 ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="p-3 sm:p-4 border-t border-[var(--border)]">
|
|
||||||
<p className="text-[var(--text-muted)] text-left text-sm sm:text-base">Yes. We started this site to improve UX and are committed to keeping our users safe. We encourage all our users to notify us if anything looks suspicious.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FAQ Item 2 */}
|
|
||||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--card)]">
|
|
||||||
<button
|
|
||||||
className="w-full flex justify-between items-center p-3 sm:p-4 text-left hover:bg-opacity-90 transition-colors"
|
|
||||||
onClick={() => toggleFAQ(1)}
|
|
||||||
>
|
|
||||||
<h3 className="text-base sm:text-lg md:text-xl font-semibold text-white pr-2">What makes JustAnime the best site to watch anime free online?</h3>
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0 transform transition-transform duration-300 ease-out ${openFAQ === 1 ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden bg-[var(--background)] transform transition-all duration-300 ease-out ${
|
|
||||||
openFAQ === 1 ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="p-3 sm:p-4 border-t border-[var(--border)]">
|
|
||||||
<p className="text-[var(--text-muted)] text-left text-sm sm:text-base">JustAnime offers the best user experience for anime streaming with fast loading speeds, a beautiful interface, no intrusive ads, large content library, HD quality, and weekly updates. Our clean design and extensive features set us apart from other platforms.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FAQ Item 3 */}
|
|
||||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--card)]">
|
|
||||||
<button
|
|
||||||
className="w-full flex justify-between items-center p-3 sm:p-4 text-left hover:bg-opacity-90 transition-colors"
|
|
||||||
onClick={() => toggleFAQ(2)}
|
|
||||||
>
|
|
||||||
<h3 className="text-base sm:text-lg md:text-xl font-semibold text-white pr-2">How do I request an anime?</h3>
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0 transform transition-transform duration-300 ease-out ${openFAQ === 2 ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden bg-[var(--background)] transform transition-all duration-300 ease-out ${
|
|
||||||
openFAQ === 2 ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="p-3 sm:p-4 border-t border-[var(--border)]">
|
|
||||||
<p className="text-[var(--text-muted)] text-left text-sm sm:text-base">You can request anime by visiting our Discord community or using the contact form. Our team aims to fulfill requests quickly based on availability.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function RecentEpisodesLayout({ children }) {
|
|
||||||
return <SharedLayout>{children}</SharedLayout>;
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import AnimeCard from '@/components/AnimeCard';
|
|
||||||
import AnimeFilters from '@/components/AnimeFilters';
|
|
||||||
import { fetchRecentEpisodes } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function RecentEpisodesPage() {
|
|
||||||
const [animeList, setAnimeList] = useState([]);
|
|
||||||
const [filteredList, setFilteredList] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
|
||||||
const [selectedGenre, setSelectedGenre] = useState(null);
|
|
||||||
const [yearFilter, setYearFilter] = useState('all');
|
|
||||||
const [sortOrder, setSortOrder] = useState('default');
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedSeasons, setSelectedSeasons] = useState([]);
|
|
||||||
const [selectedTypes, setSelectedTypes] = useState([]);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState([]);
|
|
||||||
const [selectedLanguages, setSelectedLanguages] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Current year for filtering
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
// Add ref to track if this is the first render
|
|
||||||
const initialRender = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchRecentEpisodes(currentPage);
|
|
||||||
|
|
||||||
if (currentPage === 1) {
|
|
||||||
setAnimeList(data.results || []);
|
|
||||||
} else {
|
|
||||||
setAnimeList(prev => [...prev, ...(data.results || [])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasNextPage(data.hasNextPage || false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching recent episodes:', error);
|
|
||||||
setError('Failed to load anime. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// Apply filters and sorting whenever the anime list or filter settings change
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip the initial render effect to avoid duplicate filtering
|
|
||||||
if (initialRender.current) {
|
|
||||||
initialRender.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!animeList.length) {
|
|
||||||
setFilteredList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = [...animeList];
|
|
||||||
|
|
||||||
// Search filter
|
|
||||||
if (searchQuery && searchQuery.trim() !== '') {
|
|
||||||
const query = searchQuery.toLowerCase().trim();
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const title = (anime.title || '').toLowerCase();
|
|
||||||
const otherNames = (anime.otherNames || '').toLowerCase();
|
|
||||||
return title.includes(query) || otherNames.includes(query);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by genre if selected
|
|
||||||
if (selectedGenre) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (anime.genres && Array.isArray(anime.genres)) {
|
|
||||||
return anime.genres.some(g =>
|
|
||||||
g.toLowerCase() === selectedGenre.toLowerCase() ||
|
|
||||||
(g.name && g.name.toLowerCase() === selectedGenre.toLowerCase())
|
|
||||||
);
|
|
||||||
} else if (anime.genre) {
|
|
||||||
return anime.genre.toLowerCase().includes(selectedGenre.toLowerCase());
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by season
|
|
||||||
if (selectedSeasons.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const season = getAnimeSeason(anime);
|
|
||||||
return selectedSeasons.includes(season);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by year
|
|
||||||
if (yearFilter !== 'all') {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const animeYear = parseInt(anime.year) || 0;
|
|
||||||
if (yearFilter === 'older') {
|
|
||||||
return animeYear < 2000;
|
|
||||||
} else {
|
|
||||||
return animeYear.toString() === yearFilter;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by type
|
|
||||||
if (selectedTypes.length > 0) {
|
|
||||||
result = result.filter(anime =>
|
|
||||||
selectedTypes.includes(anime.type)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status
|
|
||||||
if (selectedStatus.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const status = anime.status || getDefaultStatus(anime);
|
|
||||||
return selectedStatus.includes(status);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by language
|
|
||||||
if (selectedLanguages.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const language = anime.language || getDefaultLanguage(anime);
|
|
||||||
return selectedLanguages.includes(language);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
switch (sortOrder) {
|
|
||||||
case 'title-asc':
|
|
||||||
result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
|
||||||
break;
|
|
||||||
case 'title-desc':
|
|
||||||
result.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
|
||||||
break;
|
|
||||||
case 'year-desc':
|
|
||||||
result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0));
|
|
||||||
break;
|
|
||||||
case 'year-asc':
|
|
||||||
result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Default order from API
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredList(result);
|
|
||||||
}, [animeList, selectedGenre, yearFilter, sortOrder, searchQuery, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
setCurrentPage(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenreChange = (genre) => {
|
|
||||||
setSelectedGenre(genre);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearChange = (year) => {
|
|
||||||
setYearFilter(year);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (order) => {
|
|
||||||
setSortOrder(order);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (value) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeasonChange = (seasons) => {
|
|
||||||
setSelectedSeasons(seasons);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTypeChange = (types) => {
|
|
||||||
setSelectedTypes(types);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (status) => {
|
|
||||||
setSelectedStatus(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLanguageChange = (languages) => {
|
|
||||||
setSelectedLanguages(languages);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime season based on available data
|
|
||||||
const getAnimeSeason = (anime) => {
|
|
||||||
if (anime.season) return anime.season;
|
|
||||||
|
|
||||||
const seasons = ['Winter', 'Spring', 'Summer', 'Fall'];
|
|
||||||
// Use hash of ID to assign consistent season for demo purposes
|
|
||||||
const hash = anime.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
||||||
return seasons[hash % 4];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime status
|
|
||||||
const getDefaultStatus = (anime) => {
|
|
||||||
if (anime.status) return anime.status;
|
|
||||||
|
|
||||||
// Default logic - you may need to customize this based on your actual data
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
if (anime.year > currentYear) return 'Upcoming';
|
|
||||||
if (anime.totalEpisodes && anime.episodes && anime.episodes >= anime.totalEpisodes) return 'Completed';
|
|
||||||
return 'Ongoing';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to determine anime language
|
|
||||||
const getDefaultLanguage = (anime) => {
|
|
||||||
if (anime.language) return anime.language;
|
|
||||||
|
|
||||||
// Default to "Subbed" for all anime unless specifically marked
|
|
||||||
return anime.isDub ? 'Dubbed' : 'Subbed';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 md:px-[4rem] py-8">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold mb-6 text-white">Recent Episodes</h1>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<AnimeFilters
|
|
||||||
selectedGenre={selectedGenre}
|
|
||||||
onGenreChange={handleGenreChange}
|
|
||||||
yearFilter={yearFilter}
|
|
||||||
onYearChange={handleYearChange}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
onSortChange={handleSortChange}
|
|
||||||
showGenreFilter={true}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
selectedSeasons={selectedSeasons}
|
|
||||||
onSeasonChange={handleSeasonChange}
|
|
||||||
selectedTypes={selectedTypes}
|
|
||||||
onTypeChange={handleTypeChange}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
selectedLanguages={selectedLanguages}
|
|
||||||
onLanguageChange={handleLanguageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && animeList.length === 0 ? (
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{[...Array(14)].map((_, index) => (
|
|
||||||
<div key={index} className="bg-gray-800 rounded-lg overflow-hidden shadow animate-pulse h-64">
|
|
||||||
<div className="w-full h-40 bg-gray-700"></div>
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="h-4 bg-gray-700 rounded mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-700 rounded w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (filteredList.length > 0 || animeList.length > 0) ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{(filteredList.length > 0 ? filteredList : animeList).map((anime) => (
|
|
||||||
<AnimeCard key={anime.id} anime={anime} isRecent={true} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNextPage && (
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<button
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`px-6 py-3 bg-[var(--secondary)] text-white rounded-md ${
|
|
||||||
isLoading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-opacity-90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Load More'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="bg-gray-800 rounded-lg p-8 text-center">
|
|
||||||
<h3 className="text-xl font-medium text-white mb-2">No anime found</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
We couldn't find any anime matching your criteria. Please try different filters.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import AnimeCard from '@/components/AnimeCard';
|
|
||||||
import AnimeFilters from '@/components/AnimeFilters';
|
|
||||||
import { searchAnime, fetchMostPopular } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function SearchPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { query } = useParams();
|
|
||||||
const decodedQuery = query ? decodeURIComponent(query) : '';
|
|
||||||
|
|
||||||
const [searchResults, setSearchResults] = useState([]);
|
|
||||||
const [filteredResults, setFilteredResults] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
|
||||||
const [isEmptySearch, setIsEmptySearch] = useState(false);
|
|
||||||
|
|
||||||
// Filter states
|
|
||||||
const [selectedGenre, setSelectedGenre] = useState(null);
|
|
||||||
const [yearFilter, setYearFilter] = useState('all');
|
|
||||||
const [sortOrder, setSortOrder] = useState('default');
|
|
||||||
const [selectedSeasons, setSelectedSeasons] = useState([]);
|
|
||||||
const [selectedTypes, setSelectedTypes] = useState([]);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState([]);
|
|
||||||
const [selectedLanguages, setSelectedLanguages] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Create filters object for API request
|
|
||||||
const getFiltersForApi = useCallback(() => {
|
|
||||||
const filters = {};
|
|
||||||
|
|
||||||
if (selectedGenre) filters.genre = selectedGenre;
|
|
||||||
if (yearFilter !== 'all') filters.year = yearFilter;
|
|
||||||
if (sortOrder !== 'default') filters.sort = sortOrder;
|
|
||||||
|
|
||||||
// Only add these filters if API supports them
|
|
||||||
// Currently, these may need to be handled client-side
|
|
||||||
// if (selectedSeasons.length > 0) filters.seasons = selectedSeasons;
|
|
||||||
// if (selectedTypes.length > 0) filters.types = selectedTypes;
|
|
||||||
// if (selectedStatus.length > 0) filters.status = selectedStatus;
|
|
||||||
// if (selectedLanguages.length > 0) filters.languages = selectedLanguages;
|
|
||||||
|
|
||||||
return filters;
|
|
||||||
}, [selectedGenre, yearFilter, sortOrder]);
|
|
||||||
|
|
||||||
// Apply client-side filters
|
|
||||||
const applyClientSideFilters = useCallback((animeList) => {
|
|
||||||
if (!animeList.length) return [];
|
|
||||||
|
|
||||||
let result = [...animeList];
|
|
||||||
|
|
||||||
// Apply season filter if selected
|
|
||||||
if (selectedSeasons.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (!anime.season) return false;
|
|
||||||
|
|
||||||
const animeSeason = typeof anime.season === 'string'
|
|
||||||
? anime.season
|
|
||||||
: anime.season?.name || '';
|
|
||||||
|
|
||||||
return selectedSeasons.some(season =>
|
|
||||||
animeSeason.toLowerCase().includes(season.toLowerCase())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply type filter if selected
|
|
||||||
if (selectedTypes.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (!anime.type) return false;
|
|
||||||
|
|
||||||
return selectedTypes.some(type =>
|
|
||||||
anime.type.toLowerCase() === type.toLowerCase()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply status filter if selected
|
|
||||||
if (selectedStatus.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (!anime.status) return false;
|
|
||||||
|
|
||||||
return selectedStatus.some(status =>
|
|
||||||
anime.status.toLowerCase().includes(status.toLowerCase())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply language filter if selected
|
|
||||||
if (selectedLanguages.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
// If no language info, assume subbed (most common)
|
|
||||||
const animeLanguage = anime.language || 'Subbed';
|
|
||||||
|
|
||||||
return selectedLanguages.some(language =>
|
|
||||||
animeLanguage.toLowerCase().includes(language.toLowerCase())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply client-side sorting (when API sort is not supported)
|
|
||||||
if (sortOrder !== 'default') {
|
|
||||||
switch (sortOrder) {
|
|
||||||
case 'title-asc':
|
|
||||||
result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
|
||||||
break;
|
|
||||||
case 'title-desc':
|
|
||||||
result.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
|
||||||
break;
|
|
||||||
case 'year-desc':
|
|
||||||
result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0));
|
|
||||||
break;
|
|
||||||
case 'year-asc':
|
|
||||||
result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0));
|
|
||||||
break;
|
|
||||||
// Default order from API is used when sortOrder is 'default'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [selectedSeasons, selectedTypes, selectedStatus, selectedLanguages, sortOrder]);
|
|
||||||
|
|
||||||
// Fetch popular anime when search is empty
|
|
||||||
const fetchPopularAnime = useCallback(async (page = 1) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setIsEmptySearch(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fetchMostPopular(page);
|
|
||||||
|
|
||||||
if (page === 1) {
|
|
||||||
setSearchResults(data.results || []);
|
|
||||||
} else {
|
|
||||||
setSearchResults(prev => [...prev, ...(data.results || [])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasNextPage(data.hasNextPage || false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching popular anime:', error);
|
|
||||||
setError('Failed to fetch popular anime. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// If the query param is empty, redirect to search page with empty query
|
|
||||||
if (!decodedQuery.trim()) {
|
|
||||||
// Fetch popular anime instead
|
|
||||||
fetchPopularAnime(currentPage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsEmptySearch(false);
|
|
||||||
const fetchSearchResults = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filters = getFiltersForApi();
|
|
||||||
const data = await searchAnime(decodedQuery, currentPage, filters);
|
|
||||||
|
|
||||||
if (currentPage === 1) {
|
|
||||||
setSearchResults(data.results || []);
|
|
||||||
} else {
|
|
||||||
setSearchResults(prev => [...prev, ...(data.results || [])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasNextPage(data.hasNextPage || false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching search results:', error);
|
|
||||||
setError('Failed to search anime. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSearchResults();
|
|
||||||
}, [decodedQuery, currentPage, getFiltersForApi, fetchPopularAnime]);
|
|
||||||
|
|
||||||
// Apply client-side filters whenever search results or filter settings change
|
|
||||||
useEffect(() => {
|
|
||||||
const filteredResults = applyClientSideFilters(searchResults);
|
|
||||||
setFilteredResults(filteredResults);
|
|
||||||
}, [searchResults, applyClientSideFilters]);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
setCurrentPage(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter handlers
|
|
||||||
const handleGenreChange = (genre) => {
|
|
||||||
setSelectedGenre(genre);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearChange = (year) => {
|
|
||||||
setYearFilter(year);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (order) => {
|
|
||||||
setSortOrder(order);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeasonChange = (seasons) => {
|
|
||||||
setSelectedSeasons(seasons);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTypeChange = (types) => {
|
|
||||||
setSelectedTypes(types);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (status) => {
|
|
||||||
setSelectedStatus(status);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLanguageChange = (languages) => {
|
|
||||||
setSelectedLanguages(languages);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 md:px-8 py-8 min-h-screen">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-white mb-4">
|
|
||||||
{decodedQuery.trim() ? `Search Results for "${decodedQuery}"` : 'Popular Anime'}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<AnimeFilters
|
|
||||||
selectedGenre={selectedGenre}
|
|
||||||
onGenreChange={handleGenreChange}
|
|
||||||
yearFilter={yearFilter}
|
|
||||||
onYearChange={handleYearChange}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
onSortChange={handleSortChange}
|
|
||||||
showGenreFilter={true}
|
|
||||||
searchQuery={decodedQuery}
|
|
||||||
onSearchChange={() => {}}
|
|
||||||
selectedSeasons={selectedSeasons}
|
|
||||||
onSeasonChange={handleSeasonChange}
|
|
||||||
selectedTypes={selectedTypes}
|
|
||||||
onTypeChange={handleTypeChange}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
selectedLanguages={selectedLanguages}
|
|
||||||
onLanguageChange={handleLanguageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && currentPage === 1 ? (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="bg-gray-800 rounded-lg p-8 text-center">
|
|
||||||
<h3 className="text-xl font-medium text-white mb-2">Error</h3>
|
|
||||||
<p className="text-gray-400">{error}</p>
|
|
||||||
</div>
|
|
||||||
) : filteredResults.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
||||||
{filteredResults.map((anime) => (
|
|
||||||
<AnimeCard key={anime.id} anime={anime} isRecent={true} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNextPage && (
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<button
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`px-6 py-3 bg-[var(--secondary)] text-white rounded-md ${
|
|
||||||
isLoading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-opacity-90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Load More'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="bg-gray-800 rounded-lg p-8 text-center">
|
|
||||||
<h3 className="text-xl font-medium text-white mb-2">No results found</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
We couldn't find any anime matching your search criteria. Please try different filters or a different search term.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function SearchLayout({ children }) {
|
|
||||||
return <SharedLayout>{children}</SharedLayout>;
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
import { searchAnime, fetchMostPopular } from '@/lib/api';
|
|
||||||
import AnimeCard from '@/components/AnimeCard';
|
|
||||||
import AnimeFilters from '@/components/AnimeFilters';
|
|
||||||
|
|
||||||
function SearchResults() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const queryTerm = searchParams.get('q') || '';
|
|
||||||
const genreParam = searchParams.get('genre') || null;
|
|
||||||
|
|
||||||
const [animeList, setAnimeList] = useState([]);
|
|
||||||
const [filteredList, setFilteredList] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
|
||||||
const [selectedGenre, setSelectedGenre] = useState(genreParam);
|
|
||||||
const [yearFilter, setYearFilter] = useState('all');
|
|
||||||
const [sortOrder, setSortOrder] = useState('default');
|
|
||||||
const [selectedSeasons, setSelectedSeasons] = useState([]);
|
|
||||||
const [selectedTypes, setSelectedTypes] = useState([]);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState([]);
|
|
||||||
const [selectedLanguages, setSelectedLanguages] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [isEmptySearch, setIsEmptySearch] = useState(false);
|
|
||||||
|
|
||||||
// Current year for filtering
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
// Process and augment anime data to ensure all items have year information
|
|
||||||
const processAnimeData = useCallback((animeData) => {
|
|
||||||
if (!animeData || !animeData.results) return animeData;
|
|
||||||
|
|
||||||
// Create a copy of the data to avoid mutating the original
|
|
||||||
const processedData = {
|
|
||||||
...animeData,
|
|
||||||
results: animeData.results.map(anime => {
|
|
||||||
const processed = { ...anime };
|
|
||||||
|
|
||||||
// Extract or estimate year from various properties
|
|
||||||
// Fallback to randomized year range between 2000-current year if no year data available
|
|
||||||
if (!processed.year) {
|
|
||||||
if (processed.releaseDate && !isNaN(parseInt(processed.releaseDate))) {
|
|
||||||
processed.year = parseInt(processed.releaseDate);
|
|
||||||
} else if (processed.date && !isNaN(parseInt(processed.date))) {
|
|
||||||
processed.year = parseInt(processed.date);
|
|
||||||
} else {
|
|
||||||
// Assign a semi-random year based on anime ID to ensure consistency
|
|
||||||
const hash = processed.id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
||||||
processed.year = 2000 + (hash % (currentYear - 2000 + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return processed;
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
return processedData;
|
|
||||||
}, [currentYear]);
|
|
||||||
|
|
||||||
// Create filters object for API request
|
|
||||||
const getFiltersForApi = useCallback(() => {
|
|
||||||
const filters = {};
|
|
||||||
|
|
||||||
if (selectedGenre) filters.genre = selectedGenre;
|
|
||||||
if (yearFilter !== 'all') filters.year = yearFilter;
|
|
||||||
if (sortOrder !== 'default') filters.sort = sortOrder;
|
|
||||||
|
|
||||||
// Support all client-side filters in API call when possible
|
|
||||||
if (selectedSeasons.length > 0) filters.season = selectedSeasons.join(',');
|
|
||||||
if (selectedTypes.length > 0) filters.type = selectedTypes.join(',');
|
|
||||||
if (selectedStatus.length > 0) filters.status = selectedStatus.join(',');
|
|
||||||
if (selectedLanguages.length > 0) filters.language = selectedLanguages.join(',');
|
|
||||||
|
|
||||||
return filters;
|
|
||||||
}, [selectedGenre, yearFilter, sortOrder, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]);
|
|
||||||
|
|
||||||
// Apply client-side filters for things not supported by API
|
|
||||||
const applyClientSideFilters = useCallback((animeList) => {
|
|
||||||
if (!animeList.length) return [];
|
|
||||||
|
|
||||||
let result = [...animeList];
|
|
||||||
|
|
||||||
// Apply season filter if selected
|
|
||||||
if (selectedSeasons.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (!anime.season) return false;
|
|
||||||
|
|
||||||
const animeSeason = typeof anime.season === 'string'
|
|
||||||
? anime.season
|
|
||||||
: anime.season?.name || '';
|
|
||||||
|
|
||||||
return selectedSeasons.some(season =>
|
|
||||||
animeSeason.toLowerCase().includes(season.toLowerCase())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply type filter if selected
|
|
||||||
if (selectedTypes.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (!anime.type) return false;
|
|
||||||
|
|
||||||
return selectedTypes.some(type =>
|
|
||||||
anime.type.toLowerCase() === type.toLowerCase()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply status filter if selected
|
|
||||||
if (selectedStatus.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (!anime.status) return false;
|
|
||||||
|
|
||||||
return selectedStatus.some(status =>
|
|
||||||
anime.status.toLowerCase().includes(status.toLowerCase())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply language filter if selected
|
|
||||||
if (selectedLanguages.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
// If no language info, assume subbed (most common)
|
|
||||||
const animeLanguage = anime.language || 'Subbed';
|
|
||||||
|
|
||||||
return selectedLanguages.some(language =>
|
|
||||||
animeLanguage.toLowerCase().includes(language.toLowerCase())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply client-side sorting (when API sort is not supported)
|
|
||||||
if (sortOrder !== 'default') {
|
|
||||||
switch (sortOrder) {
|
|
||||||
case 'title-asc':
|
|
||||||
result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
|
||||||
break;
|
|
||||||
case 'title-desc':
|
|
||||||
result.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
|
||||||
break;
|
|
||||||
case 'year-desc':
|
|
||||||
result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0));
|
|
||||||
break;
|
|
||||||
case 'year-asc':
|
|
||||||
result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0));
|
|
||||||
break;
|
|
||||||
// Default order from API is used when sortOrder is 'default'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [selectedSeasons, selectedTypes, selectedStatus, selectedLanguages, sortOrder]);
|
|
||||||
|
|
||||||
// Fetch most popular anime when search is empty
|
|
||||||
const fetchPopularAnime = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setIsEmptySearch(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fetchMostPopular(1);
|
|
||||||
const processedData = processAnimeData(data);
|
|
||||||
|
|
||||||
const results = processedData.results || [];
|
|
||||||
setAnimeList(results);
|
|
||||||
|
|
||||||
// Apply client-side filters
|
|
||||||
const filteredResults = applyClientSideFilters(results);
|
|
||||||
setFilteredList(filteredResults);
|
|
||||||
|
|
||||||
setHasNextPage(processedData.hasNextPage || false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching popular anime:', error);
|
|
||||||
setError('Failed to fetch popular anime. Please try again later.');
|
|
||||||
setAnimeList([]);
|
|
||||||
setFilteredList([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [processAnimeData, applyClientSideFilters]);
|
|
||||||
|
|
||||||
// Fetch data from API when search term or main filters change
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
if (!queryTerm.trim()) {
|
|
||||||
// Show popular anime instead of empty results
|
|
||||||
fetchPopularAnime();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setCurrentPage(1);
|
|
||||||
setIsEmptySearch(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filters = getFiltersForApi();
|
|
||||||
console.log(`[Search] Searching for: "${queryTerm}" with filters:`, filters);
|
|
||||||
|
|
||||||
const data = await searchAnime(queryTerm, 1, filters);
|
|
||||||
|
|
||||||
// If no results but no error was thrown, show empty state
|
|
||||||
if (!data || (!data.results || data.results.length === 0)) {
|
|
||||||
console.log('[Search] No results found for search term:', queryTerm);
|
|
||||||
setError(`No results found for "${queryTerm}"`);
|
|
||||||
setAnimeList([]);
|
|
||||||
setFilteredList([]);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const processedData = processAnimeData(data);
|
|
||||||
const results = processedData.results || [];
|
|
||||||
setAnimeList(results);
|
|
||||||
|
|
||||||
// Only apply client-side filters for things not supported by API
|
|
||||||
const filteredResults = applyClientSideFilters(results);
|
|
||||||
setFilteredList(filteredResults);
|
|
||||||
|
|
||||||
setHasNextPage(processedData.hasNextPage || false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Search] Error searching anime:', error);
|
|
||||||
setError('Failed to search anime. Please try again later or check your internet connection.');
|
|
||||||
setAnimeList([]);
|
|
||||||
setFilteredList([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [queryTerm, getFiltersForApi, processAnimeData, applyClientSideFilters, fetchPopularAnime]);
|
|
||||||
|
|
||||||
// Handle pagination
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip if it's the first page (already fetched in the previous effect)
|
|
||||||
// or if no search term is provided
|
|
||||||
if (currentPage === 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadMoreData = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If it's an empty search query, load more popular anime
|
|
||||||
if (isEmptySearch) {
|
|
||||||
const data = await fetchMostPopular(currentPage);
|
|
||||||
const processedData = processAnimeData(data);
|
|
||||||
|
|
||||||
const newResults = processedData.results || [];
|
|
||||||
setAnimeList(prev => [...prev, ...newResults]);
|
|
||||||
|
|
||||||
// Apply client-side filters to new results
|
|
||||||
const filteredNewResults = applyClientSideFilters(newResults);
|
|
||||||
setFilteredList(prev => [...prev, ...filteredNewResults]);
|
|
||||||
|
|
||||||
setHasNextPage(processedData.hasNextPage || false);
|
|
||||||
} else {
|
|
||||||
// For search results, include filters
|
|
||||||
const filters = getFiltersForApi();
|
|
||||||
const data = await searchAnime(queryTerm, currentPage, filters);
|
|
||||||
const processedData = processAnimeData(data);
|
|
||||||
|
|
||||||
const newResults = processedData.results || [];
|
|
||||||
setAnimeList(prev => [...prev, ...newResults]);
|
|
||||||
|
|
||||||
// Only apply client-side filters for things not supported by API
|
|
||||||
const filteredNewResults = applyClientSideFilters(newResults);
|
|
||||||
setFilteredList(prev => [...prev, ...filteredNewResults]);
|
|
||||||
|
|
||||||
setHasNextPage(processedData.hasNextPage || false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading more anime:', error);
|
|
||||||
setError('Failed to load more results. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadMoreData();
|
|
||||||
}, [currentPage, queryTerm, isEmptySearch, getFiltersForApi, processAnimeData, applyClientSideFilters]);
|
|
||||||
|
|
||||||
// Re-apply client-side filters when filters change but don't need API refetch
|
|
||||||
useEffect(() => {
|
|
||||||
const applyFilters = () => {
|
|
||||||
const filteredResults = applyClientSideFilters(animeList);
|
|
||||||
setFilteredList(filteredResults);
|
|
||||||
};
|
|
||||||
|
|
||||||
applyFilters();
|
|
||||||
}, [selectedSeasons, selectedTypes, selectedStatus, selectedLanguages, animeList, applyClientSideFilters]);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
setCurrentPage(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenreChange = (genre) => {
|
|
||||||
setSelectedGenre(genre);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearChange = (year) => {
|
|
||||||
setYearFilter(year);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (order) => {
|
|
||||||
setSortOrder(order);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeasonChange = (seasons) => {
|
|
||||||
setSelectedSeasons(seasons);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTypeChange = (types) => {
|
|
||||||
setSelectedTypes(types);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (status) => {
|
|
||||||
setSelectedStatus(status);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLanguageChange = (languages) => {
|
|
||||||
setSelectedLanguages(languages);
|
|
||||||
if (currentPage !== 1) setCurrentPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 md:px-[4rem] py-8 min-h-screen">
|
|
||||||
{/* Horizontal filters at the top */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<AnimeFilters
|
|
||||||
selectedGenre={selectedGenre}
|
|
||||||
onGenreChange={handleGenreChange}
|
|
||||||
yearFilter={yearFilter}
|
|
||||||
onYearChange={handleYearChange}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
onSortChange={handleSortChange}
|
|
||||||
showGenreFilter={true}
|
|
||||||
searchQuery={queryTerm}
|
|
||||||
onSearchChange={() => {}}
|
|
||||||
selectedSeasons={selectedSeasons}
|
|
||||||
onSeasonChange={handleSeasonChange}
|
|
||||||
selectedTypes={selectedTypes}
|
|
||||||
onTypeChange={handleTypeChange}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
selectedLanguages={selectedLanguages}
|
|
||||||
onLanguageChange={handleLanguageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-zinc-200">
|
|
||||||
{queryTerm ? `Results for "${queryTerm}"` : 'Popular Anime'}
|
|
||||||
</h2>
|
|
||||||
<div className="text-sm text-zinc-400">
|
|
||||||
{filteredList.length > 0 && (
|
|
||||||
<span>{filteredList.length} {filteredList.length === 1 ? 'result' : 'results'}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<p className="text-red-400">{error}</p>
|
|
||||||
</div>
|
|
||||||
) : isLoading && currentPage === 1 ? (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{/* Loading skeleton */}
|
|
||||||
{[...Array(24)].map((_, index) => (
|
|
||||||
<div key={index} className="animate-pulse bg-gray-800 rounded-md h-64"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : filteredList.length === 0 ? (
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<p className="text-zinc-400">No anime found matching your filters.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4 mb-8">
|
|
||||||
{filteredList.map((anime) => (
|
|
||||||
<AnimeCard key={anime.id} anime={anime} isRecent={true} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Load more button */}
|
|
||||||
{hasNextPage && (
|
|
||||||
<div className="flex justify-center mt-8 mb-4">
|
|
||||||
<button
|
|
||||||
className="px-6 py-2 bg-[#1a1a1a] text-white rounded-md hover:bg-gray-800 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="flex items-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Load More'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SearchPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<SearchResults />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function TermsPage() {
|
|
||||||
return (
|
|
||||||
<SharedLayout>
|
|
||||||
<div className="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<h1 className="text-3xl font-bold text-white mb-8">Terms of Service & Privacy Policy</h1>
|
|
||||||
|
|
||||||
<div className="prose prose-invert prose-lg max-w-none">
|
|
||||||
<div className="mb-12">
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-4">Terms of Service</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">1. Acceptance of Terms</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
By accessing and using JustAnime, you acknowledge that you have read, understood, and agree to be bound by these Terms of Service.
|
|
||||||
If you do not agree with any part of these terms, you may not use our services.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">2. Service Description</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
JustAnime is a platform that provides information and links to anime content.
|
|
||||||
We do not host, upload, or distribute any content directly.
|
|
||||||
Our service aggregates links to third-party websites and services that host the actual content.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">3. User Conduct</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
Users of JustAnime agree not to:
|
|
||||||
</p>
|
|
||||||
<ul className="text-gray-400 list-disc pl-6 space-y-2 mt-2">
|
|
||||||
<li>Use our service for any illegal purpose or in violation of any local, state, national, or international law</li>
|
|
||||||
<li>Harass, abuse, or harm another person</li>
|
|
||||||
<li>Interfere with or disrupt the service or servers connected to the service</li>
|
|
||||||
<li>Create multiple accounts for disruptive or abusive purposes</li>
|
|
||||||
<li>Attempt to access any portion of the service that you are not authorized to access</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">4. Content Disclaimer</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
JustAnime does not host any content on its servers. We are not responsible for the content, accuracy, or practices of third-party websites that our service may link to.
|
|
||||||
These links are provided solely as a convenience to users, and we do not endorse the content of such third-party sites.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">5. Intellectual Property</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
All trademarks, logos, service marks, and trade names are the property of their respective owners.
|
|
||||||
JustAnime respects intellectual property rights and expects users to do the same.
|
|
||||||
If you believe content linked through our service infringes on your copyright, please contact us with details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">6. Modification of Terms</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
JustAnime reserves the right to modify these Terms of Service at any time.
|
|
||||||
We will provide notice of significant changes through our website.
|
|
||||||
Your continued use of our service after such modifications constitutes your acceptance of the updated terms.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">7. Termination</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
JustAnime reserves the right to terminate or suspend your access to our service at any time, without prior notice or liability, for any reason whatsoever, including without limitation if you breach these Terms of Service.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-4">Privacy Policy</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">1. Information We Collect</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
JustAnime collects the following types of information:
|
|
||||||
</p>
|
|
||||||
<ul className="text-gray-400 list-disc pl-6 space-y-2 mt-2">
|
|
||||||
<li><strong>Information you provide:</strong> We may collect personal information such as your email address when you sign up for an account or contact us.</li>
|
|
||||||
<li><strong>Usage data:</strong> We automatically collect information about your interactions with our service, including the pages you visit and your preferences.</li>
|
|
||||||
<li><strong>Device information:</strong> We collect information about your device and internet connection, including IP address, browser type, and operating system.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">2. How We Use Your Information</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
We use the information we collect to:
|
|
||||||
</p>
|
|
||||||
<ul className="text-gray-400 list-disc pl-6 space-y-2 mt-2">
|
|
||||||
<li>Provide, maintain, and improve our service</li>
|
|
||||||
<li>Communicate with you about updates, support, and features</li>
|
|
||||||
<li>Monitor and analyze usage patterns and trends</li>
|
|
||||||
<li>Protect against, identify, and prevent fraud and other illegal activity</li>
|
|
||||||
<li>Comply with legal obligations</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">3. Cookies and Similar Technologies</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
JustAnime uses cookies and similar tracking technologies to track activity on our service and hold certain information.
|
|
||||||
Cookies are files with a small amount of data that may include an anonymous unique identifier.
|
|
||||||
You can instruct your browser to refuse all cookies or to indicate when a cookie is being sent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">4. Data Sharing and Disclosure</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
We may share your information in the following circumstances:
|
|
||||||
</p>
|
|
||||||
<ul className="text-gray-400 list-disc pl-6 space-y-2 mt-2">
|
|
||||||
<li>With service providers who perform services on our behalf</li>
|
|
||||||
<li>To comply with legal obligations</li>
|
|
||||||
<li>To protect the rights, property, or safety of JustAnime, our users, or others</li>
|
|
||||||
<li>In connection with a business transfer, such as a merger or acquisition</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">5. Data Security</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
JustAnime takes reasonable measures to protect your information from unauthorized access, alteration, disclosure, or destruction.
|
|
||||||
However, no method of transmission over the Internet or electronic storage is 100% secure, and we cannot guarantee absolute security.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">6. Your Rights</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
Depending on your location, you may have certain rights regarding your personal data, including:
|
|
||||||
</p>
|
|
||||||
<ul className="text-gray-400 list-disc pl-6 space-y-2 mt-2">
|
|
||||||
<li>The right to access and receive a copy of your data</li>
|
|
||||||
<li>The right to rectify or update your data</li>
|
|
||||||
<li>The right to delete your data</li>
|
|
||||||
<li>The right to restrict processing of your data</li>
|
|
||||||
<li>The right to object to processing of your data</li>
|
|
||||||
<li>The right to data portability</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-gray-400 mt-2">
|
|
||||||
To exercise these rights, please contact us at privacy@justanime.com.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">7. Children's Privacy</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
JustAnime does not knowingly collect personal information from children under 13.
|
|
||||||
If you are a parent or guardian and you believe your child has provided us with personal information, please contact us.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">8. Changes to This Privacy Policy</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
We may update our Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last Updated" date.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-white">9. Contact Us</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
If you have any questions about this Privacy Policy, please contact us at privacy@justanime.com.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-6">
|
|
||||||
<p className="text-gray-500">Last Updated: May 5, 2024</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SharedLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function TopAiringLayout({ children }) {
|
|
||||||
return <SharedLayout>{children}</SharedLayout>;
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import AnimeCard from '@/components/AnimeCard';
|
|
||||||
import AnimeFilters from '@/components/AnimeFilters';
|
|
||||||
import { fetchTopAiring } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function TopAiringPage() {
|
|
||||||
const [animeList, setAnimeList] = useState([]);
|
|
||||||
const [filteredList, setFilteredList] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [hasNextPage, setHasNextPage] = useState(false);
|
|
||||||
const [selectedGenre, setSelectedGenre] = useState(null);
|
|
||||||
const [yearFilter, setYearFilter] = useState('all');
|
|
||||||
const [sortOrder, setSortOrder] = useState('default');
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedSeasons, setSelectedSeasons] = useState([]);
|
|
||||||
const [selectedTypes, setSelectedTypes] = useState([]);
|
|
||||||
const [selectedStatus, setSelectedStatus] = useState([]);
|
|
||||||
const [selectedLanguages, setSelectedLanguages] = useState([]);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
// Current year for filtering
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
// Add ref to track if this is the first render
|
|
||||||
const initialRender = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchData = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await fetchTopAiring(currentPage);
|
|
||||||
|
|
||||||
if (currentPage === 1) {
|
|
||||||
setAnimeList(data.results || []);
|
|
||||||
} else {
|
|
||||||
setAnimeList(prev => [...prev, ...(data.results || [])]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasNextPage(data.hasNextPage || false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching top airing anime:', error);
|
|
||||||
setError('Failed to load anime. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData();
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// Apply filters and sorting whenever the anime list or filter settings change
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip the initial render effect to avoid duplicate filtering
|
|
||||||
if (initialRender.current) {
|
|
||||||
initialRender.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!animeList.length) {
|
|
||||||
setFilteredList([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = [...animeList];
|
|
||||||
|
|
||||||
// Search filter
|
|
||||||
if (searchQuery && searchQuery.trim() !== '') {
|
|
||||||
const query = searchQuery.toLowerCase().trim();
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const title = (anime.title || '').toLowerCase();
|
|
||||||
const otherNames = (anime.otherNames || '').toLowerCase();
|
|
||||||
return title.includes(query) || otherNames.includes(query);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by genre if selected
|
|
||||||
if (selectedGenre) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
if (anime.genres && Array.isArray(anime.genres)) {
|
|
||||||
return anime.genres.some(g =>
|
|
||||||
g.toLowerCase() === selectedGenre.toLowerCase() ||
|
|
||||||
(g.name && g.name.toLowerCase() === selectedGenre.toLowerCase())
|
|
||||||
);
|
|
||||||
} else if (anime.genre) {
|
|
||||||
return anime.genre.toLowerCase().includes(selectedGenre.toLowerCase());
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by season
|
|
||||||
if (selectedSeasons.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const season = getAnimeSeason(anime);
|
|
||||||
return selectedSeasons.includes(season);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by year
|
|
||||||
if (yearFilter !== 'all') {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const animeYear = parseInt(anime.year) || 0;
|
|
||||||
if (yearFilter === 'older') {
|
|
||||||
return animeYear < 2000;
|
|
||||||
} else {
|
|
||||||
return animeYear.toString() === yearFilter;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by type
|
|
||||||
if (selectedTypes.length > 0) {
|
|
||||||
result = result.filter(anime =>
|
|
||||||
selectedTypes.includes(anime.type)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by status
|
|
||||||
if (selectedStatus.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const status = anime.status || getDefaultStatus(anime);
|
|
||||||
return selectedStatus.includes(status);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by language
|
|
||||||
if (selectedLanguages.length > 0) {
|
|
||||||
result = result.filter(anime => {
|
|
||||||
const language = anime.language || getDefaultLanguage(anime);
|
|
||||||
return selectedLanguages.includes(language);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
switch (sortOrder) {
|
|
||||||
case 'title-asc':
|
|
||||||
result.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
|
||||||
break;
|
|
||||||
case 'title-desc':
|
|
||||||
result.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
|
||||||
break;
|
|
||||||
case 'year-desc':
|
|
||||||
result.sort((a, b) => (parseInt(b.year) || 0) - (parseInt(a.year) || 0));
|
|
||||||
break;
|
|
||||||
case 'year-asc':
|
|
||||||
result.sort((a, b) => (parseInt(a.year) || 0) - (parseInt(b.year) || 0));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Default order from API
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredList(result);
|
|
||||||
}, [animeList, selectedGenre, yearFilter, sortOrder, searchQuery, selectedSeasons, selectedTypes, selectedStatus, selectedLanguages]);
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
setCurrentPage(prev => prev + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenreChange = (genre) => {
|
|
||||||
setSelectedGenre(genre);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearChange = (year) => {
|
|
||||||
setYearFilter(year);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortChange = (order) => {
|
|
||||||
setSortOrder(order);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (value) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeasonChange = (seasons) => {
|
|
||||||
setSelectedSeasons(seasons);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTypeChange = (types) => {
|
|
||||||
setSelectedTypes(types);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (status) => {
|
|
||||||
setSelectedStatus(status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLanguageChange = (languages) => {
|
|
||||||
setSelectedLanguages(languages);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 md:px-[4rem] py-8">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold mb-6 text-white">Top Airing Anime</h1>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<AnimeFilters
|
|
||||||
selectedGenre={selectedGenre}
|
|
||||||
onGenreChange={handleGenreChange}
|
|
||||||
yearFilter={yearFilter}
|
|
||||||
onYearChange={handleYearChange}
|
|
||||||
sortOrder={sortOrder}
|
|
||||||
onSortChange={handleSortChange}
|
|
||||||
showGenreFilter={true}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
selectedSeasons={selectedSeasons}
|
|
||||||
onSeasonChange={handleSeasonChange}
|
|
||||||
selectedTypes={selectedTypes}
|
|
||||||
onTypeChange={handleTypeChange}
|
|
||||||
selectedStatus={selectedStatus}
|
|
||||||
onStatusChange={handleStatusChange}
|
|
||||||
selectedLanguages={selectedLanguages}
|
|
||||||
onLanguageChange={handleLanguageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading && animeList.length === 0 ? (
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{[...Array(14)].map((_, index) => (
|
|
||||||
<div key={index} className="bg-gray-800 rounded-lg overflow-hidden shadow animate-pulse h-64">
|
|
||||||
<div className="w-full h-40 bg-gray-700"></div>
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="h-4 bg-gray-700 rounded mb-2"></div>
|
|
||||||
<div className="h-3 bg-gray-700 rounded w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (filteredList.length > 0 || animeList.length > 0) ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
||||||
{(filteredList.length > 0 ? filteredList : animeList).map((anime) => (
|
|
||||||
<AnimeCard key={anime.id} anime={anime} isRecent={true} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasNextPage && (
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<button
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`px-6 py-3 bg-[var(--secondary)] text-white rounded-md ${
|
|
||||||
isLoading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-opacity-90'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<span className="flex items-center justify-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Load More'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="bg-gray-800 rounded-lg p-8 text-center">
|
|
||||||
<h3 className="text-xl font-medium text-white mb-2">No anime found</h3>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
We couldn't find any top airing anime at this time. Please check back later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,664 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useParams, useRouter, usePathname, useSearchParams } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import VideoPlayer from '@/components/VideoPlayer';
|
|
||||||
import EpisodeList from '@/components/EpisodeList';
|
|
||||||
import {
|
|
||||||
fetchEpisodeSources,
|
|
||||||
fetchAnimeInfo,
|
|
||||||
fetchEpisodeServers,
|
|
||||||
fetchAnimeEpisodes
|
|
||||||
} from '@/lib/api';
|
|
||||||
|
|
||||||
export default function WatchPage() {
|
|
||||||
const { episodeId } = useParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [videoSource, setVideoSource] = useState(null);
|
|
||||||
const [anime, setAnime] = useState(null);
|
|
||||||
const [currentEpisode, setCurrentEpisode] = useState(null);
|
|
||||||
const [isDub, setIsDub] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [videoHeaders, setVideoHeaders] = useState({});
|
|
||||||
const [subtitles, setSubtitles] = useState([]);
|
|
||||||
const [thumbnails, setThumbnails] = useState(null);
|
|
||||||
const [animeId, setAnimeId] = useState(null);
|
|
||||||
const [episodeData, setEpisodeData] = useState(null);
|
|
||||||
const [isRetrying, setIsRetrying] = useState(false);
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const episodesPerPage = 100;
|
|
||||||
const [showFullSynopsis, setShowFullSynopsis] = useState(false);
|
|
||||||
const [autoSkip, setAutoSkip] = useState(false);
|
|
||||||
const [currentEpisodeId, setCurrentEpisodeId] = useState(episodeId);
|
|
||||||
const [availableServers, setAvailableServers] = useState([]);
|
|
||||||
const [selectedServer, setSelectedServer] = useState('hd-2');
|
|
||||||
const [episodes, setEpisodes] = useState([]);
|
|
||||||
|
|
||||||
// Handle URL updates when currentEpisodeId changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentEpisodeId && currentEpisodeId !== episodeId) {
|
|
||||||
const newUrl = `/watch/${currentEpisodeId}`;
|
|
||||||
window.history.pushState({ episodeId: currentEpisodeId }, '', newUrl);
|
|
||||||
}
|
|
||||||
}, [currentEpisodeId, episodeId]);
|
|
||||||
|
|
||||||
// Listen for popstate (browser back/forward) events
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePopState = (event) => {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const match = path.match(/\/watch\/(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
const newEpisodeId = match[1];
|
|
||||||
setCurrentEpisodeId(newEpisodeId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('popstate', handlePopState);
|
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Extract animeId from the URL
|
|
||||||
useEffect(() => {
|
|
||||||
if (episodeId) {
|
|
||||||
// Log the raw episodeId from the URL for debugging
|
|
||||||
console.log('[Watch] Raw episodeId from URL:', episodeId);
|
|
||||||
|
|
||||||
// Extract animeId from the episodeId parameter
|
|
||||||
// The API response contains episode.id in the format "anime-id?ep=episode-number"
|
|
||||||
let extractedAnimeId = episodeId;
|
|
||||||
|
|
||||||
// If the ID contains a query parameter, extract just the anime ID
|
|
||||||
if (episodeId.includes('?')) {
|
|
||||||
extractedAnimeId = episodeId.split('?')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
setAnimeId(extractedAnimeId);
|
|
||||||
console.log('[Watch] Extracted anime ID:', extractedAnimeId);
|
|
||||||
|
|
||||||
setCurrentEpisodeId(episodeId);
|
|
||||||
}
|
|
||||||
}, [episodeId]);
|
|
||||||
|
|
||||||
// First fetch episode servers to get available servers and subtitles
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentEpisodeId || currentEpisodeId === 'undefined') {
|
|
||||||
setError('Invalid episode ID');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchServers = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`[Watch] Fetching servers for episode ${currentEpisodeId}`);
|
|
||||||
|
|
||||||
// Fetch available servers from the API
|
|
||||||
const data = await fetchEpisodeServers(currentEpisodeId);
|
|
||||||
|
|
||||||
if (!data || !data.servers || data.servers.length === 0) {
|
|
||||||
console.warn('[Watch] No servers available for this episode');
|
|
||||||
} else {
|
|
||||||
// Filter servers based on current audio preference (sub/dub)
|
|
||||||
const filteredServers = data.servers.filter(server =>
|
|
||||||
server.category === (isDub ? 'dub' : 'sub')
|
|
||||||
);
|
|
||||||
|
|
||||||
setAvailableServers(filteredServers);
|
|
||||||
console.log(`[Watch] Available ${isDub ? 'dub' : 'sub'} servers:`, filteredServers);
|
|
||||||
|
|
||||||
// Set default server if available
|
|
||||||
// First try to find HD-1 server
|
|
||||||
let preferredServer = filteredServers.find(server =>
|
|
||||||
server.serverName && server.serverName.toLowerCase() === 'hd-2'
|
|
||||||
);
|
|
||||||
|
|
||||||
// If not found, look for vidstreaming
|
|
||||||
if (!preferredServer) {
|
|
||||||
preferredServer = filteredServers.find(server =>
|
|
||||||
server.serverName && server.serverName.toLowerCase().includes('vidstreaming')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preferredServer && preferredServer.serverName) {
|
|
||||||
setSelectedServer(preferredServer.serverName.toLowerCase());
|
|
||||||
console.log(`[Watch] Selected preferred server: ${preferredServer.serverName}`);
|
|
||||||
} else if (filteredServers.length > 0 && filteredServers[0].serverName) {
|
|
||||||
setSelectedServer(filteredServers[0].serverName.toLowerCase());
|
|
||||||
console.log(`[Watch] Selected first available server: ${filteredServers[0].serverName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue to fetch video sources with the selected server
|
|
||||||
fetchVideoSources(currentEpisodeId, isDub, selectedServer);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Watch] Error fetching episode servers:', error);
|
|
||||||
// Continue to sources even if servers fail
|
|
||||||
fetchVideoSources(currentEpisodeId, isDub, selectedServer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchServers();
|
|
||||||
}, [currentEpisodeId, isDub]);
|
|
||||||
|
|
||||||
// Fetch video sources function
|
|
||||||
const fetchVideoSources = async (episodeId, dub, server) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setVideoSource(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`[Watch] Fetching video for episode ${episodeId} (dub: ${dub}, server: ${server})`);
|
|
||||||
|
|
||||||
// Fetch the episode sources from the API
|
|
||||||
const data = await fetchEpisodeSources(episodeId, dub, server);
|
|
||||||
|
|
||||||
console.log('[Watch] Episode sources API response:', data);
|
|
||||||
setEpisodeData(data);
|
|
||||||
|
|
||||||
if (!data || !data.sources || data.sources.length === 0) {
|
|
||||||
throw new Error('No video sources available for this episode');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract headers if they exist in the response
|
|
||||||
if (data.headers) {
|
|
||||||
console.log('[Watch] Headers from API:', data.headers);
|
|
||||||
setVideoHeaders(data.headers);
|
|
||||||
} else {
|
|
||||||
// Set default headers if none provided
|
|
||||||
const defaultHeaders = {
|
|
||||||
"Referer": "https://hianime.to/",
|
|
||||||
"Origin": "https://hianime.to"
|
|
||||||
};
|
|
||||||
setVideoHeaders(defaultHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set subtitles if available in the sources response
|
|
||||||
// Check both subtitles and tracks fields since API might return either
|
|
||||||
const subtitleData = data.subtitles || data.tracks || [];
|
|
||||||
if (subtitleData.length > 0) {
|
|
||||||
// Filter out thumbnails from subtitles array
|
|
||||||
const filteredSubtitles = subtitleData.filter(sub =>
|
|
||||||
sub.lang && sub.lang.toLowerCase() !== 'thumbnails'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Look for thumbnails separately
|
|
||||||
const thumbnailTrack = subtitleData.find(sub =>
|
|
||||||
sub.lang && sub.lang.toLowerCase() === 'thumbnails'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (thumbnailTrack && thumbnailTrack.url) {
|
|
||||||
console.log('[Watch] Found thumbnails track:', thumbnailTrack.url);
|
|
||||||
setThumbnails(thumbnailTrack.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredSubtitles.length > 0) {
|
|
||||||
console.log('[Watch] Found subtitles:', filteredSubtitles.length);
|
|
||||||
setSubtitles(filteredSubtitles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find the best source in order of preference
|
|
||||||
// 1. HLS (m3u8) sources
|
|
||||||
// 2. High quality MP4 sources
|
|
||||||
const hlsSource = data.sources.find(src => src.isM3U8);
|
|
||||||
const mp4Source = data.sources.find(src => !src.isM3U8);
|
|
||||||
|
|
||||||
let selectedSource = null;
|
|
||||||
|
|
||||||
if (hlsSource && hlsSource.url) {
|
|
||||||
console.log('[Watch] Selected HLS source:', hlsSource.url);
|
|
||||||
selectedSource = hlsSource.url;
|
|
||||||
} else if (mp4Source && mp4Source.url) {
|
|
||||||
console.log('[Watch] Selected MP4 source:', mp4Source.url);
|
|
||||||
selectedSource = mp4Source.url;
|
|
||||||
} else if (data.sources[0] && data.sources[0].url) {
|
|
||||||
console.log('[Watch] Falling back to first available source:', data.sources[0].url);
|
|
||||||
selectedSource = data.sources[0].url;
|
|
||||||
} else {
|
|
||||||
throw new Error('No valid video URLs found');
|
|
||||||
}
|
|
||||||
|
|
||||||
setVideoSource(selectedSource);
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Watch] Error fetching video sources:', error);
|
|
||||||
setError(error.message || 'Failed to load video');
|
|
||||||
setIsLoading(false);
|
|
||||||
|
|
||||||
// If this is the first try, attempt to retry once
|
|
||||||
if (!isRetrying) {
|
|
||||||
console.log('[Watch] First error, attempting retry...');
|
|
||||||
setIsRetrying(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('[Watch] Executing retry...');
|
|
||||||
fetchVideoSources(episodeId, dub, server);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Effect to refetch sources when server or dub changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentEpisodeId && selectedServer) {
|
|
||||||
fetchVideoSources(currentEpisodeId, isDub, selectedServer);
|
|
||||||
}
|
|
||||||
}, [selectedServer, isDub]);
|
|
||||||
|
|
||||||
// Fetch anime info and episodes using animeId
|
|
||||||
useEffect(() => {
|
|
||||||
if (animeId) {
|
|
||||||
const fetchAnimeDetails = async () => {
|
|
||||||
try {
|
|
||||||
setIsRetrying(true);
|
|
||||||
console.log(`[Watch] Fetching anime info for ID: ${animeId}`);
|
|
||||||
|
|
||||||
// Fetch basic anime info
|
|
||||||
const animeData = await fetchAnimeInfo(animeId);
|
|
||||||
if (animeData) {
|
|
||||||
console.log('[Watch] Anime info received:', animeData.info?.name);
|
|
||||||
setAnime({
|
|
||||||
id: animeId,
|
|
||||||
title: animeData.info?.name || 'Unknown Anime',
|
|
||||||
image: animeData.info?.poster || '',
|
|
||||||
description: animeData.info?.description || 'No description available',
|
|
||||||
status: animeData.moreInfo?.status || 'Unknown',
|
|
||||||
type: animeData.info?.stats?.type || 'TV',
|
|
||||||
totalEpisodes: animeData.info?.stats?.episodes?.sub || 0,
|
|
||||||
genres: animeData.moreInfo?.genres || []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch episodes separately
|
|
||||||
const episodesData = await fetchAnimeEpisodes(animeId);
|
|
||||||
if (episodesData && episodesData.episodes && episodesData.episodes.length > 0) {
|
|
||||||
console.log('[Watch] Episodes found:', episodesData.episodes.length);
|
|
||||||
setEpisodes(episodesData.episodes);
|
|
||||||
|
|
||||||
// Find current episode in episode list
|
|
||||||
const findCurrentEpisode = () => {
|
|
||||||
// Find the episode by direct ID match
|
|
||||||
const directMatch = episodesData.episodes.find(ep => ep.id === currentEpisodeId);
|
|
||||||
if (directMatch) {
|
|
||||||
console.log('[Watch] Found episode by direct ID match:', directMatch.number);
|
|
||||||
return directMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no match found, return first episode as fallback
|
|
||||||
console.warn('[Watch] Could not find matching episode, falling back to first episode');
|
|
||||||
return episodesData.episodes[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
const episode = findCurrentEpisode();
|
|
||||||
if (episode) {
|
|
||||||
setCurrentEpisode(episode);
|
|
||||||
console.log('[Watch] Current episode found:', episode.number);
|
|
||||||
} else {
|
|
||||||
console.warn('[Watch] Current episode not found in episode list');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[Watch] No episodes found for this anime');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Watch] Error fetching anime details:', error);
|
|
||||||
} finally {
|
|
||||||
setIsRetrying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchAnimeDetails();
|
|
||||||
}
|
|
||||||
}, [animeId, currentEpisodeId]);
|
|
||||||
|
|
||||||
const handleDubToggle = () => {
|
|
||||||
setIsDub(prev => {
|
|
||||||
const newDubState = !prev;
|
|
||||||
// Refetch servers for the new audio type
|
|
||||||
fetchEpisodeServers(currentEpisodeId).then(data => {
|
|
||||||
if (data && data.servers && data.servers.length > 0) {
|
|
||||||
// Filter servers based on new audio preference
|
|
||||||
const filteredServers = data.servers.filter(server =>
|
|
||||||
server.category === (newDubState ? 'dub' : 'sub')
|
|
||||||
);
|
|
||||||
|
|
||||||
setAvailableServers(filteredServers);
|
|
||||||
|
|
||||||
// Update selected server if needed
|
|
||||||
// First try to find HD-1 server
|
|
||||||
let preferredServer = filteredServers.find(server =>
|
|
||||||
server.serverName && server.serverName.toLowerCase() === 'hd-2'
|
|
||||||
);
|
|
||||||
|
|
||||||
// If not found, look for vidstreaming
|
|
||||||
if (!preferredServer) {
|
|
||||||
preferredServer = filteredServers.find(server =>
|
|
||||||
server.serverName && server.serverName.toLowerCase().includes('vidstreaming')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preferredServer && preferredServer.serverName) {
|
|
||||||
setSelectedServer(preferredServer.serverName.toLowerCase());
|
|
||||||
console.log(`[Watch] Selected preferred server: ${preferredServer.serverName}`);
|
|
||||||
} else if (filteredServers.length > 0 && filteredServers[0].serverName) {
|
|
||||||
setSelectedServer(filteredServers[0].serverName.toLowerCase());
|
|
||||||
console.log(`[Watch] Selected first available server: ${filteredServers[0].serverName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return newDubState;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleServerChange = (server) => {
|
|
||||||
setSelectedServer(server);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEpisodeClick = (newEpisodeId) => {
|
|
||||||
if (newEpisodeId !== currentEpisodeId) {
|
|
||||||
console.log(`[Watch] Episode clicked, ID: ${newEpisodeId}`);
|
|
||||||
|
|
||||||
// Use the episode ID directly as it should already be in the correct format
|
|
||||||
// from the API response (animeId?ep=episodeNumber)
|
|
||||||
|
|
||||||
// Update the URL using history API
|
|
||||||
const newUrl = `/watch/${newEpisodeId}`;
|
|
||||||
window.history.pushState({ episodeId: newEpisodeId }, '', newUrl);
|
|
||||||
|
|
||||||
// Update state to trigger video reload
|
|
||||||
setCurrentEpisodeId(newEpisodeId);
|
|
||||||
|
|
||||||
// Update current episode in state
|
|
||||||
if (episodes) {
|
|
||||||
const newEpisode = episodes.find(ep => ep.id === newEpisodeId);
|
|
||||||
if (newEpisode) {
|
|
||||||
setCurrentEpisode(newEpisode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const findAdjacentEpisodes = () => {
|
|
||||||
if (!episodes || !currentEpisode) return { prev: null, next: null };
|
|
||||||
|
|
||||||
const currentIndex = episodes.findIndex(ep => ep.number === currentEpisode.number);
|
|
||||||
if (currentIndex === -1) return { prev: null, next: null };
|
|
||||||
|
|
||||||
return {
|
|
||||||
prev: currentIndex > 0 ? episodes[currentIndex - 1] : null,
|
|
||||||
next: currentIndex < episodes.length - 1 ? episodes[currentIndex + 1] : null
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const { prev, next } = findAdjacentEpisodes();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[var(--background)]">
|
|
||||||
<div className="container mx-auto px-4 xl:px-0">
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 py-6">
|
|
||||||
{/* Left Side - Video Player (70%) */}
|
|
||||||
<div className="w-full md:w-[70%] flex flex-col">
|
|
||||||
<div className="flex flex-col" id="videoSection">
|
|
||||||
{/* Video Player Container */}
|
|
||||||
<div className="relative w-full bg-[#0a0a0a] rounded-2xl overflow-hidden shadow-2xl ring-1 ring-white/5">
|
|
||||||
<div className="relative pt-[56.25%]">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
{error ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-4">
|
|
||||||
<div className="text-red-400 text-xl mb-4">Error: {error}</div>
|
|
||||||
<p className="text-gray-400 mb-6">
|
|
||||||
The video source couldn't be loaded. Please try again or check back later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : isLoading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
|
|
||||||
<div className="text-gray-400">Loading video...</div>
|
|
||||||
</div>
|
|
||||||
) : videoSource ? (
|
|
||||||
<div className="h-full">
|
|
||||||
<VideoPlayer
|
|
||||||
key={`${currentEpisodeId}-${isDub}-${selectedServer}`}
|
|
||||||
src={videoSource}
|
|
||||||
poster={anime?.image}
|
|
||||||
headers={videoHeaders}
|
|
||||||
subtitles={subtitles}
|
|
||||||
thumbnails={thumbnails}
|
|
||||||
category={isDub ? 'dub' : 'sub'}
|
|
||||||
intro={episodeData?.intro || null}
|
|
||||||
outro={episodeData?.outro || null}
|
|
||||||
autoSkipIntro={autoSkip}
|
|
||||||
autoSkipOutro={autoSkip}
|
|
||||||
episodeId={currentEpisodeId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-4">
|
|
||||||
<div className="text-yellow-400 text-xl mb-4">No video source available</div>
|
|
||||||
<p className="text-gray-400 mb-6">
|
|
||||||
Please try again or check back later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video Controls - Slimmer and without container background */}
|
|
||||||
<div className="flex flex-col gap-4 mt-6">
|
|
||||||
{/* Audio and Playback Controls */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-y-4">
|
|
||||||
{/* Playback Settings */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h3 className="text-white/80 text-sm font-medium">Playback Settings</h3>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{/* Auto Skip Checkbox */}
|
|
||||||
{(episodeData?.intro || episodeData?.outro) && (
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer group">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoSkip}
|
|
||||||
onChange={(e) => setAutoSkip(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-white bg-white/10 border-none rounded cursor-pointer focus:ring-white focus:ring-offset-0 focus:ring-offset-transparent focus:ring-opacity-50"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-gray-400 group-hover:text-white transition-colors">Auto Skip</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Server Selection */}
|
|
||||||
{availableServers.length > 0 && (
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h3 className="text-white/80 text-sm font-medium">Servers</h3>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{availableServers.map((server) =>
|
|
||||||
server.serverName ? (
|
|
||||||
<button
|
|
||||||
key={`${server.serverName}-${server.serverId}`}
|
|
||||||
onClick={() => handleServerChange(server.serverName.toLowerCase())}
|
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
|
||||||
selectedServer === server.serverName.toLowerCase()
|
|
||||||
? 'bg-white text-black'
|
|
||||||
: 'bg-white/5 text-gray-400 hover:text-white hover:bg-white/10 ring-1 ring-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{server.serverName}
|
|
||||||
</button>
|
|
||||||
) : null
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audio Toggle */}
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<h3 className="text-white/80 text-sm font-medium">Audio</h3>
|
|
||||||
<div className="flex bg-white/5 rounded-lg p-0.5 ring-1 ring-white/10">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsDub(false)}
|
|
||||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
|
|
||||||
!isDub
|
|
||||||
? 'bg-white text-black'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
SUB
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsDub(true)}
|
|
||||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
|
|
||||||
isDub
|
|
||||||
? 'bg-white text-black'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
DUB
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Episode Navigation */}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{episodes && episodes.length > 0 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const { prev } = findAdjacentEpisodes();
|
|
||||||
if (prev) {
|
|
||||||
handleEpisodeClick(prev.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!findAdjacentEpisodes().prev}
|
|
||||||
className="px-4 py-2 rounded-lg bg-white/5 text-white disabled:opacity-30
|
|
||||||
disabled:cursor-not-allowed hover:bg-white/10 transition-all
|
|
||||||
flex items-center gap-2 flex-1 justify-center ring-1 ring-white/10"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Previous Episode
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const { next } = findAdjacentEpisodes();
|
|
||||||
if (next) {
|
|
||||||
handleEpisodeClick(next.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!findAdjacentEpisodes().next}
|
|
||||||
className="px-4 py-2 rounded-lg bg-white/5 text-white disabled:opacity-30
|
|
||||||
disabled:cursor-not-allowed hover:bg-white/10 transition-all
|
|
||||||
flex items-center gap-2 flex-1 justify-center ring-1 ring-white/10"
|
|
||||||
>
|
|
||||||
Next Episode
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anime Info Section */}
|
|
||||||
{anime && (
|
|
||||||
<div className="mt-8">
|
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
|
||||||
{/* Cover Image */}
|
|
||||||
<div className="relative w-40 md:w-48 flex-shrink-0">
|
|
||||||
<div className="aspect-[2/3] relative rounded-xl overflow-hidden shadow-2xl ring-1 ring-white/10">
|
|
||||||
<Image
|
|
||||||
src={anime.image}
|
|
||||||
alt={anime.title}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details */}
|
|
||||||
<div className="flex-grow">
|
|
||||||
<Link href={`/anime/${animeId}`}>
|
|
||||||
<h2 className="text-4xl font-bold text-white mb-4 hover:text-white/80 transition-colors">
|
|
||||||
{anime.title}
|
|
||||||
</h2>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Status Bar */}
|
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-400 mb-8">
|
|
||||||
<span className="bg-white/5 px-3 py-1 rounded-full ring-1 ring-white/10">{anime.status}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="bg-white/5 px-3 py-1 rounded-full ring-1 ring-white/10">{anime.type}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="bg-white/5 px-3 py-1 rounded-full ring-1 ring-white/10">{anime.totalEpisodes} Episodes</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Synopsis Section */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-3">Synopsis</h3>
|
|
||||||
<div className="relative">
|
|
||||||
<div className={`text-gray-300 text-sm leading-relaxed ${!showFullSynopsis ? 'line-clamp-4' : ''}`}>
|
|
||||||
{anime.description}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFullSynopsis(!showFullSynopsis)}
|
|
||||||
className="text-white hover:text-white/80 transition-colors mt-2 text-sm font-medium"
|
|
||||||
>
|
|
||||||
{showFullSynopsis ? 'Show Less' : 'Read More'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{anime.genres && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{anime.genres.map((genre, index) => (
|
|
||||||
<Link
|
|
||||||
key={index}
|
|
||||||
href={`/genres/${encodeURIComponent(genre.toLowerCase())}`}
|
|
||||||
className="px-3 py-1 rounded-full bg-white/5 text-white text-sm
|
|
||||||
hover:bg-white/10 transition-all cursor-pointer ring-1 ring-white/10"
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Side - Episode List (30%) */}
|
|
||||||
<div className="w-full md:w-[30%]">
|
|
||||||
{episodes && episodes.length > 0 ? (
|
|
||||||
<div className="h-full max-h-[calc(100vh-2rem)] overflow-hidden">
|
|
||||||
<EpisodeList
|
|
||||||
episodes={episodes}
|
|
||||||
currentEpisode={currentEpisode}
|
|
||||||
onEpisodeClick={handleEpisodeClick}
|
|
||||||
isDub={isDub}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white/5 rounded-2xl shadow-2xl p-6 ring-1 ring-white/10">
|
|
||||||
<div className="text-center text-gray-400">
|
|
||||||
{isLoading ? 'Loading episodes...' : 'No episodes available'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import SharedLayout from '@/components/SharedLayout';
|
|
||||||
|
|
||||||
export default function WatchLayout({ children }) {
|
|
||||||
return <SharedLayout>{children}</SharedLayout>;
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { fetchSchedule } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function AnimeCalendar() {
|
|
||||||
const [selectedDay, setSelectedDay] = useState(getCurrentDayIndex());
|
|
||||||
const [scheduleData, setScheduleData] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
// Add custom scrollbar styles
|
|
||||||
useEffect(() => {
|
|
||||||
// Add custom styles for the calendar scrollbar
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
.schedule-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
.schedule-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: var(--card);
|
|
||||||
}
|
|
||||||
.schedule-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background-color: var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
|
||||||
document.head.removeChild(style);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get current day index (0-6, Sunday is 0)
|
|
||||||
function getCurrentDayIndex() {
|
|
||||||
const dayIndex = new Date().getDay();
|
|
||||||
return dayIndex; // Sunday is 0, Monday is 1, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current date info for the header
|
|
||||||
const getCurrentDateInfo = () => {
|
|
||||||
const today = new Date();
|
|
||||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
||||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
||||||
|
|
||||||
// Calculate the date for the selected day
|
|
||||||
const currentDayIndex = today.getDay();
|
|
||||||
let daysDiff = selectedDay - currentDayIndex;
|
|
||||||
|
|
||||||
// Always get the previous occurrence (or today if it's the current day)
|
|
||||||
if (daysDiff > 0) {
|
|
||||||
daysDiff -= 7; // Go back to previous week
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedDate = new Date(today);
|
|
||||||
selectedDate.setDate(today.getDate() + daysDiff);
|
|
||||||
|
|
||||||
return {
|
|
||||||
day: dayNames[selectedDay],
|
|
||||||
date: selectedDate.getDate(),
|
|
||||||
month: monthNames[selectedDate.getMonth()]
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const dateInfo = getCurrentDateInfo();
|
|
||||||
|
|
||||||
// Generate week days for the calendar
|
|
||||||
const days = [
|
|
||||||
{ label: 'Mon', value: 1 },
|
|
||||||
{ label: 'Tue', value: 2 },
|
|
||||||
{ label: 'Wed', value: 3 },
|
|
||||||
{ label: 'Thu', value: 4 },
|
|
||||||
{ label: 'Fri', value: 5 },
|
|
||||||
{ label: 'Sat', value: 6 },
|
|
||||||
{ label: 'Sun', value: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadScheduleData() {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// Get the date for the selected day
|
|
||||||
const today = new Date();
|
|
||||||
const currentDayIndex = today.getDay();
|
|
||||||
let daysDiff = selectedDay - currentDayIndex;
|
|
||||||
|
|
||||||
if (daysDiff > 0) {
|
|
||||||
daysDiff -= 7;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedDate = new Date(today);
|
|
||||||
selectedDate.setDate(today.getDate() + daysDiff);
|
|
||||||
|
|
||||||
// Format date as YYYY-MM-DD
|
|
||||||
const formattedDate = selectedDate.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
// Fetch schedule data for the selected date
|
|
||||||
const data = await fetchSchedule(formattedDate);
|
|
||||||
|
|
||||||
if (data && data.scheduledAnimes) {
|
|
||||||
// Process and sort the scheduled animes by time
|
|
||||||
const processedData = data.scheduledAnimes
|
|
||||||
.map(anime => ({
|
|
||||||
id: anime.id,
|
|
||||||
title: anime.name,
|
|
||||||
japaneseTitle: anime.jname,
|
|
||||||
time: anime.time,
|
|
||||||
airingTimestamp: anime.airingTimestamp,
|
|
||||||
secondsUntilAiring: anime.secondsUntilAiring
|
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
// Convert time strings to comparable values (assuming 24-hour format)
|
|
||||||
const timeA = a.time.split(':').map(Number);
|
|
||||||
const timeB = b.time.split(':').map(Number);
|
|
||||||
return (timeA[0] * 60 + timeA[1]) - (timeB[0] * 60 + timeB[1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
setScheduleData(processedData);
|
|
||||||
} else {
|
|
||||||
setScheduleData([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading schedule data:', error);
|
|
||||||
setScheduleData([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadScheduleData();
|
|
||||||
}, [selectedDay]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b border-[var(--border)]">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-white">Release Calendar</h2>
|
|
||||||
<div className="text-sm text-[var(--text-muted)]">
|
|
||||||
{dateInfo.month} {dateInfo.date}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Day selector */}
|
|
||||||
<div className="flex justify-between">
|
|
||||||
{days.map((day) => (
|
|
||||||
<button
|
|
||||||
key={day.value}
|
|
||||||
onClick={() => setSelectedDay(day.value)}
|
|
||||||
className={`
|
|
||||||
flex-1 py-2 text-sm font-medium rounded-md transition-colors
|
|
||||||
${selectedDay === day.value
|
|
||||||
? 'bg-white text-[var(--background)]'
|
|
||||||
: 'text-[var(--text-muted)] hover:text-white'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{day.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Schedule list */}
|
|
||||||
<div className="min-h-[375px] max-h-[490px] overflow-y-auto schedule-scrollbar">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center h-[375px]">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white/20 border-t-white"></div>
|
|
||||||
</div>
|
|
||||||
) : scheduleData.length > 0 ? (
|
|
||||||
<div className="pt-3.5 space-y-2">
|
|
||||||
{scheduleData.map((anime) => (
|
|
||||||
<Link
|
|
||||||
href={`/anime/${anime.id}`}
|
|
||||||
key={anime.id}
|
|
||||||
className="block px-3.5 py-3 hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Time */}
|
|
||||||
<div className="w-16 text-sm font-medium text-[var(--text-muted)]">
|
|
||||||
{anime.time}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anime info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-medium text-white line-clamp-1">
|
|
||||||
{anime.title}
|
|
||||||
</h3>
|
|
||||||
{anime.japaneseTitle && (
|
|
||||||
<p className="text-xs text-[var(--text-muted)] line-clamp-1">
|
|
||||||
{anime.japaneseTitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-[375px] text-[var(--text-muted)] text-sm">
|
|
||||||
No releases scheduled
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { fetchAnimeEpisodes } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function AnimeCard({ anime, isRecent }) {
|
|
||||||
const [imageError, setImageError] = useState(false);
|
|
||||||
const [firstEpisodeId, setFirstEpisodeId] = useState(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const timerRef = useRef(null);
|
|
||||||
|
|
||||||
// Fetch first episode ID when component mounts for recent anime
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchFirstEpisode = async () => {
|
|
||||||
// Only fetch for recent anime and if we don't already have the episode ID
|
|
||||||
if (isRecent && anime?.id && !firstEpisodeId && !isLoading) {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
console.log(`[AnimeCard] Fetching episodes for anime: ${anime.id}`);
|
|
||||||
const response = await fetchAnimeEpisodes(anime.id);
|
|
||||||
console.log(`[AnimeCard] Episodes response for ${anime.name}:`, response);
|
|
||||||
|
|
||||||
if (response.episodes && response.episodes.length > 0) {
|
|
||||||
// Check for the episode ID in the format expected by the watch page
|
|
||||||
const firstEp = response.episodes[0];
|
|
||||||
if (firstEp.id) {
|
|
||||||
setFirstEpisodeId(firstEp.id);
|
|
||||||
console.log(`[AnimeCard] First episode ID (id) for ${anime.name}: ${firstEp.id}`);
|
|
||||||
} else if (firstEp.episodeId) {
|
|
||||||
setFirstEpisodeId(firstEp.episodeId);
|
|
||||||
console.log(`[AnimeCard] First episode ID (episodeId) for ${anime.name}: ${firstEp.episodeId}`);
|
|
||||||
} else {
|
|
||||||
// Create a fallback ID if neither id nor episodeId are available
|
|
||||||
const fallbackId = `${anime.id}?ep=1`;
|
|
||||||
setFirstEpisodeId(fallbackId);
|
|
||||||
console.log(`[AnimeCard] Using fallback ID for ${anime.name}: ${fallbackId}`);
|
|
||||||
}
|
|
||||||
} else if (anime.id) {
|
|
||||||
// If no episodes found, create a fallback ID
|
|
||||||
const fallbackId = `${anime.id}?ep=1`;
|
|
||||||
setFirstEpisodeId(fallbackId);
|
|
||||||
console.log(`[AnimeCard] No episodes found for ${anime.name}, using fallback ID: ${fallbackId}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[AnimeCard] Error fetching episodes for ${anime.id}:`, error);
|
|
||||||
// Even on error, try to use fallback
|
|
||||||
if (anime.id) {
|
|
||||||
const fallbackId = `${anime.id}?ep=1`;
|
|
||||||
setFirstEpisodeId(fallbackId);
|
|
||||||
console.log(`[AnimeCard] Error for ${anime.name}, using fallback ID: ${fallbackId}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchFirstEpisode();
|
|
||||||
|
|
||||||
// Clean up timer if component unmounts
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}, [anime?.id, anime?.name, isRecent, firstEpisodeId, isLoading]);
|
|
||||||
|
|
||||||
if (!anime) return null;
|
|
||||||
|
|
||||||
const handleImageError = () => {
|
|
||||||
console.log("Image error for:", anime.name);
|
|
||||||
setImageError(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get image URL with fallback
|
|
||||||
const imageSrc = imageError ? '/images/placeholder.png' : anime.poster;
|
|
||||||
|
|
||||||
// Generate appropriate links
|
|
||||||
const infoLink = `/anime/${anime.id}`;
|
|
||||||
|
|
||||||
// Build the watch URL based on the first episode ID or fallback
|
|
||||||
const watchLink = isRecent && firstEpisodeId
|
|
||||||
? `/watch/${firstEpisodeId}`
|
|
||||||
: isRecent
|
|
||||||
? `/anime/${anime.id}` // Temporarily link to info page while loading
|
|
||||||
: `/anime/${anime.id}`; // Non-recent anime always link to info
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="anime-card w-full flex flex-col">
|
|
||||||
{/* Image card linking to watch page for recent anime, or info page otherwise */}
|
|
||||||
<Link
|
|
||||||
href={isRecent ? watchLink : infoLink}
|
|
||||||
className="block w-full rounded-lg overflow-hidden transition-transform duration-300 hover:scale-[1.02] group"
|
|
||||||
prefetch={false}
|
|
||||||
>
|
|
||||||
<div className="relative aspect-[2/3] rounded-lg overflow-hidden bg-gray-900 shadow-lg">
|
|
||||||
{/* Hover overlay */}
|
|
||||||
<div className="absolute inset-0 bg-black opacity-0 group-hover:opacity-60 transition-opacity duration-300 z-[3]"></div>
|
|
||||||
|
|
||||||
{/* Play button triangle - appears on hover */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-16 h-16 text-white drop-shadow-lg">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Image
|
|
||||||
src={imageSrc}
|
|
||||||
alt={anime.name || 'Anime'}
|
|
||||||
fill
|
|
||||||
className="object-cover rounded-lg"
|
|
||||||
onError={handleImageError}
|
|
||||||
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 20vw"
|
|
||||||
unoptimized={true}
|
|
||||||
priority={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Badges in bottom left */}
|
|
||||||
<div className="absolute bottom-2 left-2 flex space-x-1 z-10">
|
|
||||||
{/* Episode badges */}
|
|
||||||
{anime.episodes && (
|
|
||||||
<>
|
|
||||||
{anime.episodes.sub > 0 && (
|
|
||||||
<div className="bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded">
|
|
||||||
SUB {anime.episodes.sub}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{anime.episodes.dub > 0 && (
|
|
||||||
<div className="bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded">
|
|
||||||
DUB {anime.episodes.dub}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Type badge */}
|
|
||||||
{anime.type && (
|
|
||||||
<div className="bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded">
|
|
||||||
{anime.type}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Title linking to info page */}
|
|
||||||
<Link
|
|
||||||
href={infoLink}
|
|
||||||
className="block mt-2"
|
|
||||||
prefetch={false}
|
|
||||||
>
|
|
||||||
<h3 className="text-sm font-medium text-white line-clamp-2 hover:text-[var(--primary)] transition-colors">
|
|
||||||
{anime.name}
|
|
||||||
</h3>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import AnimeRow from './AnimeRow';
|
|
||||||
import SeasonRow from './SeasonRow';
|
|
||||||
import { fetchAnimeEpisodes } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function AnimeDetails({ anime }) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [activeVideo, setActiveVideo] = useState(null);
|
|
||||||
const [activeTab, setActiveTab] = useState('synopsis');
|
|
||||||
const [synopsisOverflows, setSynopsisOverflows] = useState(false);
|
|
||||||
const [firstEpisodeId, setFirstEpisodeId] = useState(null);
|
|
||||||
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
|
|
||||||
const synopsisRef = useRef(null);
|
|
||||||
|
|
||||||
// Check if synopsis overflows when component mounts or when content changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (synopsisRef.current) {
|
|
||||||
const element = synopsisRef.current;
|
|
||||||
setSynopsisOverflows(element.scrollHeight > element.clientHeight);
|
|
||||||
}
|
|
||||||
}, [anime?.info?.description, activeTab]);
|
|
||||||
|
|
||||||
// Fetch first episode ID when component mounts
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchFirstEpisode = async () => {
|
|
||||||
if (anime?.info?.id) {
|
|
||||||
setIsLoadingEpisodes(true);
|
|
||||||
try {
|
|
||||||
console.log(`[AnimeDetails] Fetching episodes for anime: ${anime.info.id}`);
|
|
||||||
const response = await fetchAnimeEpisodes(anime.info.id);
|
|
||||||
console.log('[AnimeDetails] Episodes response:', response);
|
|
||||||
|
|
||||||
if (response.episodes && response.episodes.length > 0) {
|
|
||||||
// Log the first episode to check its structure
|
|
||||||
console.log('[AnimeDetails] First episode:', response.episodes[0]);
|
|
||||||
|
|
||||||
// Get the first episode's id
|
|
||||||
const firstEp = response.episodes[0];
|
|
||||||
if (firstEp.id) {
|
|
||||||
setFirstEpisodeId(firstEp.id);
|
|
||||||
console.log(`[AnimeDetails] First episode ID found: ${firstEp.id}`);
|
|
||||||
} else if (firstEp.episodeId) {
|
|
||||||
// Fallback to episodeId if id is not available
|
|
||||||
setFirstEpisodeId(firstEp.episodeId);
|
|
||||||
console.log(`[AnimeDetails] Falling back to episodeId: ${firstEp.episodeId}`);
|
|
||||||
} else {
|
|
||||||
// If no episode ID is found in the API response, create a fallback ID
|
|
||||||
const fallbackId = `${anime.info.id}?ep=1`;
|
|
||||||
setFirstEpisodeId(fallbackId);
|
|
||||||
console.log(`[AnimeDetails] Using fallback ID: ${fallbackId}`);
|
|
||||||
}
|
|
||||||
} else if (anime.info.id) {
|
|
||||||
// If no episodes found but anime ID is available, use fallback
|
|
||||||
const fallbackId = `${anime.info.id}?ep=1`;
|
|
||||||
setFirstEpisodeId(fallbackId);
|
|
||||||
console.log(`[AnimeDetails] No episodes found, using fallback ID: ${fallbackId}`);
|
|
||||||
} else {
|
|
||||||
console.warn('[AnimeDetails] No episodes found and no anime ID available');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[AnimeDetails] Error fetching episodes:', error);
|
|
||||||
// Even on error, try to use fallback
|
|
||||||
if (anime.info.id) {
|
|
||||||
const fallbackId = `${anime.info.id}?ep=1`;
|
|
||||||
setFirstEpisodeId(fallbackId);
|
|
||||||
console.log(`[AnimeDetails] Error occurred, using fallback ID: ${fallbackId}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoadingEpisodes(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchFirstEpisode();
|
|
||||||
}, [anime?.info?.id]);
|
|
||||||
|
|
||||||
// Add a useEffect to debug when and why firstEpisodeId changes
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('[AnimeDetails] firstEpisodeId changed:', firstEpisodeId);
|
|
||||||
}, [firstEpisodeId]);
|
|
||||||
|
|
||||||
if (!anime?.info) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { info, moreInfo, relatedAnime, recommendations, seasons } = anime;
|
|
||||||
const hasCharacters = info.characterVoiceActor?.length > 0 || info.charactersVoiceActors?.length > 0;
|
|
||||||
const hasVideos = info.promotionalVideos && info.promotionalVideos.length > 0;
|
|
||||||
|
|
||||||
// Build the watch URL based on the first episode ID
|
|
||||||
const watchUrl = firstEpisodeId
|
|
||||||
? `/watch/${firstEpisodeId}`
|
|
||||||
: ''; // Empty string if no episodes available - this shouldn't happen with our fallback
|
|
||||||
|
|
||||||
// Add debug log here
|
|
||||||
console.log('[AnimeDetails] Rendered with watchUrl:', watchUrl, 'firstEpisodeId:', firstEpisodeId);
|
|
||||||
|
|
||||||
// Video modal for promotional videos
|
|
||||||
const VideoModal = ({ video, onClose }) => {
|
|
||||||
if (!video) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-3 backdrop-blur-sm">
|
|
||||||
<div className="relative w-full max-w-4xl bg-[var(--card)] rounded-lg overflow-hidden shadow-2xl border border-gray-700 animate-fadeIn">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-3 right-3 z-10 bg-black/50 rounded-full p-1.5 hover:bg-black/70 transition-colors"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 sm:h-6 sm:w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="aspect-video w-full">
|
|
||||||
<iframe
|
|
||||||
src={video.source}
|
|
||||||
title={video.title || "Promotional Video"}
|
|
||||||
allowFullScreen
|
|
||||||
className="w-full h-full"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format status with aired date
|
|
||||||
const getStatusWithAired = () => {
|
|
||||||
let status = moreInfo?.status || '';
|
|
||||||
if (moreInfo?.aired) {
|
|
||||||
status += ` (${moreInfo.aired})`;
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
{/* Video Modal */}
|
|
||||||
{activeVideo && <VideoModal video={activeVideo} onClose={() => setActiveVideo(null)} />}
|
|
||||||
|
|
||||||
{/* Background Image with Gradient Overlay - Desktop Only */}
|
|
||||||
<div className="absolute inset-0 h-[180px] md:h-[400px] overflow-hidden -z-10">
|
|
||||||
{info.poster && (
|
|
||||||
<>
|
|
||||||
<Image
|
|
||||||
src={info.poster}
|
|
||||||
alt={info.name}
|
|
||||||
fill
|
|
||||||
className="object-cover opacity-18"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[rgba(0,0,0,0.6)] to-[var(--background)]"></div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="container mx-auto px-3 md:px-4 pt-3 md:pt-10">
|
|
||||||
{/* MOBILE LAYOUT - Only visible on mobile */}
|
|
||||||
<div className="md:hidden">
|
|
||||||
<div className="flex flex-col mb-5">
|
|
||||||
{/* Mobile Header with Title + Rating */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h1 className="text-xl font-bold text-white pr-3">{info.name}</h1>
|
|
||||||
{info.stats?.rating && (
|
|
||||||
<div className="flex items-center bg-[var(--card)] px-2 py-1 rounded-md">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-3.5 w-3.5 text-yellow-400 mr-1"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-white text-xs font-medium">{info.stats.rating}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Japanese Title */}
|
|
||||||
{moreInfo?.japanese && (
|
|
||||||
<h2 className="text-xs text-gray-300 mt-[-0.25rem] mb-3">{moreInfo.japanese}</h2>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile Two-Column Layout */}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{/* Left Column - Poster */}
|
|
||||||
<div className="w-2/5 flex-shrink-0">
|
|
||||||
<div className="bg-[var(--card)] rounded-xl overflow-hidden shadow-lg border border-gray-800">
|
|
||||||
<div className="relative aspect-[3/4] w-full">
|
|
||||||
<Image
|
|
||||||
src={info.poster}
|
|
||||||
alt={info.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Info Card */}
|
|
||||||
<div className="w-3/5 flex flex-col">
|
|
||||||
{/* Type & Episodes on same row */}
|
|
||||||
<div className="flex gap-2 mb-2">
|
|
||||||
{info.stats?.type && (
|
|
||||||
<div className="bg-[var(--card)] px-2 py-1 rounded-md text-[10px] text-white">{info.stats.type}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info.stats?.episodes && (
|
|
||||||
<div className="bg-[var(--card)] px-2 py-1 rounded-md text-[10px] text-white grow">
|
|
||||||
{info.stats.episodes.sub > 0 && `Sub: ${info.stats.episodes.sub}`}
|
|
||||||
{info.stats.episodes.dub > 0 && info.stats.episodes.sub > 0 && ' • '}
|
|
||||||
{info.stats.episodes.dub > 0 && `Dub: ${info.stats.episodes.dub}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clean Info Layout */}
|
|
||||||
<div className="bg-[var(--card)] rounded-md p-2.5 text-[11px] space-y-1.5 mb-2">
|
|
||||||
{/* Status */}
|
|
||||||
{moreInfo?.status && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-16">Status:</span>
|
|
||||||
<span className="text-white">{getStatusWithAired()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quality */}
|
|
||||||
{info.stats?.quality && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-16">Quality:</span>
|
|
||||||
<span className="text-white">{info.stats.quality}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Duration */}
|
|
||||||
{info.stats?.duration && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-16">Duration:</span>
|
|
||||||
<span className="text-white">{info.stats.duration}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Studio */}
|
|
||||||
{moreInfo?.studios && (
|
|
||||||
<div className="flex">
|
|
||||||
<span className="text-gray-400 w-16">Studio:</span>
|
|
||||||
<span className="text-white">{moreInfo.studios}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Genres */}
|
|
||||||
{moreInfo?.genres && moreInfo.genres.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{moreInfo.genres.slice(0, 5).map((genre, index) => (
|
|
||||||
<Link
|
|
||||||
key={index}
|
|
||||||
href={`/genre/${genre.toLowerCase()}`}
|
|
||||||
className="bg-[var(--card)] px-2 py-0.5 rounded-md text-[10px] text-gray-300 hover:text-white"
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
{moreInfo.genres.length > 5 && (
|
|
||||||
<span className="text-[10px] text-gray-500 self-center">+{moreInfo.genres.length - 5}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Watch Button - Mobile */}
|
|
||||||
{firstEpisodeId && (
|
|
||||||
<Link
|
|
||||||
href={watchUrl}
|
|
||||||
className="bg-[#ffffff] text-[var(--background)] px-4 py-2.5 rounded-xl mt-3 hover:opacity-90 transition-opacity flex items-center justify-center font-medium text-sm w-full shadow-lg"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
className="h-4 w-4 mr-1.5"
|
|
||||||
>
|
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z" />
|
|
||||||
</svg>
|
|
||||||
<span>Start Watching</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DESKTOP LAYOUT - Only visible on desktop */}
|
|
||||||
<div className="hidden md:flex md:flex-row gap-10 mb-8">
|
|
||||||
{/* Poster */}
|
|
||||||
<div className="w-1/4 max-w-[240px]">
|
|
||||||
<div className="bg-[var(--card)] rounded-xl overflow-hidden shadow-lg border border-gray-800">
|
|
||||||
<div className="relative aspect-[3/4] w-full">
|
|
||||||
<Image
|
|
||||||
src={info.poster}
|
|
||||||
alt={info.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Watch Button - Desktop */}
|
|
||||||
{firstEpisodeId && (
|
|
||||||
<Link
|
|
||||||
href={watchUrl}
|
|
||||||
className="bg-[#ffffff] text-[var(--background)] px-6 py-3 rounded-xl mt-4 hover:opacity-90 transition-opacity flex items-center justify-center font-medium text-base w-full shadow-lg"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
className="h-5 w-5 mr-2"
|
|
||||||
>
|
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z" />
|
|
||||||
</svg>
|
|
||||||
<span>Start Watching</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title and Metadata */}
|
|
||||||
<div className="flex-1 pt-2">
|
|
||||||
{/* Title Section */}
|
|
||||||
<div className="text-left">
|
|
||||||
<h1 className="text-3xl lg:text-4xl font-bold text-white mb-2">
|
|
||||||
{info.name}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{moreInfo?.japanese && (
|
|
||||||
<h2 className="text-base md:text-lg text-gray-400 mb-2">{moreInfo.japanese}</h2>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Synonyms */}
|
|
||||||
{moreInfo?.synonyms && (
|
|
||||||
<div className="mt-2 mb-4">
|
|
||||||
<p className="text-sm text-gray-400 italic">{moreInfo.synonyms}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Badges */}
|
|
||||||
<div className="flex flex-wrap justify-start gap-2 my-4">
|
|
||||||
{info.stats?.rating && (
|
|
||||||
<div className="flex items-center bg-[var(--card)] px-3 py-1.5 rounded-full">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="h-4 w-4 text-yellow-400 mr-1"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-white text-sm font-medium">{info.stats.rating}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status with Aired Date */}
|
|
||||||
{moreInfo?.status && (
|
|
||||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
|
||||||
{getStatusWithAired()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info.stats?.type && (
|
|
||||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
|
||||||
{info.stats.type}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info.stats?.episodes && (
|
|
||||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
|
||||||
{info.stats.episodes.sub > 0 && `SUB ${info.stats.episodes.sub}`}
|
|
||||||
{info.stats.episodes.dub > 0 && info.stats.episodes.sub > 0 && ' | '}
|
|
||||||
{info.stats.episodes.dub > 0 && `DUB ${info.stats.episodes.dub}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info.stats?.quality && (
|
|
||||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
|
||||||
{info.stats.quality}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{info.stats?.duration && (
|
|
||||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
|
||||||
{info.stats.duration}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Genres & Studios */}
|
|
||||||
<div className="space-y-4 mt-4">
|
|
||||||
{/* Genres */}
|
|
||||||
{moreInfo?.genres && moreInfo.genres.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white text-base font-medium mb-3 text-left">Genres</h3>
|
|
||||||
<div className="flex flex-wrap justify-start gap-2">
|
|
||||||
{moreInfo.genres.map((genre, index) => (
|
|
||||||
<Link
|
|
||||||
key={index}
|
|
||||||
href={`/genre/${genre.toLowerCase()}`}
|
|
||||||
className="px-3 py-1.5 bg-[var(--card)] text-gray-300 text-sm rounded-full whitespace-nowrap hover:text-white transition-colors hover:bg-[var(--card-hover)]"
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Studios */}
|
|
||||||
{moreInfo?.studios && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white text-base font-medium mb-3 text-left">Studios</h3>
|
|
||||||
<div className="flex flex-wrap justify-start gap-2">
|
|
||||||
<div className="px-3 py-1.5 bg-[var(--card)] text-gray-300 text-sm rounded-full hover:text-white">
|
|
||||||
{moreInfo.studios}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs Section - Different for Mobile/Desktop */}
|
|
||||||
<div className="bg-[var(--card)] rounded-lg mb-6 shadow-lg border border-gray-800">
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="flex border-b border-gray-800">
|
|
||||||
{/* Synopsis Tab */}
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2.5 md:py-3 text-sm md:text-base font-medium transition-colors flex-1 md:flex-none ${activeTab === 'synopsis' ? 'text-white border-b-2 border-[var(--primary)]' : 'text-gray-400 hover:text-white'}`}
|
|
||||||
onClick={() => setActiveTab('synopsis')}
|
|
||||||
>
|
|
||||||
Synopsis
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Characters Tab */}
|
|
||||||
{hasCharacters && (
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2.5 md:py-3 text-sm md:text-base font-medium transition-colors flex-1 md:flex-none ${activeTab === 'characters' ? 'text-white border-b-2 border-[var(--primary)]' : 'text-gray-400 hover:text-white'}`}
|
|
||||||
onClick={() => setActiveTab('characters')}
|
|
||||||
>
|
|
||||||
Characters
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Videos Tab */}
|
|
||||||
{hasVideos && (
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2.5 md:py-3 text-sm md:text-base font-medium transition-colors flex-1 md:flex-none ${activeTab === 'videos' ? 'text-white border-b-2 border-[var(--primary)]' : 'text-gray-400 hover:text-white'}`}
|
|
||||||
onClick={() => setActiveTab('videos')}
|
|
||||||
>
|
|
||||||
<span>Videos</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="p-3 md:p-5">
|
|
||||||
{/* Synopsis Tab */}
|
|
||||||
{activeTab === 'synopsis' && (
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
ref={synopsisRef}
|
|
||||||
className={`text-gray-300 leading-relaxed text-xs md:text-base ${!isExpanded ? 'line-clamp-4 md:line-clamp-6' : ''}`}
|
|
||||||
>
|
|
||||||
{info.description || 'No description available for this anime.'}
|
|
||||||
</p>
|
|
||||||
{synopsisOverflows && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="text-[var(--primary)] hover:underline text-xs md:text-sm mt-2 md:mt-3 font-medium"
|
|
||||||
>
|
|
||||||
{isExpanded ? 'Show Less' : 'Read More'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Characters Tab */}
|
|
||||||
{activeTab === 'characters' && hasCharacters && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 max-h-[60vh] md:max-h-[70vh] overflow-y-auto">
|
|
||||||
{(info.characterVoiceActor || info.charactersVoiceActors || []).map((item, index) => (
|
|
||||||
<div key={index} className="bg-[var(--background)] rounded overflow-hidden flex">
|
|
||||||
{/* Character Image */}
|
|
||||||
<div className="relative w-[40px] md:w-[60px] h-[50px] md:h-[72px] flex-shrink-0">
|
|
||||||
<Image
|
|
||||||
src={item.character.poster}
|
|
||||||
alt={item.character.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text content in the middle */}
|
|
||||||
<div className="flex-1 py-1 md:py-2.5 px-2 md:px-3 flex flex-col justify-center min-w-0">
|
|
||||||
<div className="flex justify-between items-center gap-1 md:gap-3">
|
|
||||||
{/* Character Name */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-white font-medium text-xs md:text-sm truncate">{item.character.name}</p>
|
|
||||||
<p className="text-[10px] md:text-xs text-gray-400 truncate">{item.character.cast || 'Main'}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Voice Actor Name */}
|
|
||||||
<div className="min-w-0 flex-1 text-right">
|
|
||||||
<p className="text-white font-medium text-xs md:text-sm truncate">{item.voiceActor.name}</p>
|
|
||||||
<p className="text-[10px] md:text-xs text-gray-400 truncate">{item.voiceActor.cast || 'Japanese'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Voice Actor Image */}
|
|
||||||
<div className="relative w-[40px] md:w-[60px] h-[50px] md:h-[72px] flex-shrink-0">
|
|
||||||
<Image
|
|
||||||
src={item.voiceActor.poster}
|
|
||||||
alt={item.voiceActor.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Videos Tab */}
|
|
||||||
{activeTab === 'videos' && hasVideos && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
||||||
{info.promotionalVideos.map((video, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="relative aspect-video cursor-pointer group overflow-hidden rounded"
|
|
||||||
onClick={() => setActiveVideo(video)}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-black/40 group-hover:bg-black/20 transition-all duration-300 flex items-center justify-center">
|
|
||||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-[var(--primary)] flex items-center justify-center transform group-hover:scale-110 transition-transform duration-300">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 md:h-5 md:w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Image
|
|
||||||
src={video.thumbnail || '/images/video-placeholder.jpg'}
|
|
||||||
alt={video.title || `Promotional Video ${index + 1}`}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Seasons Section */}
|
|
||||||
{seasons && seasons.length > 0 && (
|
|
||||||
<SeasonRow title="Seasons" seasons={seasons} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Related Anime Section */}
|
|
||||||
{relatedAnime && relatedAnime.length > 0 && (
|
|
||||||
<AnimeRow title="Related Anime" animeList={relatedAnime} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recommendations Section */}
|
|
||||||
{recommendations && recommendations.length > 0 && (
|
|
||||||
<AnimeRow title="You May Also Like" animeList={recommendations} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,597 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { fetchGenres } from '@/lib/api';
|
|
||||||
import { ChevronDownIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
// Helper function to capitalize first letter of each word
|
|
||||||
const capitalizeFirstLetter = (string) => {
|
|
||||||
if (!string) return '';
|
|
||||||
return string.split(' ').map(word =>
|
|
||||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
||||||
).join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AnimeFilters({
|
|
||||||
selectedGenre,
|
|
||||||
onGenreChange,
|
|
||||||
yearFilter,
|
|
||||||
onYearChange,
|
|
||||||
sortOrder,
|
|
||||||
onSortChange,
|
|
||||||
showGenreFilter = true,
|
|
||||||
searchQuery = '',
|
|
||||||
onSearchChange,
|
|
||||||
selectedSeasons = [],
|
|
||||||
onSeasonChange,
|
|
||||||
selectedTypes = [],
|
|
||||||
onTypeChange,
|
|
||||||
selectedStatus = [],
|
|
||||||
onStatusChange,
|
|
||||||
selectedLanguages = [],
|
|
||||||
onLanguageChange
|
|
||||||
}) {
|
|
||||||
const [genres, setGenres] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [dropdowns, setDropdowns] = useState({
|
|
||||||
genre: false,
|
|
||||||
season: false,
|
|
||||||
year: false,
|
|
||||||
type: false,
|
|
||||||
status: false,
|
|
||||||
language: false,
|
|
||||||
sort: false
|
|
||||||
});
|
|
||||||
const dropdownRefs = useRef({
|
|
||||||
genre: null,
|
|
||||||
season: null,
|
|
||||||
year: null,
|
|
||||||
type: null,
|
|
||||||
status: null,
|
|
||||||
language: null,
|
|
||||||
sort: null
|
|
||||||
});
|
|
||||||
|
|
||||||
// Available years for filter (current year down to 2000 and 'older')
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const years = ['all', ...Array.from({ length: currentYear - 1999 }, (_, i) => (currentYear - i).toString()), 'older'];
|
|
||||||
|
|
||||||
// Seasons data
|
|
||||||
const seasons = ['Winter', 'Spring', 'Summer', 'Fall'];
|
|
||||||
|
|
||||||
// Types data
|
|
||||||
const types = ['TV', 'Movie', 'OVA', 'ONA', 'Special'];
|
|
||||||
|
|
||||||
// Status data
|
|
||||||
const statuses = ['Ongoing', 'Completed', 'Upcoming'];
|
|
||||||
|
|
||||||
// Languages data
|
|
||||||
const languages = ['Subbed', 'Dubbed', 'Chinese', 'English'];
|
|
||||||
|
|
||||||
// Fetch genres on component mount
|
|
||||||
useEffect(() => {
|
|
||||||
const getGenres = async () => {
|
|
||||||
if (!showGenreFilter) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const genreData = await fetchGenres();
|
|
||||||
// Capitalize each genre
|
|
||||||
const capitalizedGenres = genreData ? genreData.map(capitalizeFirstLetter) : [];
|
|
||||||
setGenres(capitalizedGenres);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching genres:', error);
|
|
||||||
setError('Failed to load genres. Please try again later.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getGenres();
|
|
||||||
}, [showGenreFilter]);
|
|
||||||
|
|
||||||
// Toggle dropdown visibility
|
|
||||||
const toggleDropdown = (dropdown) => {
|
|
||||||
setDropdowns(prev => {
|
|
||||||
// Close other dropdowns when opening one
|
|
||||||
const newState = {
|
|
||||||
genre: false,
|
|
||||||
season: false,
|
|
||||||
year: false,
|
|
||||||
type: false,
|
|
||||||
status: false,
|
|
||||||
language: false,
|
|
||||||
sort: false,
|
|
||||||
[dropdown]: !prev[dropdown]
|
|
||||||
};
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize refs for each dropdown
|
|
||||||
useEffect(() => {
|
|
||||||
dropdownRefs.current = {
|
|
||||||
genre: dropdownRefs.current.genre,
|
|
||||||
season: dropdownRefs.current.season,
|
|
||||||
year: dropdownRefs.current.year,
|
|
||||||
type: dropdownRefs.current.type,
|
|
||||||
status: dropdownRefs.current.status,
|
|
||||||
language: dropdownRefs.current.language,
|
|
||||||
sort: dropdownRefs.current.sort
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Close all dropdowns when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
// Check if the click was outside all dropdown containers
|
|
||||||
let isOutside = true;
|
|
||||||
Object.keys(dropdownRefs.current).forEach(key => {
|
|
||||||
if (dropdownRefs.current[key] && dropdownRefs.current[key].contains(event.target)) {
|
|
||||||
isOutside = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isOutside) {
|
|
||||||
setDropdowns({
|
|
||||||
genre: false,
|
|
||||||
season: false,
|
|
||||||
year: false,
|
|
||||||
type: false,
|
|
||||||
status: false,
|
|
||||||
language: false,
|
|
||||||
sort: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Prevent dropdown from closing when selecting an item in multiselect
|
|
||||||
const keepDropdownOpen = (e, dropdown) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// Don't toggle the dropdown state on item click for multi-select dropdowns
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearGenre = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onGenreChange) {
|
|
||||||
onGenreChange(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toggle multi-select filter
|
|
||||||
const handleMultiSelectToggle = (type, value, onChange) => {
|
|
||||||
if (!onChange) return;
|
|
||||||
|
|
||||||
let updatedSelection;
|
|
||||||
if (type.includes(value)) {
|
|
||||||
updatedSelection = type.filter(item => item !== value);
|
|
||||||
} else {
|
|
||||||
updatedSelection = [...type, value];
|
|
||||||
}
|
|
||||||
onChange(updatedSelection);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modify the onClick handlers for each button to prevent event propagation
|
|
||||||
const handleGenreSelect = (e, genre) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onGenreChange) {
|
|
||||||
onGenreChange(genre);
|
|
||||||
// Close genre dropdown after selection since it's a single select
|
|
||||||
setDropdowns(prev => ({ ...prev, genre: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearSelect = (e, year) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onYearChange) {
|
|
||||||
onYearChange(year);
|
|
||||||
// Close year dropdown after selection since it's a single select
|
|
||||||
setDropdowns(prev => ({ ...prev, year: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSortSelect = (e, sort) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onSortChange) {
|
|
||||||
onSortChange(sort);
|
|
||||||
// Close sort dropdown after selection since it's a single select
|
|
||||||
setDropdowns(prev => ({ ...prev, sort: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMultiSelect = (e, type, value, onChange, dropdown) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
let updatedSelection;
|
|
||||||
if (type.includes(value)) {
|
|
||||||
updatedSelection = type.filter(item => item !== value);
|
|
||||||
} else {
|
|
||||||
updatedSelection = [...type, value];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onChange) {
|
|
||||||
onChange(updatedSelection);
|
|
||||||
// Keep dropdown open for multiselect to allow multiple selections
|
|
||||||
// Without closing the dropdown
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add clear filter handlers
|
|
||||||
const clearAllFilters = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onGenreChange) onGenreChange(null);
|
|
||||||
if (onYearChange) onYearChange('all');
|
|
||||||
if (onSortChange) onSortChange('default');
|
|
||||||
if (onSeasonChange) onSeasonChange([]);
|
|
||||||
if (onTypeChange) onTypeChange([]);
|
|
||||||
if (onStatusChange) onStatusChange([]);
|
|
||||||
if (onLanguageChange) onLanguageChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearGenre = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onGenreChange) onGenreChange(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearYear = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onYearChange) onYearChange('all');
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSort = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onSortChange) onSortChange('default');
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSeasons = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onSeasonChange) onSeasonChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearTypes = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onTypeChange) onTypeChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearStatus = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onStatusChange) onStatusChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearLanguages = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onLanguageChange) onLanguageChange([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get display text for filters
|
|
||||||
const getYearDisplayText = () => {
|
|
||||||
if (yearFilter === 'all') return 'Year';
|
|
||||||
if (yearFilter === 'older') return 'Before 2000';
|
|
||||||
return yearFilter;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSortDisplayText = () => {
|
|
||||||
switch (sortOrder) {
|
|
||||||
case 'title-asc': return 'Title (A-Z)';
|
|
||||||
case 'title-desc': return 'Title (Z-A)';
|
|
||||||
case 'year-desc': return 'Newest First';
|
|
||||||
case 'year-asc': return 'Oldest First';
|
|
||||||
default: return 'Default';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if any filter is active
|
|
||||||
const isAnyFilterActive = () => {
|
|
||||||
return selectedGenre !== null ||
|
|
||||||
yearFilter !== 'all' ||
|
|
||||||
sortOrder !== 'default' ||
|
|
||||||
selectedSeasons.length > 0 ||
|
|
||||||
selectedTypes.length > 0 ||
|
|
||||||
selectedStatus.length > 0 ||
|
|
||||||
selectedLanguages.length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{/* Genre Filter */}
|
|
||||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.genre = el}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleDropdown('genre')}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-white/80">
|
|
||||||
{selectedGenre ? selectedGenre : 'Genre'}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<XMarkIcon
|
|
||||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${!selectedGenre ? 'opacity-40' : 'opacity-100'}`}
|
|
||||||
onClick={clearGenre}
|
|
||||||
/>
|
|
||||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.genre ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{dropdowns.genre && (
|
|
||||||
<div className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
|
||||||
<div className="max-h-[250px] overflow-y-auto custom-scrollbar">
|
|
||||||
{genres.map((genre) => (
|
|
||||||
<button
|
|
||||||
key={genre}
|
|
||||||
onClick={(e) => handleGenreSelect(e, genre)}
|
|
||||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
|
||||||
selectedGenre === genre ? 'text-white font-medium' : 'text-white/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Year Filter */}
|
|
||||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.year = el}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleDropdown('year')}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-white/80">
|
|
||||||
{getYearDisplayText()}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<XMarkIcon
|
|
||||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${yearFilter === 'all' ? 'opacity-40' : 'opacity-100'}`}
|
|
||||||
onClick={clearYear}
|
|
||||||
/>
|
|
||||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.year ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{dropdowns.year && (
|
|
||||||
<div className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
|
||||||
<div className="max-h-[250px] overflow-y-auto custom-scrollbar">
|
|
||||||
{years.map((year) => (
|
|
||||||
<button
|
|
||||||
key={year}
|
|
||||||
onClick={(e) => handleYearSelect(e, year)}
|
|
||||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
|
||||||
yearFilter === year ? 'text-white font-medium' : 'text-white/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{year === 'older' ? 'Before 2000' : year === 'all' ? 'All Years' : year}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Season Filter */}
|
|
||||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.season = el}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleDropdown('season')}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-white/80">
|
|
||||||
{selectedSeasons.length > 0 ? `${selectedSeasons.length} Selected` : 'Season'}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<XMarkIcon
|
|
||||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${selectedSeasons.length === 0 ? 'opacity-40' : 'opacity-100'}`}
|
|
||||||
onClick={clearSeasons}
|
|
||||||
/>
|
|
||||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.season ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{dropdowns.season && (
|
|
||||||
<div onClick={(e) => keepDropdownOpen(e, 'season')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
|
||||||
{seasons.map((season) => (
|
|
||||||
<button
|
|
||||||
key={season}
|
|
||||||
onClick={(e) => handleMultiSelect(e, selectedSeasons, season, onSeasonChange, 'season')}
|
|
||||||
className="w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<span className={`text-[13px] ${selectedSeasons.includes(season) ? 'text-white font-medium' : 'text-white/70'}`}>
|
|
||||||
{season}
|
|
||||||
</span>
|
|
||||||
{selectedSeasons.includes(season) && (
|
|
||||||
<CheckIcon className="w-4 h-4 text-primary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Format Filter */}
|
|
||||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.type = el}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleDropdown('type')}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-white/80">
|
|
||||||
{selectedTypes.length > 0 ? `${selectedTypes.length} Selected` : 'Format'}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<XMarkIcon
|
|
||||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${selectedTypes.length === 0 ? 'opacity-40' : 'opacity-100'}`}
|
|
||||||
onClick={clearTypes}
|
|
||||||
/>
|
|
||||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.type ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{dropdowns.type && (
|
|
||||||
<div onClick={(e) => keepDropdownOpen(e, 'type')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
|
||||||
{types.map((type) => (
|
|
||||||
<button
|
|
||||||
key={type}
|
|
||||||
onClick={(e) => handleMultiSelect(e, selectedTypes, type, onTypeChange, 'type')}
|
|
||||||
className="w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<span className={`text-[13px] ${selectedTypes.includes(type) ? 'text-white font-medium' : 'text-white/70'}`}>
|
|
||||||
{type}
|
|
||||||
</span>
|
|
||||||
{selectedTypes.includes(type) && (
|
|
||||||
<CheckIcon className="w-4 h-4 text-primary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.status = el}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleDropdown('status')}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-white/80">
|
|
||||||
{selectedStatus.length > 0 ? `${selectedStatus.length} Selected` : 'Status'}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<XMarkIcon
|
|
||||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${selectedStatus.length === 0 ? 'opacity-40' : 'opacity-100'}`}
|
|
||||||
onClick={clearStatus}
|
|
||||||
/>
|
|
||||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.status ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{dropdowns.status && (
|
|
||||||
<div onClick={(e) => keepDropdownOpen(e, 'status')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
|
||||||
{statuses.map((status) => (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
onClick={(e) => handleMultiSelect(e, selectedStatus, status, onStatusChange, 'status')}
|
|
||||||
className="w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<span className={`text-[13px] ${selectedStatus.includes(status) ? 'text-white font-medium' : 'text-white/70'}`}>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
{selectedStatus.includes(status) && (
|
|
||||||
<CheckIcon className="w-4 h-4 text-primary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Language Filter */}
|
|
||||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.language = el}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleDropdown('language')}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-white/80">
|
|
||||||
{selectedLanguages.length > 0 ? `${selectedLanguages.length} Selected` : 'Language'}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<XMarkIcon
|
|
||||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${selectedLanguages.length === 0 ? 'opacity-40' : 'opacity-100'}`}
|
|
||||||
onClick={clearLanguages}
|
|
||||||
/>
|
|
||||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.language ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{dropdowns.language && (
|
|
||||||
<div onClick={(e) => keepDropdownOpen(e, 'language')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
|
||||||
{languages.map((language) => (
|
|
||||||
<button
|
|
||||||
key={language}
|
|
||||||
onClick={(e) => handleMultiSelect(e, selectedLanguages, language, onLanguageChange, 'language')}
|
|
||||||
className="w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<span className={`text-[13px] ${selectedLanguages.includes(language) ? 'text-white font-medium' : 'text-white/70'}`}>
|
|
||||||
{language}
|
|
||||||
</span>
|
|
||||||
{selectedLanguages.includes(language) && (
|
|
||||||
<CheckIcon className="w-4 h-4 text-primary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort Filter */}
|
|
||||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.sort = el}>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleDropdown('sort')}
|
|
||||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-[13px] font-medium text-white/80">
|
|
||||||
{getSortDisplayText()}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<XMarkIcon
|
|
||||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${sortOrder === 'default' ? 'opacity-40' : 'opacity-100'}`}
|
|
||||||
onClick={clearSort}
|
|
||||||
/>
|
|
||||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.sort ? 'rotate-180' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{dropdowns.sort && (
|
|
||||||
<div className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleSortSelect(e, 'default')}
|
|
||||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
|
||||||
sortOrder === 'default' ? 'text-white font-medium' : 'text-white/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Default
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleSortSelect(e, 'title-asc')}
|
|
||||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
|
||||||
sortOrder === 'title-asc' ? 'text-white font-medium' : 'text-white/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Title (A-Z)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleSortSelect(e, 'title-desc')}
|
|
||||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
|
||||||
sortOrder === 'title-desc' ? 'text-white font-medium' : 'text-white/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Title (Z-A)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleSortSelect(e, 'year-desc')}
|
|
||||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
|
||||||
sortOrder === 'year-desc' ? 'text-white font-medium' : 'text-white/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Newest First
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleSortSelect(e, 'year-asc')}
|
|
||||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
|
||||||
sortOrder === 'year-asc' ? 'text-white font-medium' : 'text-white/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Oldest First
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clear All Button - Always visible */}
|
|
||||||
<button
|
|
||||||
onClick={clearAllFilters}
|
|
||||||
className={`flex items-center justify-center gap-1 px-4 py-2 rounded-lg bg-[#1a1a1a] hover:bg-[#2a2a2a] border border-white/[0.04] transition-colors ${!isAnyFilterActive() ? 'opacity-50' : 'opacity-100'}`}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="w-3.5 h-3.5 text-white/80" />
|
|
||||||
<span className="text-[13px] font-medium text-white/80">Clear All</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useState, useEffect } from 'react';
|
|
||||||
import AnimeCard from './AnimeCard';
|
|
||||||
|
|
||||||
export default function AnimeRow({ title, animeList }) {
|
|
||||||
const scrollContainerRef = useRef(null);
|
|
||||||
const contentRef = useRef(null);
|
|
||||||
const [showLeftButton, setShowLeftButton] = useState(false);
|
|
||||||
const [showRightButton, setShowRightButton] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!animeList || animeList.length <= 7) {
|
|
||||||
setShowRightButton(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowRightButton(true);
|
|
||||||
|
|
||||||
const checkScroll = () => {
|
|
||||||
if (!scrollContainerRef.current) return;
|
|
||||||
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
|
||||||
setShowLeftButton(scrollLeft > 0);
|
|
||||||
setShowRightButton(scrollLeft + clientWidth < scrollWidth - 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollContainer = scrollContainerRef.current;
|
|
||||||
scrollContainer.addEventListener('scroll', checkScroll);
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkScroll();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (scrollContainer) {
|
|
||||||
scrollContainer.removeEventListener('scroll', checkScroll);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [animeList]);
|
|
||||||
|
|
||||||
const scroll = (direction) => {
|
|
||||||
if (!scrollContainerRef.current) return;
|
|
||||||
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
// Calculate single card width based on viewport
|
|
||||||
const isMobile = window.innerWidth < 640; // sm breakpoint in Tailwind
|
|
||||||
const cardsPerRow = isMobile ? 3 : 7;
|
|
||||||
const singleCardWidth = container.clientWidth / cardsPerRow;
|
|
||||||
|
|
||||||
if (direction === 'left') {
|
|
||||||
container.scrollBy({ left: -singleCardWidth, behavior: 'smooth' });
|
|
||||||
} else {
|
|
||||||
container.scrollBy({ left: singleCardWidth, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!animeList || animeList.length === 0) return null;
|
|
||||||
|
|
||||||
// Create groups of cards for pagination - 3 for mobile, 7 for larger screens
|
|
||||||
const cardGroups = [];
|
|
||||||
const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640;
|
|
||||||
const groupSize = isMobileView ? 3 : 7;
|
|
||||||
|
|
||||||
for (let i = 0; i < animeList.length; i += groupSize) {
|
|
||||||
cardGroups.push(animeList.slice(i, i + groupSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-8">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-xl font-semibold text-white">{title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{showLeftButton && (
|
|
||||||
<button
|
|
||||||
onClick={() => scroll('left')}
|
|
||||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/70 flex items-center justify-center text-white hover:bg-black shadow-lg -ml-5"
|
|
||||||
aria-label="Scroll left"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
className="overflow-x-auto hide-scrollbar scroll-smooth"
|
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
className="flex snap-x snap-mandatory"
|
|
||||||
>
|
|
||||||
{cardGroups.map((group, groupIndex) => (
|
|
||||||
<div
|
|
||||||
key={groupIndex}
|
|
||||||
className="grid grid-cols-3 sm:grid-cols-7 gap-3 snap-start snap-always min-w-full px-1"
|
|
||||||
>
|
|
||||||
{group.map((anime, index) => (
|
|
||||||
<div key={index}>
|
|
||||||
<AnimeCard anime={anime} isRecent={true} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* Add empty placeholders if needed to ensure slots are filled */}
|
|
||||||
{Array.from({ length: (typeof window !== 'undefined' && window.innerWidth < 640) ?
|
|
||||||
Math.max(0, 3 - group.length) :
|
|
||||||
Math.max(0, 7 - group.length) }).map((_, index) => (
|
|
||||||
<div key={`empty-${index}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showRightButton && (
|
|
||||||
<button
|
|
||||||
onClick={() => scroll('right')}
|
|
||||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/70 flex items-center justify-center text-white hover:bg-black shadow-lg -mr-5"
|
|
||||||
aria-label="Scroll right"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import AnimeCard from './AnimeCard';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'topAiring', label: 'TOP AIRING' },
|
|
||||||
{ id: 'popular', label: 'POPULAR' },
|
|
||||||
{ id: 'latestCompleted', label: 'LATEST COMPLETED' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AnimeTabs({ topAiring = [], popular = [], latestCompleted = [] }) {
|
|
||||||
const [activeTab, setActiveTab] = useState('topAiring');
|
|
||||||
|
|
||||||
const getActiveList = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'topAiring':
|
|
||||||
return topAiring;
|
|
||||||
case 'popular':
|
|
||||||
return popular;
|
|
||||||
case 'latestCompleted':
|
|
||||||
return latestCompleted;
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getViewAllLink = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'topAiring':
|
|
||||||
return '/top-airing';
|
|
||||||
case 'popular':
|
|
||||||
return '/most-popular';
|
|
||||||
case 'latestCompleted':
|
|
||||||
return '/latest-completed';
|
|
||||||
default:
|
|
||||||
return '/';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-10">
|
|
||||||
{/* Tabs Navigation */}
|
|
||||||
<div className="flex items-center mb-6 border-b border-[var(--border)] overflow-x-auto scrollbar-none">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`px-3 sm:px-6 py-3 text-xs sm:text-sm font-medium transition-colors relative whitespace-nowrap flex-shrink-0 ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'text-white'
|
|
||||||
: 'text-[var(--text-muted)] hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
{activeTab === tab.id && (
|
|
||||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-[var(--primary)]"></div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<Link
|
|
||||||
href={getViewAllLink()}
|
|
||||||
className="text-[var(--text-muted)] hover:text-white text-xs sm:text-sm transition-colors flex items-center ml-auto px-3 sm:px-6 py-3 whitespace-nowrap flex-shrink-0"
|
|
||||||
prefetch={false}
|
|
||||||
>
|
|
||||||
<span>View All</span>
|
|
||||||
<svg className="ml-1 w-3 h-3 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anime Grid */}
|
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
||||||
{getActiveList().slice(0, 18).map((anime, index) => (
|
|
||||||
<AnimeCard
|
|
||||||
key={anime.id + '-' + index}
|
|
||||||
anime={anime}
|
|
||||||
isRecent={true}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
|
||||||
|
|
||||||
export default function EpisodeList({ episodes, currentEpisode, onEpisodeClick, isDub = false }) {
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [isGridView, setIsGridView] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [activeEpisodeId, setActiveEpisodeId] = useState(null);
|
|
||||||
const episodesPerPage = 100;
|
|
||||||
|
|
||||||
// Update active episode when currentEpisode changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentEpisode?.id) {
|
|
||||||
setActiveEpisodeId(currentEpisode.id);
|
|
||||||
}
|
|
||||||
}, [currentEpisode]);
|
|
||||||
|
|
||||||
// Sync with URL to identify current episode
|
|
||||||
useEffect(() => {
|
|
||||||
const checkCurrentEpisode = () => {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const match = path.match(/\/watch\/(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
const urlEpisodeId = match[1];
|
|
||||||
setActiveEpisodeId(urlEpisodeId);
|
|
||||||
|
|
||||||
// Find the episode and update page
|
|
||||||
const episode = episodes.find(ep => ep.id === urlEpisodeId);
|
|
||||||
|
|
||||||
if (episode) {
|
|
||||||
const pageNumber = Math.ceil(episode.number / episodesPerPage);
|
|
||||||
setCurrentPage(pageNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check initially
|
|
||||||
checkCurrentEpisode();
|
|
||||||
|
|
||||||
// Set up listener for URL changes using the History API
|
|
||||||
const handleUrlChange = () => {
|
|
||||||
checkCurrentEpisode();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('popstate', handleUrlChange);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('popstate', handleUrlChange);
|
|
||||||
};
|
|
||||||
}, [episodes, episodesPerPage]);
|
|
||||||
|
|
||||||
const filteredEpisodes = useMemo(() => {
|
|
||||||
if (!searchQuery) return episodes;
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return episodes.filter(episode =>
|
|
||||||
episode.number.toString().includes(query) ||
|
|
||||||
(episode.title && episode.title.toLowerCase().includes(query))
|
|
||||||
);
|
|
||||||
}, [episodes, searchQuery]);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredEpisodes.length / episodesPerPage);
|
|
||||||
const indexOfLastEpisode = currentPage * episodesPerPage;
|
|
||||||
const indexOfFirstEpisode = indexOfLastEpisode - episodesPerPage;
|
|
||||||
const currentEpisodes = filteredEpisodes.slice(indexOfFirstEpisode, indexOfLastEpisode);
|
|
||||||
|
|
||||||
const getPageRange = (pageNum) => {
|
|
||||||
const start = (pageNum - 1) * episodesPerPage + 1;
|
|
||||||
const end = Math.min(pageNum * episodesPerPage, filteredEpisodes.length);
|
|
||||||
return `${start}-${end}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isCurrentEpisode = (episode) => {
|
|
||||||
if (!episode || !episode.id || !activeEpisodeId) return false;
|
|
||||||
return episode.id === activeEpisodeId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEpisodeSelect = (episode, e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (onEpisodeClick && episode.id) {
|
|
||||||
// Use the episode ID directly as it's already in the correct format from the API
|
|
||||||
console.log(`[EpisodeList] Selected episode: ${episode.number}, ID: ${episode.id}`);
|
|
||||||
onEpisodeClick(episode.id);
|
|
||||||
setActiveEpisodeId(episode.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Scroll active episode into view when page changes or active episode changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeEpisodeId) {
|
|
||||||
setTimeout(() => {
|
|
||||||
const activeElement = document.querySelector(`[data-episode-id="${activeEpisodeId}"]`);
|
|
||||||
if (activeElement) {
|
|
||||||
activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, [activeEpisodeId, currentPage]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[#1a1a1a] rounded-xl shadow-2xl overflow-hidden h-[calc(90vh-6rem)]">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="bg-[#242424] p-3 border-b border-gray-800 sticky top-0 z-40">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="relative flex-grow max-w-lg">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search episodes by name or number..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className="w-full bg-[#2a2a2a] text-white text-sm rounded-lg px-4 py-1.5 pl-9 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] placeholder-gray-500"
|
|
||||||
/>
|
|
||||||
<svg
|
|
||||||
className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-500"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={currentPage}
|
|
||||||
onChange={(e) => setCurrentPage(Number(e.target.value))}
|
|
||||||
className="bg-[#2a2a2a] text-white text-sm rounded-lg px-2 py-1.5 border border-gray-700 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] min-w-[90px]"
|
|
||||||
>
|
|
||||||
{[...Array(totalPages)].map((_, index) => (
|
|
||||||
<option key={index + 1} value={index + 1}>
|
|
||||||
{getPageRange(index + 1)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsGridView(!isGridView)}
|
|
||||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white transition-colors bg-[#2a2a2a] hover:bg-[#333333]"
|
|
||||||
title={isGridView ? "Switch to List View" : "Switch to Grid View"}
|
|
||||||
>
|
|
||||||
{isGridView ? (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Episodes Container */}
|
|
||||||
<div className="overflow-y-auto h-[calc(100%-4rem)] scroll-smooth" id="episodes-container">
|
|
||||||
<div className="p-4">
|
|
||||||
{isGridView ? (
|
|
||||||
// Grid View
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
|
||||||
{currentEpisodes.map((episode) => (
|
|
||||||
<button
|
|
||||||
key={episode.number}
|
|
||||||
data-episode-id={episode.id}
|
|
||||||
onClick={(e) => handleEpisodeSelect(episode, e)}
|
|
||||||
className={`group relative ${
|
|
||||||
isCurrentEpisode(episode)
|
|
||||||
? 'bg-[#2a2a2a] ring-2 ring-white z-30'
|
|
||||||
: 'bg-[#2a2a2a] hover:bg-[#333333]'
|
|
||||||
} rounded-lg transition-all duration-300 ease-out transform hover:scale-[1.02] hover:z-10`}
|
|
||||||
>
|
|
||||||
<div className="aspect-w-16 aspect-h-9">
|
|
||||||
<div className={`flex items-center justify-center text-white p-1.5 ${
|
|
||||||
isCurrentEpisode(episode) ? 'text-base font-bold' : 'text-sm font-medium'
|
|
||||||
}`}>
|
|
||||||
<span>{episode.number}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isCurrentEpisode(episode) && (
|
|
||||||
<div className="absolute -top-1 -right-1 bg-white rounded-full p-0.5">
|
|
||||||
<svg className="w-3 h-3 text-[#2a2a2a]" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// List View
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{currentEpisodes.map((episode) => (
|
|
||||||
<button
|
|
||||||
key={episode.number}
|
|
||||||
data-episode-id={episode.id}
|
|
||||||
onClick={(e) => handleEpisodeSelect(episode, e)}
|
|
||||||
className={`group flex items-center gap-3 py-2 px-3 rounded-lg transition-all duration-300 w-full text-left ${
|
|
||||||
isCurrentEpisode(episode)
|
|
||||||
? 'bg-[#2a2a2a] ring-2 ring-white z-30'
|
|
||||||
: 'bg-[#2a2a2a] hover:bg-[#333333]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`flex-shrink-0 w-8 h-8 rounded-lg ${
|
|
||||||
isCurrentEpisode(episode)
|
|
||||||
? 'bg-black/20'
|
|
||||||
: 'bg-black/20'
|
|
||||||
} flex items-center justify-center`}>
|
|
||||||
<span className={`${
|
|
||||||
isCurrentEpisode(episode)
|
|
||||||
? 'text-base font-bold'
|
|
||||||
: 'text-sm font-medium'
|
|
||||||
} text-white`}>{episode.number}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow min-w-0">
|
|
||||||
<div className="text-sm text-white font-medium truncate">
|
|
||||||
{episode.title || `Episode ${episode.number}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isCurrentEpisode(episode) && (
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path d="M8 5v14l11-7z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
||||||
import { fetchGenres } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function GenreBar() {
|
|
||||||
const [genres, setGenres] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [showLeftButton, setShowLeftButton] = useState(true); // Always show left button initially
|
|
||||||
const [showRightButton, setShowRightButton] = useState(true);
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
const scrollContainerRef = useRef(null);
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const [visibleGenres, setVisibleGenres] = useState(14);
|
|
||||||
|
|
||||||
// Function to capitalize first letter
|
|
||||||
const capitalizeFirstLetter = (string) => {
|
|
||||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Predefined genres exactly as specified - wrapped in useMemo to prevent recreation on every render
|
|
||||||
const defaultGenres = useMemo(() => [
|
|
||||||
"Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy",
|
|
||||||
"Horror", "Mahou Shoujo", "Mecha", "Music", "Mystery", "Psychological",
|
|
||||||
"Romance", "Sci-Fi", "Slice of Life", "Sports", "Supernatural", "Thriller"
|
|
||||||
], []);
|
|
||||||
|
|
||||||
// Handle long names on mobile
|
|
||||||
const getMobileGenreName = (genre) => {
|
|
||||||
// Abbreviate long genre names for mobile view
|
|
||||||
switch(genre) {
|
|
||||||
case "Psychological": return "Psycho";
|
|
||||||
case "Mahou Shoujo": return "Mahou";
|
|
||||||
case "Supernatural": return "Super";
|
|
||||||
case "Slice of Life": return "SoL";
|
|
||||||
default: return genre;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Detect mobile devices
|
|
||||||
useEffect(() => {
|
|
||||||
const checkIfMobile = () => {
|
|
||||||
setIsMobile(window.innerWidth < 768);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkIfMobile();
|
|
||||||
window.addEventListener('resize', checkIfMobile);
|
|
||||||
|
|
||||||
return () => window.removeEventListener('resize', checkIfMobile);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Calculate the number of genres that fit in the container
|
|
||||||
useEffect(() => {
|
|
||||||
const calculateVisibleGenres = () => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (container) {
|
|
||||||
const containerWidth = container.offsetWidth;
|
|
||||||
// Approximate width of each genre button
|
|
||||||
const genreButtonWidth = isMobile ? 72 : 88; // Slightly larger on mobile to fit text
|
|
||||||
const visibleCount = Math.floor((containerWidth - 80) / genreButtonWidth);
|
|
||||||
// Minimum genres visible (smaller minimum on mobile)
|
|
||||||
setVisibleGenres(Math.max(visibleCount, isMobile ? 4 : 8));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
calculateVisibleGenres();
|
|
||||||
window.addEventListener('resize', calculateVisibleGenres);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', calculateVisibleGenres);
|
|
||||||
};
|
|
||||||
}, [isMobile]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Force scroll position slightly to the right initially
|
|
||||||
// to ensure there are genres on both sides for scrolling
|
|
||||||
setTimeout(() => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
scrollContainerRef.current.scrollLeft = 40; // Start slightly scrolled
|
|
||||||
// Trigger scroll event to update button states
|
|
||||||
scrollContainerRef.current.dispatchEvent(new Event('scroll'));
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
setGenres(defaultGenres);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, [defaultGenres]);
|
|
||||||
|
|
||||||
// Check scroll position to determine button visibility
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
|
||||||
|
|
||||||
// Show left button if not at the start
|
|
||||||
setShowLeftButton(scrollLeft > 5);
|
|
||||||
|
|
||||||
// Show right button if not at the end
|
|
||||||
setShowRightButton(scrollLeft < scrollWidth - clientWidth - 5);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollContainer = scrollContainerRef.current;
|
|
||||||
if (scrollContainer) {
|
|
||||||
scrollContainer.addEventListener('scroll', handleScroll);
|
|
||||||
// Initial check
|
|
||||||
handleScroll();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Scroll left/right functions
|
|
||||||
const scrollLeft = () => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
const scrollAmount = isMobile ? -80 : -200;
|
|
||||||
scrollContainerRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollRight = () => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
const scrollAmount = isMobile ? 80 : 200;
|
|
||||||
scrollContainerRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mobile-specific styles
|
|
||||||
const mobileButtonStyle = {
|
|
||||||
padding: '0.15rem 0.5rem',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
height: '1.5rem',
|
|
||||||
minWidth: '4rem',
|
|
||||||
maxWidth: '5.5rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full overflow-hidden">
|
|
||||||
<div className="flex space-x-2 md:space-x-4 py-2 animate-pulse justify-between px-4 md:px-8">
|
|
||||||
{[...Array(isMobile ? 5 : visibleGenres)].map((_, i) => (
|
|
||||||
<div key={i} className="h-6 md:h-7 bg-[#1f1f1f] rounded-md flex-1 max-w-[100px] min-w-[60px] md:min-w-[80px]"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full" ref={containerRef}>
|
|
||||||
{/* Left fade effect */}
|
|
||||||
<div className="absolute left-0 top-0 h-full w-6 md:w-16 z-10 pointer-events-none"
|
|
||||||
style={{ background: 'linear-gradient(to right, var(--background) 30%, transparent 100%)' }}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Left scroll button - only visible when not at the leftmost position */}
|
|
||||||
{showLeftButton && (
|
|
||||||
<button
|
|
||||||
onClick={scrollLeft}
|
|
||||||
className="absolute left-0 top-1/2 transform -translate-y-1/2 z-20 bg-[var(--background)] bg-opacity-40 backdrop-blur-sm rounded-full p-0.5 md:p-1 shadow-lg transition-opacity"
|
|
||||||
aria-label="Scroll left"
|
|
||||||
style={isMobile ? { left: '2px', padding: '2px' } : {}}
|
|
||||||
>
|
|
||||||
<svg className="w-3.5 h-3.5 md:w-5 md:h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable genre container */}
|
|
||||||
<div className="w-full">
|
|
||||||
<div
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
className="flex py-1.5 md:py-2 overflow-x-auto scrollbar-hide px-5 md:px-8"
|
|
||||||
style={{
|
|
||||||
scrollbarWidth: 'none',
|
|
||||||
msOverflowStyle: 'none',
|
|
||||||
display: 'grid',
|
|
||||||
gridAutoFlow: 'column',
|
|
||||||
gridAutoColumns: `minmax(${Math.floor(100 / (isMobile ? 4.5 : visibleGenres))}%, ${Math.floor(100 / (isMobile ? 3 : visibleGenres - 2))}%)`,
|
|
||||||
gap: isMobile ? '6px' : '8px',
|
|
||||||
scrollSnapType: isMobile ? 'x mandatory' : 'none',
|
|
||||||
WebkitOverflowScrolling: 'touch' // For smoother scrolling on iOS
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{genres.map((genre) => (
|
|
||||||
<Link
|
|
||||||
key={genre}
|
|
||||||
href={`/search?genre=${genre.toLowerCase()}`}
|
|
||||||
className="bg-[#1f1f1f] text-white rounded-md hover:bg-white/20 transition-colors text-xs font-medium flex items-center justify-center h-6 md:h-7 px-1 md:px-2 scroll-snap-align-start"
|
|
||||||
style={isMobile ? mobileButtonStyle : {}}
|
|
||||||
title={genre} // Add tooltip showing full name
|
|
||||||
>
|
|
||||||
{isMobile ? getMobileGenreName(genre) : genre}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right fade effect */}
|
|
||||||
<div className="absolute right-0 top-0 h-full w-6 md:w-16 z-10 pointer-events-none"
|
|
||||||
style={{ background: 'linear-gradient(to left, var(--background) 30%, transparent 100%)' }}>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right scroll button - only visible when not at the rightmost position */}
|
|
||||||
{showRightButton && (
|
|
||||||
<button
|
|
||||||
onClick={scrollRight}
|
|
||||||
className="absolute right-0 top-1/2 transform -translate-y-1/2 z-20 bg-[var(--background)] bg-opacity-40 backdrop-blur-sm rounded-full p-0.5 md:p-1 shadow-lg transition-opacity"
|
|
||||||
aria-label="Scroll right"
|
|
||||||
style={isMobile ? { right: '2px', padding: '2px' } : {}}
|
|
||||||
>
|
|
||||||
<svg className="w-3.5 h-3.5 md:w-5 md:h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { fetchGenres } from '@/lib/api';
|
|
||||||
|
|
||||||
export default function GenreList() {
|
|
||||||
const [genres, setGenres] = useState([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadGenres() {
|
|
||||||
try {
|
|
||||||
const genreData = await fetchGenres();
|
|
||||||
setGenres(genreData || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching genres:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadGenres();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Predefined popular genres if API doesn't return them
|
|
||||||
const defaultGenres = [
|
|
||||||
"Action", "Adventure", "Comedy", "Drama", "Fantasy",
|
|
||||||
"Horror", "Mystery", "Romance", "Sci-Fi", "Slice of Life",
|
|
||||||
"Supernatural", "Thriller", "Isekai", "Mecha", "Sports"
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use fetched genres or fallback to default genres
|
|
||||||
const displayGenres = genres.length > 0 ? genres : defaultGenres;
|
|
||||||
|
|
||||||
// Show only first 12 genres if not showing all
|
|
||||||
const visibleGenres = showAll ? displayGenres : displayGenres.slice(0, 12);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
|
||||||
<div className="p-4 border-b border-[var(--border)]">
|
|
||||||
<h2 className="text-lg font-semibold text-white">Genres</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 gap-3 animate-pulse">
|
|
||||||
{[...Array(12)].map((_, i) => (
|
|
||||||
<div key={i} className="h-10 bg-[var(--border)] rounded"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
|
||||||
<div className="p-4 border-b border-[var(--border)]">
|
|
||||||
<h2 className="text-lg font-semibold text-white">Genres</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
||||||
{visibleGenres.map((genre) => (
|
|
||||||
<Link
|
|
||||||
key={genre}
|
|
||||||
href={`/search?genre=${genre.toLowerCase()}`}
|
|
||||||
className="bg-[var(--background)] hover:bg-white/10 text-white text-sm text-center py-2 px-3 rounded transition-colors truncate"
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{displayGenres.length > 12 && (
|
|
||||||
<div className="p-4 pt-0 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowAll(!showAll)}
|
|
||||||
className="text-white/70 hover:text-white text-sm transition-colors inline-flex items-center"
|
|
||||||
>
|
|
||||||
<span>{showAll ? 'Show Less' : 'Show All'}</span>
|
|
||||||
<svg
|
|
||||||
className={`ml-1 w-4 h-4 transition-transform duration-300 ${showAll ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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
|
||||||
@@ -1,628 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
|
||||||
fetchSearchSuggestions,
|
|
||||||
fetchMostPopular,
|
|
||||||
fetchTopAiring,
|
|
||||||
fetchRecentEpisodes,
|
|
||||||
fetchMostFavorite,
|
|
||||||
fetchTopUpcoming
|
|
||||||
} from '@/lib/api';
|
|
||||||
|
|
||||||
export default function Navbar() {
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [searchSuggestions, setSearchSuggestions] = useState([]);
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isRandomLoading, setIsRandomLoading] = useState(false);
|
|
||||||
const suggestionRef = useRef(null);
|
|
||||||
const searchInputRef = useRef(null);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Track scroll position
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (window.scrollY > 10) {
|
|
||||||
setIsScrolled(true);
|
|
||||||
} else {
|
|
||||||
setIsScrolled(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update suggestions when search query changes
|
|
||||||
useEffect(() => {
|
|
||||||
const updateSuggestions = async () => {
|
|
||||||
// Only search if we have at least 2 characters
|
|
||||||
if (searchQuery.trim().length >= 2) {
|
|
||||||
setIsLoading(true);
|
|
||||||
setShowSuggestions(true); // Always show the suggestions container when typing
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Fetching suggestions for: ${searchQuery}`);
|
|
||||||
const apiSuggestions = await fetchSearchSuggestions(searchQuery);
|
|
||||||
console.log('API returned:', apiSuggestions);
|
|
||||||
|
|
||||||
if (Array.isArray(apiSuggestions) && apiSuggestions.length > 0) {
|
|
||||||
// Take top 5 results
|
|
||||||
setSearchSuggestions(apiSuggestions.slice(0, 5));
|
|
||||||
} else {
|
|
||||||
// Create a generic suggestion based on the search query
|
|
||||||
setSearchSuggestions([{
|
|
||||||
id: searchQuery.toLowerCase().replace(/\s+/g, '-'),
|
|
||||||
title: `Search for "${searchQuery}"`,
|
|
||||||
type: "SEARCH",
|
|
||||||
image: null
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in search component:', error);
|
|
||||||
// Create a generic suggestion
|
|
||||||
setSearchSuggestions([{
|
|
||||||
id: searchQuery.toLowerCase().replace(/\s+/g, '-'),
|
|
||||||
title: `Search for "${searchQuery}"`,
|
|
||||||
type: "SEARCH",
|
|
||||||
image: null
|
|
||||||
}]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSearchSuggestions([]);
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const debounceTimer = setTimeout(() => {
|
|
||||||
updateSuggestions();
|
|
||||||
}, 300); // 300ms debounce time
|
|
||||||
|
|
||||||
return () => clearTimeout(debounceTimer);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
// Close suggestions when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (
|
|
||||||
suggestionRef.current &&
|
|
||||||
!suggestionRef.current.contains(event.target) &&
|
|
||||||
!searchInputRef.current?.contains(event.target)
|
|
||||||
) {
|
|
||||||
setShowSuggestions(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSearch = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// Navigate to search page regardless if search is empty or not
|
|
||||||
router.push(searchQuery.trim() ? `/search?q=${encodeURIComponent(searchQuery)}` : '/search');
|
|
||||||
setSearchQuery('');
|
|
||||||
setShowSuggestions(false);
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle suggestion item click
|
|
||||||
const handleAnimeClick = (id) => {
|
|
||||||
router.push(`/anime/${id}`);
|
|
||||||
setSearchQuery('');
|
|
||||||
setShowSuggestions(false);
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle search by query click
|
|
||||||
const handleSearchByQueryClick = () => {
|
|
||||||
router.push(`/search?q=${encodeURIComponent(searchQuery)}`);
|
|
||||||
setSearchQuery('');
|
|
||||||
setShowSuggestions(false);
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to render clear button
|
|
||||||
const renderClearButton = () => {
|
|
||||||
if (searchQuery) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to handle input focus
|
|
||||||
const handleInputFocus = () => {
|
|
||||||
if (searchQuery.trim().length >= 2) {
|
|
||||||
setShowSuggestions(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to handle random anime click
|
|
||||||
const handleRandomAnimeClick = async () => {
|
|
||||||
setIsRandomLoading(true);
|
|
||||||
try {
|
|
||||||
// Randomly select a category to fetch from
|
|
||||||
const categories = [
|
|
||||||
{ name: 'Most Popular', fetch: fetchMostPopular },
|
|
||||||
{ name: 'Top Airing', fetch: fetchTopAiring },
|
|
||||||
{ name: 'Recent Episodes', fetch: fetchRecentEpisodes },
|
|
||||||
{ name: 'Most Favorite', fetch: fetchMostFavorite },
|
|
||||||
{ name: 'Top Upcoming', fetch: fetchTopUpcoming }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Select a random category
|
|
||||||
const randomCategoryIndex = Math.floor(Math.random() * categories.length);
|
|
||||||
const selectedCategory = categories[randomCategoryIndex];
|
|
||||||
|
|
||||||
console.log(`Fetching random anime from: ${selectedCategory.name}`);
|
|
||||||
|
|
||||||
// Fetch anime from the selected category - use a random page number to get more variety
|
|
||||||
const randomPage = Math.floor(Math.random() * 5) + 1; // Random page between 1-5
|
|
||||||
const animeList = await selectedCategory.fetch(randomPage);
|
|
||||||
|
|
||||||
if (animeList && animeList.results && animeList.results.length > 0) {
|
|
||||||
// Skip the first few results as they tend to be more popular
|
|
||||||
const skipCount = Math.min(5, Math.floor(animeList.results.length / 3));
|
|
||||||
let availableAnime = animeList.results.slice(skipCount);
|
|
||||||
|
|
||||||
if (availableAnime.length === 0) {
|
|
||||||
// If we've filtered out everything, use the original list
|
|
||||||
availableAnime = animeList.results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a random index
|
|
||||||
const randomAnimeIndex = Math.floor(Math.random() * availableAnime.length);
|
|
||||||
|
|
||||||
// Get the random anime ID
|
|
||||||
const randomAnimeId = availableAnime[randomAnimeIndex].id;
|
|
||||||
|
|
||||||
console.log(`Selected random anime: ${availableAnime[randomAnimeIndex].title} (ID: ${randomAnimeId})`);
|
|
||||||
|
|
||||||
// Navigate to the anime page
|
|
||||||
router.push(`/anime/${randomAnimeId}`);
|
|
||||||
} else {
|
|
||||||
console.error('No anime found to select randomly from');
|
|
||||||
|
|
||||||
// Fallback to most popular if the chosen category fails, but use a higher page number
|
|
||||||
const fallbackPage = Math.floor(Math.random() * 5) + 2; // Pages 2-6 for more obscure options
|
|
||||||
const fallbackList = await fetchMostPopular(fallbackPage);
|
|
||||||
|
|
||||||
if (fallbackList && fallbackList.results && fallbackList.results.length > 0) {
|
|
||||||
const randomIndex = Math.floor(Math.random() * fallbackList.results.length);
|
|
||||||
const randomAnimeId = fallbackList.results[randomIndex].id;
|
|
||||||
router.push(`/anime/${randomAnimeId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching random anime:', error);
|
|
||||||
} finally {
|
|
||||||
setIsRandomLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className={`fixed w-full z-20 transition-all duration-300 ${
|
|
||||||
isScrolled
|
|
||||||
? 'backdrop-blur-xl shadow-md bg-[#0a0a0a]/80'
|
|
||||||
: 'bg-transparent'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between h-16 px-2 sm:px-4 md:px-[4rem]">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Link href="/home" className="flex items-center" prefetch={false}>
|
|
||||||
<Image src="/Logo.png" alt="JustAnime Logo" width={80} height={38} className="h-[38px] w-auto" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Bar - Desktop */}
|
|
||||||
<div className="hidden sm:flex flex-1 max-w-lg mx-auto">
|
|
||||||
<form onSubmit={handleSearch} className="flex items-center w-full">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Search Anime"
|
|
||||||
className="bg-[#1a1a1a] text-white pl-10 pr-8 py-2 rounded-md focus:outline-none w-full"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onFocus={handleInputFocus}
|
|
||||||
/>
|
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{renderClearButton()}
|
|
||||||
|
|
||||||
{/* Search Suggestions Dropdown */}
|
|
||||||
{showSuggestions && (
|
|
||||||
<div
|
|
||||||
ref={suggestionRef}
|
|
||||||
className="absolute mt-2 w-full bg-[#121212] rounded-md shadow-lg z-30 border border-gray-700 overflow-hidden"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="px-4 py-3 text-sm text-gray-400 flex items-center justify-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : searchSuggestions.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<ul className="list-none m-0 p-0 w-full">
|
|
||||||
{searchSuggestions.map((suggestion, index) => (
|
|
||||||
<li key={index}>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (suggestion.type === 'SEARCH') {
|
|
||||||
handleSearchByQueryClick();
|
|
||||||
} else {
|
|
||||||
handleAnimeClick(suggestion.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="block p-2.5 text-sm text-white hover:bg-gray-700 cursor-pointer border-b border-gray-700 last:border-0 transition-colors duration-150 bg-[#121212] active:bg-gray-600"
|
|
||||||
>
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="flex-shrink-0 w-10 h-14 bg-gray-800 overflow-hidden rounded">
|
|
||||||
{suggestion.image ? (
|
|
||||||
<div className="relative w-full h-full">
|
|
||||||
<Image
|
|
||||||
src={suggestion.image}
|
|
||||||
alt={suggestion.title}
|
|
||||||
fill
|
|
||||||
sizes="40px"
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
|
|
||||||
<span className="text-xs text-gray-500">No img</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium truncate">{suggestion.title}</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-1 text-xs">
|
|
||||||
{suggestion.year && (
|
|
||||||
<span className="text-gray-400">{suggestion.year}</span>
|
|
||||||
)}
|
|
||||||
{suggestion.type && suggestion.type !== 'SEARCH' && (
|
|
||||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-300">{suggestion.type}</span>
|
|
||||||
)}
|
|
||||||
{suggestion.type === 'SEARCH' && (
|
|
||||||
<span className="bg-blue-900 px-2 py-0.5 rounded text-blue-200">Search</span>
|
|
||||||
)}
|
|
||||||
{suggestion.episodes && (
|
|
||||||
<span className="flex items-center text-gray-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4v16M17 4v16M3 8h18M3 16h18" />
|
|
||||||
</svg>
|
|
||||||
{suggestion.episodes}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{suggestion.rating && (
|
|
||||||
<span className="flex items-center text-gray-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1 text-yellow-500" fill="currentColor" viewBox="0 0 24 24" stroke="none">
|
|
||||||
<path d="M12 2l2.4 7.4H22l-6 4.6 2.3 7-6.3-4.6L5.7 21l2.3-7-6-4.6h7.6z" />
|
|
||||||
</svg>
|
|
||||||
{suggestion.rating}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleSearchByQueryClick();
|
|
||||||
}}
|
|
||||||
className="block p-2 border-t border-gray-700 bg-[#121212] hover:bg-gray-700 cursor-pointer transition-colors duration-150 active:bg-gray-600 text-center"
|
|
||||||
>
|
|
||||||
<div className="text-sm text-gray-300 hover:text-white py-2 flex items-center justify-center">
|
|
||||||
<span>VIEW ALL</span>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-3 text-sm text-gray-400">
|
|
||||||
No results found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Button */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-[#1a1a1a] text-white p-2 rounded-md transition-colors duration-200 focus:outline-none ml-2 h-[38px] w-[38px] flex items-center justify-center cursor-pointer"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Random Anime Button */}
|
|
||||||
<div className="ml-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRandomAnimeClick}
|
|
||||||
disabled={isRandomLoading}
|
|
||||||
className="bg-[#1a1a1a] text-white p-2 rounded-md transition-colors duration-200 focus:outline-none h-[38px] w-[38px] flex items-center justify-center cursor-pointer"
|
|
||||||
aria-label="Random Anime"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" className="bi bi-shuffle" viewBox="0 0 16 16">
|
|
||||||
<path fillRule="evenodd" d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z"/>
|
|
||||||
<path d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Login Button - Desktop */}
|
|
||||||
<div className="hidden sm:block flex-shrink-0">
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
className="bg-white text-black px-4 py-2 rounded-md hover:bg-gray-200"
|
|
||||||
prefetch={false}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
|
||||||
<div className="sm:hidden flex items-center ml-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
||||||
className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-[#1a1a1a] focus:outline-none"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`${isMenuOpen ? 'hidden' : 'block'} h-6 w-6`}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
className={`${isMenuOpen ? 'block' : 'hidden'} h-6 w-6`}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile menu */}
|
|
||||||
{isMenuOpen && (
|
|
||||||
<div className="sm:hidden absolute top-16 inset-x-0 bg-[var(--card)] shadow-lg border-t border-[var(--border)] z-10">
|
|
||||||
<div className="px-4 pt-4 pb-6 space-y-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<form onSubmit={handleSearch} className="flex items-center">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search Anime"
|
|
||||||
className="bg-[#1a1a1a] text-white pl-10 pr-8 py-2 rounded-md focus:outline-none w-full text-sm"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onFocus={handleInputFocus}
|
|
||||||
/>
|
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Clear Button */}
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile Search Suggestions */}
|
|
||||||
{showSuggestions && (
|
|
||||||
<div
|
|
||||||
className="absolute mt-2 w-full bg-[#121212] rounded-md shadow-lg z-30 border border-gray-700 overflow-hidden"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="px-4 py-3 text-sm text-gray-400 flex items-center justify-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
) : searchSuggestions.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<ul className="list-none m-0 p-0 w-full">
|
|
||||||
{searchSuggestions.map((suggestion, index) => (
|
|
||||||
<li key={index}>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (suggestion.type === 'SEARCH') {
|
|
||||||
handleSearchByQueryClick();
|
|
||||||
} else {
|
|
||||||
handleAnimeClick(suggestion.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="block p-2.5 text-sm text-white hover:bg-gray-700 cursor-pointer border-b border-gray-700 last:border-0 transition-colors duration-150 bg-[#121212] active:bg-gray-600"
|
|
||||||
>
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="flex-shrink-0 w-10 h-14 bg-gray-800 overflow-hidden rounded">
|
|
||||||
{suggestion.image ? (
|
|
||||||
<div className="relative w-full h-full">
|
|
||||||
<Image
|
|
||||||
src={suggestion.image}
|
|
||||||
alt={suggestion.title}
|
|
||||||
fill
|
|
||||||
sizes="40px"
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
|
|
||||||
<span className="text-xs text-gray-500">No img</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium truncate">{suggestion.title}</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-1 text-xs">
|
|
||||||
{suggestion.year && (
|
|
||||||
<span className="text-gray-400">{suggestion.year}</span>
|
|
||||||
)}
|
|
||||||
{suggestion.type && suggestion.type !== 'SEARCH' && (
|
|
||||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-300">{suggestion.type}</span>
|
|
||||||
)}
|
|
||||||
{suggestion.type === 'SEARCH' && (
|
|
||||||
<span className="bg-blue-900 px-2 py-0.5 rounded text-blue-200">Search</span>
|
|
||||||
)}
|
|
||||||
{suggestion.episodes && (
|
|
||||||
<span className="flex items-center text-gray-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4v16M17 4v16M3 8h18M3 16h18" />
|
|
||||||
</svg>
|
|
||||||
{suggestion.episodes}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{suggestion.rating && (
|
|
||||||
<span className="flex items-center text-gray-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1 text-yellow-500" fill="currentColor" viewBox="0 0 24 24" stroke="none">
|
|
||||||
<path d="M12 2l2.4 7.4H22l-6 4.6 2.3 7-6.3-4.6L5.7 21l2.3-7-6-4.6h7.6z" />
|
|
||||||
</svg>
|
|
||||||
{suggestion.rating}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleSearchByQueryClick();
|
|
||||||
setIsMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="block p-2 border-t border-gray-700 bg-[#121212] hover:bg-gray-700 cursor-pointer transition-colors duration-150 active:bg-gray-600 text-center"
|
|
||||||
>
|
|
||||||
<div className="text-sm text-gray-300 hover:text-white py-2 flex items-center justify-center">
|
|
||||||
<span>VIEW ALL</span>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-3 text-sm text-gray-400">
|
|
||||||
No results found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Button - Mobile */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-[#1a1a1a] text-white p-2 rounded-md transition-colors duration-200 focus:outline-none ml-2 h-[34px] w-[34px] flex items-center justify-center cursor-pointer"
|
|
||||||
aria-label="Search"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Random Anime Button - Mobile */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRandomAnimeClick}
|
|
||||||
disabled={isRandomLoading}
|
|
||||||
className="bg-[#1a1a1a] text-white p-2 rounded-md transition-colors duration-200 focus:outline-none ml-2 h-[34px] w-[34px] flex items-center justify-center cursor-pointer"
|
|
||||||
aria-label="Random Anime"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" className="bi bi-shuffle" viewBox="0 0 16 16">
|
|
||||||
<path fillRule="evenodd" d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z"/>
|
|
||||||
<path d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t border-[var(--border)]">
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
className="block px-3 py-2 text-base font-medium text-white bg-[var(--primary)] hover:bg-opacity-90 rounded-md"
|
|
||||||
onClick={() => setIsMenuOpen(false)}
|
|
||||||
prefetch={false}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export default function SeasonCard({ season }) {
|
|
||||||
const [imageError, setImageError] = useState(false);
|
|
||||||
|
|
||||||
if (!season) return null;
|
|
||||||
|
|
||||||
const handleImageError = () => {
|
|
||||||
console.log("Image error for:", season.name);
|
|
||||||
setImageError(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get image URL with fallback
|
|
||||||
const imageSrc = imageError ? '/images/placeholder.png' : season.poster;
|
|
||||||
|
|
||||||
// Generate link
|
|
||||||
const infoLink = `/anime/${season.id}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={infoLink}
|
|
||||||
className="block w-full rounded-lg overflow-hidden transition-transform duration-300 hover:scale-[1.02] group"
|
|
||||||
prefetch={false}
|
|
||||||
>
|
|
||||||
<div className={`relative aspect-[3/1.5] rounded-lg overflow-hidden bg-gray-900 shadow-lg ${season.isCurrent ? 'border-2 border-white' : ''}`}>
|
|
||||||
{/* Background image with blur */}
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<div className="absolute inset-0 bg-black opacity-60 z-[2]"></div>
|
|
||||||
<Image
|
|
||||||
src={imageSrc}
|
|
||||||
alt={season.name || 'Season'}
|
|
||||||
fill
|
|
||||||
className="object-cover blur-[2px]"
|
|
||||||
onError={handleImageError}
|
|
||||||
sizes="(max-width: 768px) 100vw, 50vw"
|
|
||||||
unoptimized={true}
|
|
||||||
priority={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content overlay */}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center z-10 p-3">
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-white font-bold text-lg line-clamp-1">
|
|
||||||
{season.title || season.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useState, useEffect } from 'react';
|
|
||||||
import SeasonCard from './SeasonCard';
|
|
||||||
|
|
||||||
export default function SeasonRow({ title, seasons }) {
|
|
||||||
const scrollContainerRef = useRef(null);
|
|
||||||
const contentRef = useRef(null);
|
|
||||||
const [showLeftButton, setShowLeftButton] = useState(false);
|
|
||||||
const [showRightButton, setShowRightButton] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!seasons || seasons.length <= 7) {
|
|
||||||
setShowRightButton(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setShowRightButton(true);
|
|
||||||
|
|
||||||
const checkScroll = () => {
|
|
||||||
if (!scrollContainerRef.current) return;
|
|
||||||
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
|
||||||
setShowLeftButton(scrollLeft > 0);
|
|
||||||
setShowRightButton(scrollLeft + clientWidth < scrollWidth - 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollContainer = scrollContainerRef.current;
|
|
||||||
scrollContainer.addEventListener('scroll', checkScroll);
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkScroll();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (scrollContainer) {
|
|
||||||
scrollContainer.removeEventListener('scroll', checkScroll);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [seasons]);
|
|
||||||
|
|
||||||
// Updated effect to handle mobile view arrows
|
|
||||||
useEffect(() => {
|
|
||||||
if (!seasons) return;
|
|
||||||
|
|
||||||
// Check if we're on mobile and have more than 3 seasons
|
|
||||||
const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640;
|
|
||||||
const showArrowsOnMobile = isMobileView && seasons.length > 3;
|
|
||||||
|
|
||||||
// On desktop, show arrows if more than 7 seasons
|
|
||||||
const showArrowsOnDesktop = !isMobileView && seasons.length > 7;
|
|
||||||
|
|
||||||
if (showArrowsOnMobile || showArrowsOnDesktop) {
|
|
||||||
setShowRightButton(true);
|
|
||||||
} else {
|
|
||||||
setShowRightButton(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for resize events to update arrow visibility
|
|
||||||
const handleResize = () => {
|
|
||||||
const isMobile = window.innerWidth < 640;
|
|
||||||
const showArrows = isMobile ? seasons.length > 3 : seasons.length > 7;
|
|
||||||
setShowRightButton(showArrows);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
};
|
|
||||||
}, [seasons]);
|
|
||||||
|
|
||||||
const scroll = (direction) => {
|
|
||||||
if (!scrollContainerRef.current) return;
|
|
||||||
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
// Calculate single card width based on viewport
|
|
||||||
const isMobile = window.innerWidth < 640; // sm breakpoint in Tailwind
|
|
||||||
const cardsPerRow = isMobile ? 3 : 7;
|
|
||||||
const singleCardWidth = container.clientWidth / cardsPerRow;
|
|
||||||
|
|
||||||
if (direction === 'left') {
|
|
||||||
container.scrollBy({ left: -singleCardWidth, behavior: 'smooth' });
|
|
||||||
} else {
|
|
||||||
container.scrollBy({ left: singleCardWidth, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!seasons || seasons.length === 0) return null;
|
|
||||||
|
|
||||||
// Create groups of cards for pagination - 3 for mobile, 7 for larger screens
|
|
||||||
const seasonGroups = [];
|
|
||||||
const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640;
|
|
||||||
const groupSize = isMobileView ? 3 : 7;
|
|
||||||
|
|
||||||
for (let i = 0; i < seasons.length; i += groupSize) {
|
|
||||||
seasonGroups.push(seasons.slice(i, i + groupSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-8">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-xl font-semibold text-white">{title || 'Seasons'}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{showLeftButton && (
|
|
||||||
<button
|
|
||||||
onClick={() => scroll('left')}
|
|
||||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/70 flex items-center justify-center text-white hover:bg-black shadow-lg -ml-5"
|
|
||||||
aria-label="Scroll left"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
className="overflow-x-auto hide-scrollbar scroll-smooth"
|
|
||||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={contentRef}
|
|
||||||
className="flex snap-x snap-mandatory"
|
|
||||||
>
|
|
||||||
{seasonGroups.map((group, groupIndex) => (
|
|
||||||
<div
|
|
||||||
key={groupIndex}
|
|
||||||
className="grid grid-cols-3 sm:grid-cols-7 gap-3 snap-start snap-always min-w-full px-1"
|
|
||||||
>
|
|
||||||
{group.map((season, index) => (
|
|
||||||
<div key={index}>
|
|
||||||
<SeasonCard season={season} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{/* Add empty placeholders if needed to ensure slots are filled */}
|
|
||||||
{Array.from({ length: (typeof window !== 'undefined' && window.innerWidth < 640) ?
|
|
||||||
Math.max(0, 3 - group.length) :
|
|
||||||
Math.max(0, 7 - group.length) }).map((_, index) => (
|
|
||||||
<div key={`empty-${index}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showRightButton && (
|
|
||||||
<button
|
|
||||||
onClick={() => scroll('right')}
|
|
||||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/70 flex items-center justify-center text-white hover:bg-black shadow-lg -mr-5"
|
|
||||||
aria-label="Scroll right"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Navbar from './Navbar';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
const Footer = () => {
|
|
||||||
return (
|
|
||||||
<footer className="bg-[#0a0a0a] text-gray-400 py-6 border-t border-gray-800">
|
|
||||||
<div className="px-2 sm:px-4 md:px-[4rem] mx-auto">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
|
||||||
<div className="flex flex-col items-center md:items-start md:flex-row gap-6 w-full md:w-auto">
|
|
||||||
<div className="flex w-full justify-center items-center md:justify-start md:w-auto">
|
|
||||||
<div className="flex-1 flex justify-end pr-2">
|
|
||||||
<Image src="/Logo.png" alt="JustAnime Logo" width={96} height={32} className="h-8 w-auto" />
|
|
||||||
</div>
|
|
||||||
<div className="h-8 w-px bg-gray-700 md:hidden"></div>
|
|
||||||
<div className="flex-1 flex items-center pl-2 md:hidden">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4 md:hidden">
|
|
||||||
<a href="/terms" className="hover:text-white transition-colors">Terms & Privacy</a>
|
|
||||||
<a href="/dmca" className="hover:text-white transition-colors">DMCA</a>
|
|
||||||
<a href="/contacts" className="hover:text-white transition-colors">Contacts</a>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs max-w-md text-center md:text-left">This website does not retain any files on its server. Rather, it solely provides links to media content hosted by third-party services.</p>
|
|
||||||
<div className="flex items-center space-x-4 md:hidden mt-4 hidden">
|
|
||||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:flex items-center gap-6">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<a href="/terms" className="hover:text-white transition-colors">Terms & Privacy</a>
|
|
||||||
<a href="/dmca" className="hover:text-white transition-colors">DMCA</a>
|
|
||||||
<a href="/contacts" className="hover:text-white transition-colors">Contacts</a>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
|
||||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SharedLayout({ children }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Navbar />
|
|
||||||
<main className="pt-16 flex-grow">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
|
||||||
import { Autoplay, Navigation, Pagination, EffectFade } from 'swiper/modules';
|
|
||||||
import { fetchAnimeEpisodes } from '@/lib/api';
|
|
||||||
|
|
||||||
// Import Swiper styles
|
|
||||||
import 'swiper/css';
|
|
||||||
import 'swiper/css/navigation';
|
|
||||||
import 'swiper/css/pagination';
|
|
||||||
import 'swiper/css/effect-fade';
|
|
||||||
|
|
||||||
const SpotlightCarousel = ({ items = [] }) => {
|
|
||||||
const [isClient, setIsClient] = useState(false);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const [autoplay, setAutoplay] = useState(true);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [episodeIds, setEpisodeIds] = useState({});
|
|
||||||
const [loadingItems, setLoadingItems] = useState({});
|
|
||||||
const intervalRef = useRef(null);
|
|
||||||
const progressIntervalRef = useRef(null);
|
|
||||||
|
|
||||||
// Handle hydration mismatch
|
|
||||||
useEffect(() => {
|
|
||||||
setIsClient(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch first episode IDs for all spotlight items
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchEpisodeData = async () => {
|
|
||||||
// Create a copy to track what we're loading
|
|
||||||
const newLoadingItems = { ...loadingItems };
|
|
||||||
const episodeData = { ...episodeIds };
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
// Skip if we already have the episode ID or if it's already loading
|
|
||||||
if (item.id && !episodeData[item.id] && !newLoadingItems[item.id]) {
|
|
||||||
newLoadingItems[item.id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update loading state
|
|
||||||
setLoadingItems(newLoadingItems);
|
|
||||||
|
|
||||||
// Process items that need to be loaded
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.id && !episodeData[item.id] && newLoadingItems[item.id]) {
|
|
||||||
try {
|
|
||||||
console.log(`[SpotlightCarousel] Fetching episodes for anime: ${item.id}`);
|
|
||||||
const response = await fetchAnimeEpisodes(item.id);
|
|
||||||
console.log(`[SpotlightCarousel] Episodes response for ${item.name}:`, response);
|
|
||||||
|
|
||||||
if (response.episodes && response.episodes.length > 0) {
|
|
||||||
// Check for episode ID in the expected format
|
|
||||||
const firstEp = response.episodes[0];
|
|
||||||
if (firstEp.id) {
|
|
||||||
episodeData[item.id] = firstEp.id;
|
|
||||||
console.log(`[SpotlightCarousel] Found episode ID (id) for ${item.name}: ${firstEp.id}`);
|
|
||||||
} else if (firstEp.episodeId) {
|
|
||||||
episodeData[item.id] = firstEp.episodeId;
|
|
||||||
console.log(`[SpotlightCarousel] Found episode ID (episodeId) for ${item.name}: ${firstEp.episodeId}`);
|
|
||||||
} else {
|
|
||||||
// Create a fallback ID if neither id nor episodeId are available
|
|
||||||
episodeData[item.id] = `${item.id}?ep=1`;
|
|
||||||
console.log(`[SpotlightCarousel] Using fallback ID for ${item.name}: ${item.id}?ep=1`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no episodes, use a fallback
|
|
||||||
episodeData[item.id] = `${item.id}?ep=1`;
|
|
||||||
console.log(`[SpotlightCarousel] No episodes for ${item.name}, using fallback: ${item.id}?ep=1`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[SpotlightCarousel] Error fetching episodes for ${item.id}:`, error);
|
|
||||||
// Even on error, try to use fallback
|
|
||||||
episodeData[item.id] = `${item.id}?ep=1`;
|
|
||||||
} finally {
|
|
||||||
// Mark as no longer loading
|
|
||||||
newLoadingItems[item.id] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update states
|
|
||||||
setEpisodeIds(episodeData);
|
|
||||||
setLoadingItems(newLoadingItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (items && items.length > 0) {
|
|
||||||
fetchEpisodeData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up function
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) clearTimeout(intervalRef.current);
|
|
||||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
|
||||||
};
|
|
||||||
}, [items, episodeIds, loadingItems]);
|
|
||||||
|
|
||||||
// Autoplay functionality
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoplay && items.length > 1) {
|
|
||||||
// Clear any existing intervals
|
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
||||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
|
||||||
|
|
||||||
// Set up new intervals
|
|
||||||
setProgress(0);
|
|
||||||
progressIntervalRef.current = setInterval(() => {
|
|
||||||
setProgress(prev => {
|
|
||||||
const newProgress = prev + 1;
|
|
||||||
return newProgress <= 100 ? newProgress : prev;
|
|
||||||
});
|
|
||||||
}, 50); // Update every 50ms to get smooth progress
|
|
||||||
|
|
||||||
intervalRef.current = setTimeout(() => {
|
|
||||||
setCurrentIndex(prevIndex => (prevIndex + 1) % items.length);
|
|
||||||
setProgress(0);
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) clearTimeout(intervalRef.current);
|
|
||||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
|
||||||
};
|
|
||||||
}, [autoplay, currentIndex, items.length]);
|
|
||||||
|
|
||||||
const handleDotClick = (index) => {
|
|
||||||
setCurrentIndex(index);
|
|
||||||
setProgress(0);
|
|
||||||
// Reset autoplay timer when manually changing slides
|
|
||||||
if (intervalRef.current) clearTimeout(intervalRef.current);
|
|
||||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
|
||||||
if (autoplay) {
|
|
||||||
intervalRef.current = setTimeout(() => {
|
|
||||||
setCurrentIndex((index + 1) % items.length);
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseEnter = () => setAutoplay(false);
|
|
||||||
const handleMouseLeave = () => setAutoplay(true);
|
|
||||||
|
|
||||||
// If no items or not on client yet, show loading state
|
|
||||||
if (!isClient || !items.length) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-[250px] md:h-[450px] bg-[var(--card)] rounded-xl animate-pulse flex items-center justify-center mb-6 md:mb-10">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="h-10 w-40 bg-[var(--border)] rounded mx-auto mb-4"></div>
|
|
||||||
<div className="h-4 w-60 bg-[var(--border)] rounded mx-auto"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentItem = items[currentIndex];
|
|
||||||
|
|
||||||
// Get the watch URL for the current item
|
|
||||||
const watchUrl = episodeIds[currentItem.id]
|
|
||||||
? `/watch/${episodeIds[currentItem.id]}`
|
|
||||||
: `/anime/${currentItem.id}`; // Direct to anime info if no episode ID
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full mb-6 md:mb-10 spotlight-carousel">
|
|
||||||
<Swiper
|
|
||||||
modules={[Autoplay, Navigation, Pagination, EffectFade]}
|
|
||||||
slidesPerView={1}
|
|
||||||
effect="fade"
|
|
||||||
navigation
|
|
||||||
pagination={{ clickable: true }}
|
|
||||||
autoplay={{
|
|
||||||
delay: 5000,
|
|
||||||
disableOnInteraction: false,
|
|
||||||
}}
|
|
||||||
loop={true}
|
|
||||||
className="rounded-xl overflow-hidden"
|
|
||||||
onSlideChange={(swiper) => {
|
|
||||||
setCurrentIndex(swiper.realIndex);
|
|
||||||
setProgress(0);
|
|
||||||
// Reset autoplay timer when manually changing slides
|
|
||||||
if (intervalRef.current) clearTimeout(intervalRef.current);
|
|
||||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
|
||||||
if (autoplay) {
|
|
||||||
intervalRef.current = setTimeout(() => {
|
|
||||||
setCurrentIndex((swiper.realIndex + 1) % items.length);
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
|
||||||
{items.map((anime, index) => (
|
|
||||||
<SwiperSlide key={`spotlight-${anime.id}-${index}`}>
|
|
||||||
<div className="relative w-full h-[250px] md:h-[450px]">
|
|
||||||
{/* Background Image */}
|
|
||||||
<Image
|
|
||||||
src={anime.banner || anime.poster || '/LandingPage.jpg'}
|
|
||||||
alt={anime.name || 'Anime spotlight'}
|
|
||||||
fill
|
|
||||||
priority={index < 2}
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Overlay */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0"
|
|
||||||
style={{
|
|
||||||
background: `
|
|
||||||
linear-gradient(to right,
|
|
||||||
rgba(10,10,10,0.9) 0%,
|
|
||||||
rgba(10,10,10,0.6) 25%,
|
|
||||||
rgba(10,10,10,0.3) 40%,
|
|
||||||
rgba(10,10,10,0) 60%),
|
|
||||||
linear-gradient(to top,
|
|
||||||
rgba(10,10,10,0.95) 0%,
|
|
||||||
rgba(10,10,10,0.7) 15%,
|
|
||||||
rgba(10,10,10,0.3) 30%,
|
|
||||||
rgba(10,10,10,0) 50%)
|
|
||||||
`
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<div className="absolute inset-0 flex flex-col justify-end p-3 pb-12 md:p-8">
|
|
||||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between">
|
|
||||||
{/* Left Side Content */}
|
|
||||||
<div className="max-w-2xl">
|
|
||||||
{/* Metadata first - Minimal boxed design */}
|
|
||||||
<div className="flex items-center mb-2 md:mb-3 text-xs md:text-xs space-x-1.5 md:space-x-1.5">
|
|
||||||
{anime.otherInfo?.map((info, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="inline-block px-2 md:px-1.5 py-1 md:py-0.5 bg-white/10 text-white/80 rounded-sm"
|
|
||||||
>
|
|
||||||
{info}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{anime.episodes && (
|
|
||||||
<>
|
|
||||||
{anime.episodes.sub > 0 && (
|
|
||||||
<span className="inline-block px-2 md:px-1.5 py-1 md:py-0.5 bg-white/10 text-white/80 rounded-sm">
|
|
||||||
SUB {anime.episodes.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{anime.episodes.dub > 0 && (
|
|
||||||
<span className="inline-block px-2 md:px-1.5 py-1 md:py-0.5 bg-white/10 text-white/80 rounded-sm">
|
|
||||||
DUB {anime.episodes.dub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title second */}
|
|
||||||
<h2 className="text-lg md:text-4xl font-bold mb-2 md:mb-2 line-clamp-2 md:line-clamp-none">
|
|
||||||
{anime.name || 'Anime Title'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Japanese Title */}
|
|
||||||
{anime.jname && (
|
|
||||||
<h3 className="text-sm md:text-lg text-white/70 mb-2 line-clamp-1">
|
|
||||||
{anime.jname}
|
|
||||||
</h3>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Description third - hidden on mobile, shown on desktop with exactly 3 lines */}
|
|
||||||
<p className="hidden md:block text-base line-clamp-3 text-white/90 max-h-[4.5rem] overflow-hidden">
|
|
||||||
{anime.description || 'No description available.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Buttons - Below title on mobile, right side on desktop */}
|
|
||||||
<div className="flex items-center space-x-2 md:space-x-4 mt-1 md:mt-0 md:absolute md:bottom-8 md:right-8">
|
|
||||||
{/* Watch button - Uses episodeIds[anime.id] if available, otherwise links to anime details */}
|
|
||||||
<Link
|
|
||||||
href={episodeIds[anime.id] ? `/watch/${episodeIds[anime.id]}` : `/anime/${anime.id}`}
|
|
||||||
className="bg-white hover:bg-gray-200 text-[#0a0a0a] font-medium text-xs md:text-base px-3 md:px-6 py-1.5 md:py-2 rounded flex items-center space-x-1.5 md:space-x-2 transition-colors"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 md:h-5 md:w-5" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
<span>WATCH NOW</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/anime/${anime.id}`}
|
|
||||||
className="text-white border border-white/30 hover:bg-white/10 text-xs md:text-base px-3 md:px-6 py-1.5 md:py-2 rounded flex items-center space-x-1.5 md:space-x-2 transition-colors"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 md:h-5 md:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<span>DETAILS</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SwiperSlide>
|
|
||||||
))}
|
|
||||||
</Swiper>
|
|
||||||
|
|
||||||
<style jsx global>{`
|
|
||||||
.spotlight-carousel .swiper-button-next,
|
|
||||||
.spotlight-carousel .swiper-button-prev {
|
|
||||||
color: white;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.spotlight-carousel .swiper-button-next,
|
|
||||||
.spotlight-carousel .swiper-button-prev {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spotlight-carousel .swiper-button-next:after,
|
|
||||||
.spotlight-carousel .swiper-button-prev:after {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.spotlight-carousel .swiper-button-next:after,
|
|
||||||
.spotlight-carousel .swiper-button-prev:after {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spotlight-carousel .swiper-pagination {
|
|
||||||
bottom: 12px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spotlight-carousel .swiper-pagination-bullet {
|
|
||||||
background: white;
|
|
||||||
opacity: 0.5;
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
margin: 0 3px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.spotlight-carousel .swiper-pagination {
|
|
||||||
bottom: 20px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spotlight-carousel .swiper-pagination-bullet {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
margin: 0 4px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.spotlight-carousel .swiper-pagination-bullet-active {
|
|
||||||
background: white;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SpotlightCarousel;
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
export default function TopLists({ topToday = [], topWeek = [], topMonth = [] }) {
|
|
||||||
const [activeTab, setActiveTab] = useState('today');
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'today', label: 'Today', data: topToday },
|
|
||||||
{ id: 'week', label: 'Week', data: topWeek },
|
|
||||||
{ id: 'month', label: 'Month', data: topMonth },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add custom scrollbar styles
|
|
||||||
useEffect(() => {
|
|
||||||
// Add custom styles for the toplists scrollbar
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
.toplists-scrollbar::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
.toplists-scrollbar::-webkit-scrollbar-track {
|
|
||||||
background: var(--card);
|
|
||||||
}
|
|
||||||
.toplists-scrollbar::-webkit-scrollbar-thumb {
|
|
||||||
background-color: var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
|
||||||
document.head.removeChild(style);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Find the active tab data
|
|
||||||
const activeTabData = tabs.find(tab => tab.id === activeTab)?.data || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
|
||||||
<div className="p-4 border-b border-[var(--border)] flex justify-between items-center">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.783-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
|
||||||
</svg>
|
|
||||||
<h2 className="text-lg font-semibold text-white">Top 10 Anime</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="grid grid-cols-3 border-b border-[var(--border)]">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`py-3 transition-colors text-sm font-medium ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'text-white bg-[var(--background)] border-b-2 border-[var(--border)]'
|
|
||||||
: 'text-[var(--text-muted)] hover:bg-[var(--background)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List content */}
|
|
||||||
<div className="p-4">
|
|
||||||
{activeTabData.length === 0 ? (
|
|
||||||
<div className="py-8 text-center text-[var(--text-muted)]">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.783-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm">No data available</p>
|
|
||||||
<p className="text-xs mt-1 opacity-70">Check another tab or come back later</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 min-h-[375px] max-h-[490px] overflow-y-auto pr-1 toplists-scrollbar">
|
|
||||||
{activeTabData.slice(0, 10).map((anime, index) => (
|
|
||||||
<Link
|
|
||||||
href={`/anime/${anime.id}`}
|
|
||||||
key={anime.id}
|
|
||||||
className="block p-3 rounded hover:bg-white/5 transition-colors border border-[var(--border)] bg-[var(--card)] relative overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Top rank highlight for top 3 */}
|
|
||||||
{index < 3 && (
|
|
||||||
<div className="absolute top-0 left-0 w-1 h-full opacity-70"
|
|
||||||
style={{
|
|
||||||
background: index === 0 ? 'linear-gradient(to bottom, #303030, #1a1a1a)' :
|
|
||||||
index === 1 ? 'linear-gradient(to bottom, #282828, #181818)' :
|
|
||||||
'linear-gradient(to bottom, #202020, #161616)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
{/* Rank number with monochrome styling */}
|
|
||||||
<div className="flex-shrink-0 w-8 flex items-center justify-center mr-3">
|
|
||||||
<span
|
|
||||||
className={`flex items-center justify-center w-7 h-7 rounded-lg text-sm font-bold ${
|
|
||||||
index === 0 ? 'bg-white/20 text-white' :
|
|
||||||
index === 1 ? 'bg-white/15 text-white' :
|
|
||||||
index === 2 ? 'bg-white/10 text-white' :
|
|
||||||
'bg-[var(--background)] text-white/70 border border-[var(--border)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{anime.rank || index + 1}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anime thumbnail with subtle shadow */}
|
|
||||||
<div className="flex-shrink-0 w-12 h-16 relative rounded overflow-hidden mr-3 shadow-md">
|
|
||||||
<Image
|
|
||||||
src={anime.poster || '/images/placeholder.png'}
|
|
||||||
alt={anime.name}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
unoptimized={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{/* Title */}
|
|
||||||
<div className="mb-1">
|
|
||||||
<h3 className="text-sm font-medium text-white line-clamp-1">
|
|
||||||
{anime.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Episodes if available */}
|
|
||||||
{anime.episodes && (
|
|
||||||
<div className="flex items-center mb-1">
|
|
||||||
{anime.episodes.sub > 0 && (
|
|
||||||
<span className="text-xs bg-[var(--background)] text-[var(--text-muted)] px-1.5 py-0.5 rounded">
|
|
||||||
SUB {anime.episodes.sub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{anime.episodes.dub > 0 && (
|
|
||||||
<span className="text-xs bg-[var(--background)] text-[var(--text-muted)] px-1.5 py-0.5 rounded ml-1">
|
|
||||||
DUB {anime.episodes.dub}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
export default function TrendingList({ trendingAnime = [] }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
|
||||||
<div className="p-4 border-b border-[var(--border)] flex items-center">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
|
||||||
</svg>
|
|
||||||
<h2 className="text-lg font-semibold text-white">Trending Now</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-[375px] max-h-[490px] overflow-y-auto toplists-scrollbar">
|
|
||||||
<div className="pt-3.5 space-y-2">
|
|
||||||
{trendingAnime.slice(0, 10).map((anime, index) => (
|
|
||||||
<Link
|
|
||||||
href={`/anime/${anime.id}`}
|
|
||||||
key={anime.id || index}
|
|
||||||
className="block px-3.5 py-3 hover:bg-white/5 transition-colors relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Rank number */}
|
|
||||||
<div className="flex items-center justify-center w-8 text-lg font-bold text-[var(--text-muted)]">
|
|
||||||
#{index + 1}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anime image */}
|
|
||||||
<div className="relative w-[45px] h-[60px] flex-shrink-0">
|
|
||||||
<Image
|
|
||||||
src={anime.image || '/placeholder.png'}
|
|
||||||
alt={anime.title}
|
|
||||||
className="rounded object-cover"
|
|
||||||
fill
|
|
||||||
sizes="45px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anime info */}
|
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
|
||||||
<h3 className="text-sm font-medium text-white line-clamp-2">
|
|
||||||
{anime.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
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);
|
||||||
|
}
|
||||||
|
`;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user