Landing page

This commit is contained in:
Tejas Panchal
2025-07-24 19:41:17 +05:30
parent 1c0e1cfe14
commit d502d2dbc5
161 changed files with 14116 additions and 12747 deletions

View File

@@ -1,2 +1,15 @@
# Your Self Hosted AniWatch API URL - replace with your own API endpoint
ANIWATCH_API=https://your-api-url.com/api/v2/hianime
#Refer https://github.com/itzzzme/anime-api to host your backend API
VITE_API_URL=<your_hosted_api>/api
#Refer this gist to setup proxy server https://gist.github.com/itzzzme/180813be2c7b45eedc8ce8344c8dea3b
VITE_PROXY_URL=<proxy_server_name>/?url=
#Refer https://github.com/itzzzme/m3u8proxy to host you m3u8 proxy server though it's optional but if you don't set it up you may get CORS error for some servers if you set up from the given repo then only the url structure will look like this
VITE_M3U8_PROXY_URL=<m3u8_proxy_server_name>/m3u8-proxy?url=
#totaly optional / if you don't want to setup worker just change the code of getQtip.utils.js following the pattern of any other utils file
VITE_WORKER_URL=https://worker1.workers.dev,https://worker2.workers.dev,https://worker3.workers.dev,...
VITE_BASE_IFRAME_URL=https://megaplay.buzz/stream/s-2
VITE_BASE_IFRAME_URL_2=https://vidwish.live/stream/s-2

158
.gitignore vendored
View File

@@ -1,41 +1,133 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/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
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# vercel
.vercel
# Runtime data
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
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
View File

@@ -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
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
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.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
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.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

151
README.md
View File

@@ -1,74 +1,119 @@
<p align="center">
<a href="https://justanime.vercel.app">
<img src="./public/Favicon.png" alt="JustAnime" width="160">
<div align="center">
<a href="https://zenime.site/">
<img alt="AnimeHi" src="https://raw.githubusercontent.com/itzzzme/zenime/refs/heads/main/public/logo.png" width="220"/>
</a>
</div>
<h3 align="center">Zenime - Ad free anime streaming platform</h3>
<p align="center">
<a href="https://github.com/itzzzme/zenime">
<img src="https://img.shields.io/github/stars/itzzzme/zenime" alt="Github Stars">
</a>
<img src="https://img.shields.io/github/issues/itzzzme/zenime" alt="Github Issues">
<a href="https://github.com/itzzzme/zenime">
<img src="https://img.shields.io/github/forks/itzzzme/zenime" alt="Github Forks" />
</a>
</p>
<h1 align="center"><b>JustAnime</b></h1>
<h4 align="center"><b>A sleek anime streaming platform with a modern UI</b></h4>
</p>
<p align="center">
<a href="#what-is-justanime">About</a>
<a href="#features">Features</a>
<a href="#quick-start">Quick Start</a>
<a href="#development">Development</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.
</p>
<p align="center">
<a href="https://github.com/tejaspanchall/JustAnime/stargazers">
<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>
<details>
<summary>View more Features</summary>
## 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
* 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
## Previews
### 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
* 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
## Installation and Local Development
## 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
# Clone the repository & Navigate to projetc directory
git clone https://github.com/tejaspanchall/JustAnime.git
cd JustAnime
# Install dependencies
npm install
# Set up environment variables
cp .env.example .env
# Start development server
npm run dev
git clone https://github.com/itzzzme/zenime.git
cd zenime
npm install # or yarn
```
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
[![Deploy to Vercel](https://vercel.com/button)](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.
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](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
View 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
View 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 },
],
},
},
]

View File

@@ -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
View 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>

View File

@@ -1,7 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./*"]
}
}
}

6
lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

@@ -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;

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,54 @@
{
"name": "justanime",
"version": "0.1.0",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"host": "vite --host"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"hls.js": "^1.5.7",
"next": "latest",
"proxy-from-env": "^1.1.0",
"react": "latest",
"react-dom": "latest",
"swiper": "^11.2.6"
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@radix-ui/react-icons": "^1.3.0",
"artplayer": "^5.2.3",
"artplayer-plugin-chapter": "^1.0.0",
"artplayer-plugin-hls-control": "^1.0.1",
"axios": "^1.7.7",
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"hls.js": "^1.5.17",
"lucide-react": "^0.447.0",
"react": "^18.3.1",
"react-content-loader": "^7.0.2",
"react-dom": "^18.3.1",
"react-icons": "^5.3.0",
"react-lazy-load": "^4.0.1",
"react-router-dom": "^6.26.2",
"styled-components": "^6.1.13",
"swiper": "^11.2.5",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"autoprefixer": "latest",
"eslint": "^9",
"eslint-config-next": "15.2.5",
"postcss": "latest",
"tailwindcss": "^4"
"@eslint/js": "^9.9.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react": "^7.35.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"vite": "^5.4.1"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

Binary file not shown.

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-Agent: *
Allow: /
Sitemap: https://zenime.site/sitemap.xml

2430
public/sitemap.xml Normal file

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

27
src/App.css Normal file
View 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
View 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;

View File

@@ -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&apos;re looking for doesn&apos;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>
);
}

View File

@@ -1,5 +0,0 @@
import SharedLayout from '@/components/SharedLayout';
export default function AnimeLayout({ children }) {
return <SharedLayout>{children}</SharedLayout>;
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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 */
}

View File

@@ -1,5 +0,0 @@
import SharedLayout from '@/components/SharedLayout';
export default function HomeLayout({ children }) {
return <SharedLayout>{children}</SharedLayout>;
}

View File

@@ -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;

View File

@@ -1,5 +0,0 @@
import SharedLayout from '@/components/SharedLayout';
export default function LatestCompletedLayout({ children }) {
return <SharedLayout>{children}</SharedLayout>;
}

View File

@@ -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&apos;t find any anime matching your criteria. Please try different filters.
</p>
</div>
)}
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import SharedLayout from '@/components/SharedLayout';
export default function MostPopularLayout({ children }) {
return <SharedLayout>{children}</SharedLayout>;
}

View File

@@ -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&apos;t find any anime matching your criteria. Please try different filters.
</p>
</div>
)}
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import SharedLayout from '@/components/SharedLayout';
export default function RecentEpisodesLayout({ children }) {
return <SharedLayout>{children}</SharedLayout>;
}

View File

@@ -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&apos;t find any anime matching your criteria. Please try different filters.
</p>
</div>
)}
</div>
);
}

View File

@@ -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&apos;t find any anime matching your search criteria. Please try different filters or a different search term.
</p>
</div>
)}
</div>
);
}

View File

@@ -1,5 +0,0 @@
import SharedLayout from '@/components/SharedLayout';
export default function SearchLayout({ children }) {
return <SharedLayout>{children}</SharedLayout>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import SharedLayout from '@/components/SharedLayout';
export default function TopAiringLayout({ children }) {
return <SharedLayout>{children}</SharedLayout>;
}

View File

@@ -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&apos;t find any top airing anime at this time. Please check back later.
</p>
</div>
)}
</div>
);
}

View File

@@ -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&apos;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>
);
}

View File

@@ -1,5 +0,0 @@
import SharedLayout from '@/components/SharedLayout';
export default function WatchLayout({ children }) {
return <SharedLayout>{children}</SharedLayout>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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;

View 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;

View 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

View 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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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

View 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;

View 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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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

View 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%
);
}
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;

View 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;
}

View 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:&nbsp;{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:&nbsp;{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;

View 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&apos;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

View 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;

View 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);

View 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;

View 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

View 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>
);
}

View 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;
}
}

View 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>;
}

View 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,
};

View 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",
};
};
}

View 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;
}

View 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;
});
},
});
};
}

View 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();
},
};
};
}

View 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;
}

View 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;
}

View 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