This commit is contained in:
tejaspanchall
2025-05-28 23:11:20 +05:30
commit 00797f0c96
52 changed files with 15166 additions and 0 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
ANIWATCH_API = your animwatch-api key

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# 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
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

14
eslint.config.mjs Normal file
View File

@@ -0,0 +1,14 @@
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;

7
jsconfig.json Normal file
View File

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

82
next.config.js Normal file
View File

@@ -0,0 +1,82 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
env: {
NEXT_PUBLIC_CORSPROXY_URL: process.env.CORSPROXY_URL,
},
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() {
return [
{
source: '/api/v2/hianime/:path*',
destination: 'https://justaniwatchapi.vercel.app/api/v2/hianime/:path*'
},
{
source: '/api/anime/:path*',
destination: 'https://justaniwatchapi.vercel.app/api/v2/hianime/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;

17
next.config.mjs Normal file
View File

@@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
env: {
NEXT_PUBLIC_CORSPROXY_URL: process.env.CORSPROXY_URL,
},
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
}
};
export default nextConfig;

5911
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "justanime",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"hls.js": "^1.5.7",
"next": "latest",
"proxy-from-env": "^1.1.0",
"react": "latest",
"react-dom": "latest",
"swiper": "^11.2.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"autoprefixer": "latest",
"eslint": "^9",
"eslint-config-next": "15.2.5",
"postcss": "latest",
"tailwindcss": "^4"
}
}

5
postcss.config.mjs Normal file
View File

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

BIN
public/LandingPage.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

BIN
public/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

114
src/app/anime/[id]/page.js Normal file
View File

@@ -0,0 +1,114 @@
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 {
console.log('[Server] Fetching info for ID:', id);
const anime = await fetchAnimeInfo(id);
console.log('[Server] API Response structure:', JSON.stringify({
info: anime.info ? 'Present' : 'Missing',
moreInfo: anime.moreInfo ? 'Present' : 'Missing',
relatedAnime: Array.isArray(anime.relatedAnime) ? anime.relatedAnime.length : 'Not an array',
recommendations: Array.isArray(anime.recommendations) ? anime.recommendations.length : 'Not an array',
seasons: Array.isArray(anime.seasons) ? anime.seasons.length : 'Not an array',
mostPopular: Array.isArray(anime.mostPopular) ? anime.mostPopular.length : 'Not an array',
promotionalVideos: anime.info?.promotionalVideos ? anime.info.promotionalVideos.length : 'Missing'
}, null, 2));
if (!anime || !anime.info) {
console.error('[Server] Missing required anime data');
return <NotFoundState />;
}
return (
<div className="min-h-screen pb-12 mt-1.5">
<AnimeDetails anime={anime} />
</div>
);
} catch (error) {
console.error('[Server Error]', 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>
);
}

5
src/app/anime/layout.js Normal file
View File

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

169
src/app/contacts/page.jsx Normal file
View File

@@ -0,0 +1,169 @@
'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>
);
}

62
src/app/dmca/page.jsx Normal file
View File

@@ -0,0 +1,62 @@
'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>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

173
src/app/globals.css Normal file
View File

@@ -0,0 +1,173 @@
@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 */
}

5
src/app/home/layout.js Normal file
View File

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

185
src/app/home/page.js Normal file
View File

@@ -0,0 +1,185 @@
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 {
// Fetch all data in parallel
const [
spotlightData,
recentEpisodes,
mostFavorite,
topToday,
topWeek,
topMonth,
topAiring,
popular,
latestCompleted,
trending
] = await Promise.all([
fetchSpotlightAnime().catch(err => {
console.error("Error fetching spotlight anime:", err);
return [];
}),
fetchRecentEpisodes().catch(err => {
console.error("Error fetching recent episodes:", err);
return { results: [] };
}),
fetchMostFavorite().catch(err => {
console.error("Error fetching most favorite:", err);
return { results: [] };
}),
fetchTopToday().catch(err => {
console.error("Error fetching top today:", err);
return [];
}),
fetchTopWeek().catch(err => {
console.error("Error fetching top week:", err);
return [];
}),
fetchTopMonth().catch(err => {
console.error("Error fetching top month:", err);
return [];
}),
fetchTopAiring().catch(err => {
console.error("Error fetching top airing anime:", err);
return { results: [] };
}),
fetchMostPopular().catch(err => {
console.error("Error fetching popular anime:", err);
return { results: [] };
}),
fetchLatestCompleted().catch(err => {
console.error("Error fetching latest completed anime:", err);
return { results: [] };
}),
fetchTrending().catch(err => {
console.error("Error fetching trending anime:", err);
return { results: [] };
})
]);
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('Error in HomePage:', error);
return <div>Error loading page</div>;
}
}
export default HomePage;

View File

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

View File

@@ -0,0 +1,305 @@
'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} />
))}
</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>
);
}

30
src/app/layout.js Normal file
View File

@@ -0,0 +1,30 @@
import { Geist, Geist_Mono } from "next/font/google";
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>
</body>
</html>
);
}

View File

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

View File

@@ -0,0 +1,305 @@
'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} />
))}
</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>
);
}

308
src/app/page.jsx Normal file
View File

@@ -0,0 +1,308 @@
'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);
// Create separate delays for staggered loading of FAQ items
const faqDelays = ['0.6s', '0.7s', '0.8s'];
// 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);
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/${searchQuery}`);
setSearchQuery('');
setShowSuggestions(false);
}
};
const handleSuggestionClick = (title) => {
router.push(`/search/${title}`);
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 fade-in 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"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion}
</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 - with fade-in animation matching the hero section */}
<div className="max-w-3xl mx-auto w-full px-4 sm:px-6 lg:px-8 pb-6 fade-in" style={{ animationDelay: '0.5s' }}>
<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)] fade-in" style={{ animationDelay: faqDelays[0] }}>
<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)] fade-in" style={{ animationDelay: faqDelays[1] }}>
<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)]">
<ul className="text-[var(--text-muted)] space-y-2 sm:space-y-3 list-disc pl-5 text-left text-sm sm:text-base">
<li><span className="font-medium text-white">Content library:</span> Our extensive database ensures you can find almost everything here.</li>
<li><span className="font-medium text-white">Streaming experience:</span> We have top of the line streaming servers. You can simply choose one that is fast for you.</li>
<li><span className="font-medium text-white">Quality/Resolution:</span> All our video files are encoded in highest possible resolution. We also have quality setting function that allows every user to enjoy streaming regardless of their internet speed.</li>
<li><span className="font-medium text-white">Updates:</span> Our content is updated hourly, so you will get update as fast as possible.</li>
<li><span className="font-medium text-white">User interface:</span> We focus on the simple and easy to use, so you will feel the life is easier here. We also have almost every feature that other anime streaming sites have, but not the opposite.</li>
<li><span className="font-medium text-white">Device compatibility:</span> JustAnime works fine on both desktop and mobile devices, even with old browsers, so you can enjoy your anime anywhere you want.</li>
</ul>
</div>
</div>
</div>
{/* FAQ Item 3 */}
<div className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--card)] fade-in" style={{ animationDelay: faqDelays[2] }}>
<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">Why should I choose JustAnime?</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">
If you want a good and safe place to watch anime online for free, give JustAnime a try, and if you like what we provide, please help us by sharing JustAnime to your friends and do not forget to bookmark our site.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
);
}

5
src/app/recent/layout.js Normal file
View File

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

305
src/app/recent/page.js Normal file
View File

@@ -0,0 +1,305 @@
'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

@@ -0,0 +1,311 @@
'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} />
))}
</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>
);
}

5
src/app/search/layout.js Normal file
View File

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

433
src/app/search/page.js Normal file
View File

@@ -0,0 +1,433 @@
'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;
// 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 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();
const data = await searchAnime(queryTerm, 1, filters);
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 searching anime:', error);
setError('Failed to search anime. Please try again later.');
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 {
// Load more search results
const filters = getFiltersForApi();
const data = await searchAnime(queryTerm, currentPage, filters);
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);
}
} 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, getFiltersForApi, processAnimeData, applyClientSideFilters, isEmptySearch]);
// 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>
{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-zinc-900 rounded-lg p-8 text-center">
<h3 className="text-xl font-medium text-zinc-200 mb-2">Error</h3>
<p className="text-zinc-400">{error}</p>
</div>
) : !queryTerm && !isEmptySearch ? (
<div className="bg-zinc-900 rounded-lg p-8 text-center">
<h3 className="text-xl font-medium text-zinc-200 mb-2">Start Searching</h3>
<p className="text-zinc-400">Enter a keyword in the search box above to find anime</p>
</div>
) : filteredList.length > 0 ? (
<>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-2 sm:gap-4">
{filteredList.map((anime) => (
<AnimeCard key={anime.id} anime={anime} />
))}
</div>
{hasNextPage && (
<div className="mt-8 text-center">
<button
onClick={handleLoadMore}
disabled={isLoading}
className={`px-6 py-3 bg-zinc-700 text-white rounded-md ${
isLoading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-zinc-600'
}`}
>
{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-zinc-900 rounded-lg p-8 text-center">
<h3 className="text-xl font-medium text-zinc-200 mb-2">No results found</h3>
<p className="text-zinc-400">
We couldn't find any anime matching your search criteria. Please try different filters or a different search term.
</p>
</div>
)}
</div>
</div>
);
}
export default function SearchPage() {
return (
<Suspense fallback={
<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>
}>
<SearchResults />
</Suspense>
);
}

View File

@@ -0,0 +1,191 @@
'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

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

276
src/app/top-airing/page.js Normal file
View File

@@ -0,0 +1,276 @@
'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} />
))}
</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

@@ -0,0 +1,503 @@
'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 } 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);
// 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);
// The URL might contain query parameters for episode number
const [baseId, queryParams] = episodeId.split('?');
console.log('[Watch] Base ID:', baseId);
setAnimeId(baseId);
setCurrentEpisodeId(episodeId);
}
}, [episodeId]);
// Fetch episode sources first to ensure we have data even if anime info fails
useEffect(() => {
if (!currentEpisodeId || currentEpisodeId === 'undefined') {
setError('Invalid episode ID');
setIsLoading(false);
return;
}
const fetchVideoData = async () => {
setIsLoading(true);
setError(null);
setVideoSource(null);
try {
console.log(`[Watch] Fetching video for episode ${currentEpisodeId} (dub: ${isDub})`);
// Fetch the episode sources from the API
const data = await fetchEpisodeSources(currentEpisodeId, isDub);
console.log('[Watch] Episode 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"
};
console.log('[Watch] No headers provided from API, using defaults:', defaultHeaders);
setVideoHeaders(defaultHeaders);
}
// 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...');
fetchVideoData();
}, 2000);
}
}
};
fetchVideoData();
}, [currentEpisodeId, isDub, isRetrying]);
// Fetch anime info using extracted animeId
useEffect(() => {
if (animeId) {
const fetchAnimeDetails = async () => {
try {
setIsRetrying(true);
console.log(`[Watch] Fetching anime info for ID: ${animeId}`);
const animeData = await fetchAnimeInfo(animeId);
if (animeData) {
console.log('[Watch] Anime info received:', animeData.title);
setAnime(animeData);
// Find the current episode in the anime episode list
if (animeData.episodes && animeData.episodes.length > 0) {
console.log('[Watch] Episodes found:', animeData.episodes.length);
// First try exact match
let episode = animeData.episodes.find(ep => ep.id === episodeId);
// If not found, try to find by checking if episodeId is contained in ep.id
if (!episode && episodeId.includes('$episode$')) {
const episodeIdPart = episodeId.split('$episode$')[1];
episode = animeData.episodes.find(ep => ep.id.includes(episodeIdPart));
}
if (episode) {
setCurrentEpisode(episode);
console.log('[Watch] Current episode found:', episode.number);
} else {
console.warn('[Watch] Current episode not found in episode list. Looking for:', episodeId);
console.log('[Watch] First few episodes:', animeData.episodes.slice(0, 3).map(ep => ep.id));
}
} else {
console.warn('[Watch] No episodes found in anime data or episodes array is empty');
}
} else {
console.error('[Watch] Failed to fetch anime info or received empty response');
}
} catch (error) {
console.error('[Watch] Error fetching anime info:', error);
} finally {
setIsRetrying(false);
}
};
fetchAnimeDetails();
} else {
console.warn('[Watch] No animeId available to fetch anime details');
}
}, [animeId, episodeId]);
const handleDubToggle = () => {
setIsDub(!isDub);
};
const handleEpisodeClick = (newEpisodeId) => {
if (newEpisodeId !== currentEpisodeId) {
// 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 (anime?.episodes) {
const newEpisode = anime.episodes.find(ep => ep.id === newEpisodeId);
if (newEpisode) {
setCurrentEpisode(newEpisode);
}
}
}
};
const handleRetryAnimeInfo = () => {
if (animeId) {
setIsRetrying(true);
fetchAnimeInfo(animeId)
.then(data => {
if (data) {
setAnime(data);
console.log('[Watch] Anime info retry succeeded:', data.title);
} else {
console.error('[Watch] Anime info retry failed: empty response');
}
})
.catch(error => {
console.error('[Watch] Anime info retry error:', error);
})
.finally(() => {
setIsRetrying(false);
});
}
};
const findAdjacentEpisodes = () => {
if (!anime?.episodes || !currentEpisodeId) return { prev: null, next: null };
const currentIndex = anime.episodes.findIndex(ep => ep.id === currentEpisodeId);
if (currentIndex === -1) return { prev: null, next: null };
return {
prev: currentIndex > 0 ? anime.episodes[currentIndex - 1] : null,
next: currentIndex < anime.episodes.length - 1 ? anime.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}`}
src={videoSource}
poster={anime?.image}
headers={videoHeaders}
subtitles={subtitles}
thumbnails={thumbnails}
category={isDub ? 'dub' : 'sub'}
intro={episodeData?.intro}
outro={episodeData?.outro}
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 items-center justify-between">
{/* 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>
{/* 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">
{anime?.episodes && (
<>
<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) => (
<span
key={index}
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}
</span>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Right Side - Episode List (30%) */}
<div className="w-full md:w-[30%]">
{anime?.episodes ? (
<div className="h-full max-h-[calc(100vh-2rem)] overflow-hidden">
<EpisodeList
episodes={anime.episodes}
currentEpisode={currentEpisode}
onEpisodeClick={handleEpisodeClick}
/>
</div>
) : (
<div className="bg-white/5 rounded-2xl shadow-2xl p-6 ring-1 ring-white/10">
<div className="text-center text-gray-400">
No episodes available
</div>
</div>
)}
</div>
</div>
</div>
</main>
);
}

5
src/app/watch/layout.js Normal file
View File

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

View File

@@ -0,0 +1,210 @@
'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

@@ -0,0 +1,96 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
export default function AnimeCard({ anime, isRecent }) {
const [imageError, setImageError] = useState(false);
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}`;
const watchLink = isRecent
? `/watch/${anime.id}?ep=${anime.episodes?.sub || anime.episodes?.dub || 1}`
: `/anime/${anime.id}`;
return (
<div className="anime-card w-full flex flex-col">
{/* Image card linking to watch page */}
<Link
href={watchLink}
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

@@ -0,0 +1,435 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
export default function AnimeDetails({ anime }) {
const [isExpanded, setIsExpanded] = useState(false);
const [activeVideo, setActiveVideo] = useState(null);
console.log('AnimeDetails received:', anime);
if (!anime?.info) {
console.error('Invalid anime data structure:', anime);
return null;
}
const { info, moreInfo, relatedAnime, recommendations, mostPopular, seasons } = anime;
// Helper function to render anime cards
const renderAnimeCards = (animeList, title) => {
if (!animeList || animeList.length === 0) return null;
return (
<div className="mt-8">
<h3 className="text-xl font-semibold text-white mb-4 text-center md:text-left">{title}</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{animeList.map((item, index) => (
<Link key={index} href={`/anime/${item.id}`} className="block group">
<div className="bg-[var(--card)] rounded-lg overflow-hidden transition-transform hover:scale-105">
<div className="relative aspect-[3/4]">
<Image
src={item.poster}
alt={item.name}
fill
sizes="(max-width: 768px) 50vw, 20vw"
className="object-cover"
/>
</div>
<div className="p-2">
<p className="text-white text-sm font-medium line-clamp-2">{item.name}</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
};
// Helper function to render seasons
const renderSeasons = () => {
if (!seasons || seasons.length === 0) return null;
return (
<div className="mt-8">
<h3 className="text-xl font-semibold text-white mb-4 text-center md:text-left">Seasons</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{seasons.map((season, index) => (
<Link key={index} href={`/anime/${season.id}`} className="block group">
<div className={`${season.isCurrent ? 'border-2 border-[var(--primary)]' : ''} bg-[var(--card)] rounded-lg overflow-hidden transition-transform hover:scale-105`}>
<div className="relative aspect-[3/4]">
<Image
src={season.poster}
alt={season.name}
fill
sizes="(max-width: 768px) 50vw, 20vw"
className="object-cover"
/>
{season.isCurrent && (
<div className="absolute top-2 right-2 bg-[var(--primary)] text-black text-xs px-2 py-1 rounded-full">
Current
</div>
)}
</div>
<div className="p-2">
<p className="text-white text-sm font-medium line-clamp-2">{season.title || season.name}</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
};
// Video modal for promotional videos
const VideoModal = ({ video, onClose }) => {
if (!video) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center p-4">
<div className="relative max-w-4xl w-full bg-[var(--card)] rounded-lg overflow-hidden">
<button
onClick={onClose}
className="absolute top-3 right-3 z-10 bg-black bg-opacity-50 rounded-full p-1"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 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>
{video.title && (
<div className="p-3">
<p className="text-white font-medium">{video.title}</p>
</div>
)}
</div>
</div>
);
};
return (
<div className="relative">
{/* Video Modal */}
{activeVideo && <VideoModal video={activeVideo} onClose={() => setActiveVideo(null)} />}
{/* Background Image with Gradient Overlay */}
<div className="absolute inset-0 h-[250px] md:h-[400px] overflow-hidden -z-10">
{info.poster && (
<>
<Image
src={info.poster}
alt={info.name}
fill
className="object-cover opacity-10 blur-sm"
priority
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[var(--background)] to-[var(--background)]"></div>
</>
)}
</div>
{/* Main Content */}
<div className="container mx-auto px-4 md:px-4 pt-6 md:pt-8">
<div className="flex flex-col md:flex-row md:gap-8">
{/* Left Column - Poster and Mobile Title */}
<div className="w-full md:w-1/4 lg:w-1/4">
<div className="bg-[var(--card)] rounded-xl md:rounded-lg overflow-hidden shadow-xl max-w-[180px] mx-auto md:max-w-none">
<div className="relative aspect-[3/4] w-full">
<Image
src={info.poster}
alt={info.name}
fill
className="object-cover"
priority
/>
</div>
</div>
{/* Mobile Title Section */}
<div className="md:hidden mt-4 text-center">
<h1 className="text-2xl font-bold text-white mb-2">{info.name}</h1>
{info.jname && (
<h2 className="text-base text-gray-400">{info.jname}</h2>
)}
</div>
{/* Mobile Quick Info */}
<div className="md:hidden mt-4 flex flex-wrap justify-center gap-2">
{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 font-medium">{info.stats.rating}</span>
</div>
)}
{moreInfo?.status && (
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
{moreInfo.status}
</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>
)}
</div>
</div>
{/* Right Column - Details */}
<div className="w-full md:w-3/4 lg:w-3/4 mt-6 md:mt-0">
<div className="flex flex-col gap-5 md:gap-6">
{/* Desktop Title Section */}
<div className="hidden md:block">
<h1 className="text-3xl lg:text-4xl font-bold text-white mb-2">{info.name}</h1>
{info.jname && (
<h2 className="text-lg text-gray-400">{info.jname}</h2>
)}
</div>
{/* Desktop Quick Info */}
<div className="hidden md:flex flex-wrap gap-3">
{info.stats?.rating && (
<div className="flex items-center bg-[var(--card)] px-3 py-1 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 font-medium">{info.stats.rating}</span>
</div>
)}
{moreInfo?.status && (
<div className="bg-[var(--card)] px-3 py-1 rounded-full text-sm text-white">
{moreInfo.status}
</div>
)}
{info.stats?.type && (
<div className="bg-[var(--card)] px-3 py-1 rounded-full text-sm text-white">
{info.stats.type}
</div>
)}
{info.stats?.episodes && (
<div className="bg-[var(--card)] px-3 py-1 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 rounded-full text-sm text-white">
{info.stats.quality}
</div>
)}
{info.stats?.duration && (
<div className="bg-[var(--card)] px-3 py-1 rounded-full text-sm text-white">
{info.stats.duration}
</div>
)}
</div>
{/* Synopsis */}
<div className="bg-[var(--card)] rounded-xl md:rounded-lg p-4 md:p-6">
<h3 className="text-lg md:text-xl font-semibold text-white mb-3">Synopsis</h3>
<div className="relative">
<p className={`text-gray-300 leading-relaxed text-sm md:text-base ${!isExpanded ? 'line-clamp-4' : ''}`}>
{info.description || 'No description available for this anime.'}
</p>
{info.description && info.description.length > 100 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-[var(--primary)] hover:underline text-sm mt-2 font-medium"
>
{isExpanded ? 'Show Less' : 'Read More'}
</button>
)}
</div>
</div>
{/* Watch Button */}
{info.stats?.episodes && (info.stats.episodes.sub > 0 || info.stats.episodes.dub > 0) && (
<div className="flex items-center gap-4">
<Link
href={`/watch/${info.id}?ep=1`}
className="bg-[#ffffff] text-[var(--background)] px-6 py-3 rounded-xl md:rounded-lg hover:opacity-90 transition-opacity flex items-center justify-center font-medium text-base w-full md:w-auto"
>
<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>
)}
{/* Promotional Videos */}
{info.promotionalVideos && info.promotionalVideos.length > 0 && (
<div className="bg-[var(--card)] rounded-xl md:rounded-lg p-4 md:p-6">
<h3 className="text-lg md:text-xl font-semibold text-white mb-3">Promotional Videos</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{info.promotionalVideos.map((video, index) => (
<div
key={index}
className="relative aspect-video cursor-pointer group overflow-hidden rounded-lg"
onClick={() => setActiveVideo(video)}
>
<div className="absolute inset-0 bg-black bg-opacity-30 group-hover:bg-opacity-20 transition-all duration-200 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-[var(--primary)] flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 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>
)}
{/* Additional Info */}
<div className="space-y-5 md:space-y-6">
{/* Genres */}
{moreInfo?.genres && moreInfo.genres.length > 0 && (
<div>
<h3 className="text-white font-medium mb-3 text-center md:text-left">Genres</h3>
<div className="overflow-x-auto hide-scrollbar">
<div className="flex flex-wrap md:flex-nowrap gap-2 justify-center md:justify-start pb-1">
{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"
>
{genre}
</Link>
))}
</div>
</div>
</div>
)}
{/* Studios */}
{moreInfo?.studios && (
<div>
<h3 className="text-white font-medium mb-3 text-center md:text-left">Studios</h3>
<div className="flex flex-wrap gap-2 justify-center md:justify-start">
<div className="px-3 py-1.5 bg-[var(--card)] text-gray-300 text-sm rounded-full">
{moreInfo.studios}
</div>
</div>
</div>
)}
{/* Aired Date */}
{moreInfo?.aired && (
<div>
<h3 className="text-white font-medium mb-3 text-center md:text-left">Aired</h3>
<div className="flex flex-wrap gap-2 justify-center md:justify-start">
<div className="px-3 py-1.5 bg-[var(--card)] text-gray-300 text-sm rounded-full">
{moreInfo.aired}
</div>
</div>
</div>
)}
{/* Character & Voice Actors */}
{info.characterVoiceActor && info.characterVoiceActor.length > 0 && (
<div>
<h3 className="text-white font-medium mb-3 text-center md:text-left">Characters & Voice Actors</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-2 lg:grid-cols-3 gap-4">
{info.characterVoiceActor.map((item, index) => (
<div key={index} className="bg-[var(--card)] p-3 rounded-lg flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="relative w-10 h-10 rounded-full overflow-hidden">
<Image
src={item.character.poster}
alt={item.character.name}
fill
className="object-cover"
/>
</div>
<div className="min-w-0">
<p className="text-sm text-white truncate">{item.character.name}</p>
<p className="text-xs text-gray-400 truncate">{item.character.cast}</p>
</div>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="relative w-10 h-10 rounded-full overflow-hidden">
<Image
src={item.voiceActor.poster}
alt={item.voiceActor.name}
fill
className="object-cover"
/>
</div>
<div className="min-w-0">
<p className="text-sm text-white truncate">{item.voiceActor.name}</p>
<p className="text-xs text-gray-400 truncate">{item.voiceActor.cast}</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
{/* Seasons Section */}
{renderSeasons()}
{/* Related Anime Section */}
{renderAnimeCards(relatedAnime, 'Related Anime')}
{/* Recommendations Section */}
{renderAnimeCards(recommendations, 'You May Also Like')}
{/* Most Popular Section */}
{renderAnimeCards(mostPopular, 'Most Popular')}
</div>
</div>
);
}

View File

@@ -0,0 +1,597 @@
'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>
);
}

101
src/components/AnimeInfo.js Normal file
View File

@@ -0,0 +1,101 @@
import Image from 'next/image';
export default function AnimeInfo({ anime }) {
return (
<div className="bg-[#1a1a1a] rounded-xl shadow-2xl overflow-hidden">
<div className="relative">
{/* Banner Image - You'll need to add bannerImage to your anime object */}
<div className="w-full h-48 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-t from-[#1a1a1a] to-transparent z-10" />
{anime.bannerImage ? (
<Image
src={anime.bannerImage}
alt=""
fill
className="object-cover opacity-50"
/>
) : (
<div className="w-full h-full bg-gradient-to-r from-gray-800 to-gray-900" />
)}
</div>
{/* Content */}
<div className="relative z-20 -mt-24 px-6 pb-6">
<div className="flex flex-col md:flex-row gap-6">
{/* Cover Image */}
<div className="relative w-40 md:w-48 flex-shrink-0">
<div className="aspect-[2/3] relative rounded-lg overflow-hidden shadow-xl border-4 border-[#1a1a1a]">
<Image
src={anime.coverImage}
alt={anime.title}
fill
className="object-cover"
/>
</div>
</div>
{/* Details */}
<div className="flex-grow">
{/* Title and Alternative Titles */}
<div className="mb-4">
<h1 className="text-2xl md:text-3xl font-bold text-white mb-2">
{anime.title}
</h1>
{anime.alternativeTitles && (
<div className="text-gray-400 text-sm">
{anime.alternativeTitles.join(' • ')}
</div>
)}
</div>
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-[#242424] rounded-lg p-3">
<div className="text-gray-400 text-xs mb-1">Status</div>
<div className="text-white font-medium">{anime.status}</div>
</div>
<div className="bg-[#242424] rounded-lg p-3">
<div className="text-gray-400 text-xs mb-1">Episodes</div>
<div className="text-white font-medium">{anime.totalEpisodes}</div>
</div>
<div className="bg-[#242424] rounded-lg p-3">
<div className="text-gray-400 text-xs mb-1">Season</div>
<div className="text-white font-medium">{anime.season} {anime.year}</div>
</div>
<div className="bg-[#242424] rounded-lg p-3">
<div className="text-gray-400 text-xs mb-1">Studio</div>
<div className="text-white font-medium">{anime.studio}</div>
</div>
</div>
{/* Genres */}
{anime.genres && (
<div className="mb-6">
<div className="text-gray-400 text-sm mb-2">Genres</div>
<div className="flex flex-wrap gap-2">
{anime.genres.map((genre) => (
<span
key={genre}
className="px-3 py-1 rounded-full bg-[#242424] text-white text-sm hover:bg-[#2a2a2a] transition-colors cursor-pointer"
>
{genre}
</span>
))}
</div>
</div>
)}
{/* Synopsis */}
<div>
<div className="text-gray-400 text-sm mb-2">Synopsis</div>
<div className="text-gray-300 text-sm leading-relaxed">
{anime.synopsis}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
'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}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,220 @@
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?.episodeId) {
setActiveEpisodeId(currentEpisode.episodeId);
}
}, [currentEpisode]);
// Sync with URL to identify current episode
useEffect(() => {
const checkCurrentEpisode = () => {
const path = window.location.pathname;
const match = path.match(/\/watch\/(.+)$/);
if (match) {
const episodeId = match[1];
setActiveEpisodeId(episodeId);
// Find the episode and update page
const episode = episodes.find(ep => ep.episodeId === episodeId);
if (episode) {
const pageNumber = Math.ceil(episode.number / episodesPerPage);
setCurrentPage(pageNumber);
}
}
};
checkCurrentEpisode();
}, [episodes, window.location.pathname]);
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) => {
return episode.episodeId === activeEpisodeId;
};
const handleEpisodeSelect = (episode, e) => {
e.preventDefault();
if (onEpisodeClick) {
onEpisodeClick(episode.episodeId);
}
setActiveEpisodeId(episode.episodeId);
};
// Scroll active episode into view when page changes or active episode changes
useEffect(() => {
if (activeEpisodeId) {
const activeElement = document.querySelector(`[data-episode-id="${activeEpisodeId}"]`);
if (activeElement) {
activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}, [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.episodeId}
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.episodeId}
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>
);
}

230
src/components/GenreBar.js Normal file
View File

@@ -0,0 +1,230 @@
'use client';
import Link from 'next/link';
import { useState, useEffect, useRef } 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
const defaultGenres = [
"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);
}, []);
// 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

@@ -0,0 +1,93 @@
'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>
);
}

703
src/components/Navbar.js Normal file
View File

@@ -0,0 +1,703 @@
'use client';
import Link from 'next/link';
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();
// Enhanced fallback suggestions with popular anime
const fallbackSuggestions = [
{
id: "naruto",
title: "Naruto",
image: "https://cdn.myanimelist.net/images/anime/13/17405.jpg",
type: "TV",
year: 2002,
episodes: 220,
rating: 79
},
{
id: "one-piece",
title: "One Piece",
image: "https://cdn.myanimelist.net/images/anime/6/73245.jpg",
type: "TV",
year: 1999,
episodes: 1000,
rating: 85
},
{
id: "attack-on-titan",
title: "Attack on Titan",
image: "https://cdn.myanimelist.net/images/anime/10/47347.jpg",
type: "TV",
year: 2013,
episodes: 75,
rating: 90
},
{
id: "demon-slayer",
title: "Demon Slayer",
image: "https://cdn.myanimelist.net/images/anime/1286/99889.jpg",
type: "TV",
year: 2019,
episodes: 26,
rating: 86
},
{
id: "my-hero-academia",
title: "My Hero Academia",
image: "https://cdn.myanimelist.net/images/anime/10/78745.jpg",
type: "TV",
year: 2016,
episodes: 113,
rating: 82
},
{
id: "jujutsu-kaisen",
title: "Jujutsu Kaisen",
image: "https://cdn.myanimelist.net/images/anime/1171/109222.jpg",
type: "TV",
year: 2020,
episodes: 24,
rating: 88
},
{
id: "tokyo-revengers",
title: "Tokyo Revengers",
image: "https://cdn.myanimelist.net/images/anime/1839/122012.jpg",
type: "TV",
year: 2021,
episodes: 24,
rating: 80
},
{
id: "death-note",
title: "Death Note",
image: "https://cdn.myanimelist.net/images/anime/9/9453.jpg",
type: "TV",
year: 2006,
episodes: 37,
rating: 90
}
];
// 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 {
// Use fallback with pattern matching
getFilteredFallbackSuggestions(searchQuery);
}
} catch (error) {
console.error('Error in search component:', error);
// Use fallback with pattern matching
getFilteredFallbackSuggestions(searchQuery);
} finally {
setIsLoading(false);
}
} else {
setSearchSuggestions([]);
setShowSuggestions(false);
}
};
// Helper function to get filtered fallback suggestions
const getFilteredFallbackSuggestions = (query) => {
const filtered = fallbackSuggestions.filter(anime =>
anime.title.toLowerCase().includes(query.toLowerCase())
);
if (filtered.length > 0) {
setSearchSuggestions(filtered.slice(0, 5));
} else {
// Create a generic suggestion based on the search query
setSearchSuggestions([{
id: query.toLowerCase().replace(/\s+/g, '-'),
title: `Search for "${query}"`,
type: "SEARCH"
}]);
}
};
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}>
<img src="/Logo.png" alt="JustAnime Logo" className="h-[38px]" />
</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 ? (
<img
src={suggestion.image}
alt={suggestion.title}
className="w-full h-full object-cover"
/>
) : (
<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 ? (
<img
src={suggestion.image}
alt={suggestion.title}
className="w-full h-full object-cover"
/>
) : (
<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

@@ -0,0 +1,85 @@
'use client';
import Navbar from './Navbar';
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">
<img src="/Logo.png" alt="JustAnime Logo" className="h-8" />
</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

@@ -0,0 +1,226 @@
'use client';
import React, { useEffect, useState } 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 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);
// Handle hydration mismatch
useEffect(() => {
setIsClient(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>
);
}
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"
>
{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">
<Link
href={`/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;

159
src/components/TopLists.js Normal file
View File

@@ -0,0 +1,159 @@
'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

@@ -0,0 +1,55 @@
'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

510
src/lib/api.js Normal file
View File

@@ -0,0 +1,510 @@
const API_BASE_URL = process.env.ANIWATCH_API || "https://justaniwatchapi.vercel.app/api/v2/hianime";
// Common headers for all API requests
const API_HEADERS = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Origin': 'https://hianime.to',
'Referer': 'https://hianime.to/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
};
export const fetchRecentEpisodes = async (page = 1) => {
try {
const response = await fetch(`${API_BASE_URL}/category/recently-updated?page=${page}`, {
headers: API_HEADERS,
credentials: 'omit'
});
if (!response.ok) throw new Error('Failed to fetch recent episodes');
const data = await response.json();
return {
results: data.data.animes.map(anime => ({
id: anime.id,
name: anime.name,
poster: anime.poster,
type: anime.type,
episodes: {
sub: anime.episodes?.sub || 0,
dub: anime.episodes?.dub || 0
}
})) || [],
currentPage: data.data.currentPage,
hasNextPage: data.data.hasNextPage
};
} catch (error) {
console.error('Error fetching recent episodes:', error);
return { results: [] };
}
};
export const fetchTopAiring = async (page = 1) => {
try {
const response = await fetch(`${API_BASE_URL}/category/top-airing?page=${page}`);
if (!response.ok) throw new Error('Failed to fetch top airing');
const data = await response.json();
return {
results: data.data.animes.map(anime => ({
id: anime.id,
name: anime.name,
poster: anime.poster,
type: anime.type,
episodes: {
sub: anime.episodes?.sub || 0,
dub: anime.episodes?.dub || 0
}
})) || [],
currentPage: data.data.currentPage,
hasNextPage: data.data.hasNextPage
};
} catch (error) {
console.error('Error fetching top airing:', error);
return { results: [] };
}
};
export const fetchMostPopular = async (page = 1) => {
try {
const response = await fetch(`${API_BASE_URL}/category/most-popular?page=${page}`);
if (!response.ok) throw new Error('Failed to fetch most popular');
const data = await response.json();
return {
results: data.data.animes.map(anime => ({
id: anime.id,
name: anime.name,
poster: anime.poster,
type: anime.type,
episodes: {
sub: anime.episodes?.sub || 0,
dub: anime.episodes?.dub || 0
}
})) || [],
currentPage: data.data.currentPage,
hasNextPage: data.data.hasNextPage
};
} catch (error) {
console.error('Error fetching most popular:', error);
return { results: [] };
}
};
export const fetchMostFavorite = async (page = 1) => {
try {
const response = await fetch(`${API_BASE_URL}/category/most-favorite?page=${page}`);
if (!response.ok) throw new Error('Failed to fetch most favorite');
const data = await response.json();
return {
results: data.data.animes || [],
currentPage: data.data.currentPage,
hasNextPage: data.data.hasNextPage
};
} catch (error) {
console.error('Error fetching most favorite:', error);
return { results: [] };
}
};
export const fetchLatestCompleted = async (page = 1) => {
try {
const response = await fetch(`${API_BASE_URL}/category/completed?page=${page}`);
if (!response.ok) throw new Error('Failed to fetch latest completed');
const data = await response.json();
return {
results: data.data.animes.map(anime => ({
id: anime.id,
name: anime.name,
poster: anime.poster,
type: anime.type,
episodes: {
sub: anime.episodes?.sub || 0,
dub: anime.episodes?.dub || 0
}
})) || [],
currentPage: data.data.currentPage,
hasNextPage: data.data.hasNextPage
};
} catch (error) {
console.error('Error fetching latest completed:', error);
return { results: [] };
}
};
export const fetchTopUpcoming = async (page = 1) => {
try {
const response = await fetch(`${API_BASE_URL}/category/top-upcoming?page=${page}`);
if (!response.ok) throw new Error('Failed to fetch top upcoming');
const data = await response.json();
return {
results: data.data.animes || [],
currentPage: data.data.currentPage,
hasNextPage: data.data.hasNextPage
};
} catch (error) {
console.error('Error fetching top upcoming:', error);
return { results: [] };
}
};
export const fetchTrending = async () => {
try {
const response = await fetch(`${API_BASE_URL}/home`);
if (!response.ok) throw new Error('Failed to fetch trending anime');
const data = await response.json();
// Map the trending animes to match the TrendingList component's expected format
const trendingAnimes = (data.data.trendingAnimes || []).map(anime => ({
id: anime.id,
title: anime.name,
image: anime.poster,
rank: anime.rank
}));
return {
results: trendingAnimes
};
} catch (error) {
console.error('Error fetching trending anime:', error);
return { results: [] };
}
};
export const fetchAnimeInfo = async (id) => {
try {
if (!id) {
console.error('Invalid anime ID provided');
return null;
}
const encodedId = encodeURIComponent(id);
const url = `${API_BASE_URL}/anime/${encodedId}`;
console.log('[API Call] Fetching anime info from:', url);
// Server-side fetch doesn't need credentials or mode settings
const requestOptions = {
method: 'GET',
headers: API_HEADERS,
};
const response = await fetch(url, requestOptions);
// Handle failed requests gracefully
if (!response.ok) {
console.error(`[API Error] Status: ${response.status}`);
return createFallbackAnimeData(id);
}
// Parse the JSON response
const data = await response.json();
console.log('[API Response]', data);
// Check if the response is successful
if (!data.success && data.status !== 200) {
console.error('[API Error] Invalid response format:', data);
return createFallbackAnimeData(id);
}
// The data structure might be nested in different ways depending on the API
const responseData = data.data || data;
// Log the data structure for debugging
console.log('[API Data Structure]', JSON.stringify(responseData, null, 2));
// Extract the anime data from the response
const animeData = responseData.anime;
if (!animeData) {
console.error('[API Error] Missing anime data in response:', responseData);
return createFallbackAnimeData(id);
}
// Return the complete data structure as expected by the components
return {
info: {
id: id,
name: animeData.info?.name || '',
jname: animeData.info?.jname || '',
poster: animeData.info?.poster || '',
description: animeData.info?.description || '',
stats: {
rating: animeData.info?.stats?.rating || '0',
quality: animeData.info?.stats?.quality || 'HD',
episodes: animeData.info?.stats?.episodes || { sub: 0, dub: 0 },
type: animeData.info?.stats?.type || 'TV',
duration: animeData.info?.stats?.duration || 'Unknown'
},
promotionalVideos: Array.isArray(animeData.info?.promotionalVideos)
? animeData.info.promotionalVideos
: [],
characterVoiceActor: Array.isArray(animeData.info?.characterVoiceActor)
? animeData.info.characterVoiceActor
: []
},
moreInfo: animeData.moreInfo || {
aired: '',
genres: [],
status: 'Unknown',
studios: '',
duration: ''
},
relatedAnime: Array.isArray(responseData.relatedAnimes)
? responseData.relatedAnimes
: [],
recommendations: Array.isArray(responseData.recommendedAnimes)
? responseData.recommendedAnimes
: [],
mostPopular: Array.isArray(responseData.mostPopularAnimes)
? responseData.mostPopularAnimes
: [],
seasons: Array.isArray(responseData.seasons)
? responseData.seasons
: []
};
} catch (error) {
console.error('[API Error] Error fetching anime info:', error);
return createFallbackAnimeData(id);
}
};
// Helper function to create fallback anime data when the API fails
function createFallbackAnimeData(id) {
return {
info: {
id: id,
name: 'Anime Information Temporarily Unavailable',
jname: '',
poster: 'https://via.placeholder.com/225x318?text=Anime',
description: 'The anime data could not be loaded at this time. Please try again later.',
stats: {
rating: '0',
quality: 'HD',
episodes: {
sub: 0,
dub: 0
},
type: 'Unknown',
duration: 'Unknown'
},
promotionalVideos: [],
characterVoiceActor: []
},
moreInfo: {
aired: '',
genres: ['Action', 'Adventure'],
status: 'Unknown',
studios: '',
duration: ''
},
relatedAnime: [],
recommendations: [],
mostPopular: [],
seasons: []
};
}
export const fetchEpisodeSources = async (episodeId, dub = false) => {
try {
if (!episodeId || episodeId === 'undefined') {
console.error('Invalid episode ID provided');
return { sources: [] };
}
const apiUrl = `${API_BASE_URL}/episode/sources?animeEpisodeId=${episodeId}&category=${dub ? 'dub' : 'sub'}`;
console.log(`[API Call] Fetching sources from: ${apiUrl}`);
const response = await fetch(apiUrl, {
headers: API_HEADERS,
credentials: 'omit'
});
if (!response.ok) {
throw new Error(`Failed to fetch episode sources: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('[API Response] Raw data:', data);
if (!data || !data.data) {
console.error('[API Error] Empty response received');
return { sources: [] };
}
return {
sources: data.data.sources,
headers: data.data.headers || { "Referer": "https://hianime.to/" },
subtitles: data.data.subtitles || []
};
} catch (error) {
console.error('Error fetching episode sources:', error);
return { sources: [] };
}
};
export const searchAnime = async (query, page = 1) => {
try {
const response = await fetch(`${API_BASE_URL}/search?q=${encodeURIComponent(query)}&page=${page}`);
if (!response.ok) throw new Error('Failed to search anime');
const data = await response.json();
return {
results: data.data.animes || [],
currentPage: data.data.currentPage,
hasNextPage: data.data.hasNextPage
};
} catch (error) {
console.error('Error searching anime:', error);
return { results: [] };
}
};
export const fetchGenres = async () => {
try {
const response = await fetch(`${API_BASE_URL}/home`);
if (!response.ok) throw new Error('Failed to fetch genres');
const data = await response.json();
return data.data.genres || [];
} catch (error) {
console.error('Error fetching genres:', error);
return [];
}
};
export const fetchGenreAnime = async (genre, page = 1) => {
try {
const response = await fetch(`${API_BASE_URL}/genre/${encodeURIComponent(genre)}?page=${page}`);
if (!response.ok) throw new Error('Failed to fetch genre anime');
const data = await response.json();
return {
results: data.data.animes || [],
currentPage: data.data.currentPage,
hasNextPage: data.data.hasNextPage
};
} catch (error) {
console.error('Error fetching genre anime:', error);
return { results: [] };
}
};
export const fetchSearchSuggestions = async (query) => {
try {
const response = await fetch(`${API_BASE_URL}/search/suggestion?q=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error('Failed to fetch search suggestions');
const data = await response.json();
return data.data.suggestions || [];
} catch (error) {
console.error('Error fetching search suggestions:', error);
return [];
}
};
export const fetchSchedule = async () => {
try {
const today = new Date().toISOString().split('T')[0];
const response = await fetch(`${API_BASE_URL}/schedule?date=${today}`);
if (!response.ok) throw new Error('Failed to fetch schedule');
const data = await response.json();
// Map the scheduled animes to include all required fields
const scheduledAnimes = data.data.scheduledAnimes || [];
return {
scheduledAnimes: scheduledAnimes.map(anime => ({
id: anime.id,
time: anime.time,
name: anime.name,
jname: anime.jname,
airingTimestamp: anime.airingTimestamp,
secondsUntilAiring: anime.secondsUntilAiring
}))
};
} catch (error) {
console.error('Error fetching schedule:', error);
return { scheduledAnimes: [] };
}
};
export const fetchSpotlightAnime = async (limit = 8) => {
try {
const response = await fetch(`${API_BASE_URL}/home`);
if (!response.ok) throw new Error('Failed to fetch spotlight anime');
const data = await response.json();
// Map the spotlight animes to match the exact schema from the API
const spotlightAnimes = (data.data.spotlightAnimes || []).map(anime => ({
id: anime.id,
name: anime.name,
jname: anime.jname,
poster: anime.poster,
banner: anime.banner,
description: anime.description,
rank: anime.rank,
otherInfo: anime.otherInfo || [],
episodes: {
sub: anime.episodes?.sub || 0,
dub: anime.episodes?.dub || 0
}
}));
return spotlightAnimes.slice(0, limit);
} catch (error) {
console.error('Error fetching spotlight anime:', error);
return [];
}
};
// Top 10 sections with proper data mapping
export const fetchTopToday = async () => {
try {
const response = await fetch(`${API_BASE_URL}/home`);
if (!response.ok) throw new Error('Failed to fetch top today');
const data = await response.json();
// Map the top 10 animes to include all required fields
return (data.data.top10Animes?.today || []).map(anime => ({
id: anime.id,
name: anime.name,
poster: anime.poster,
rank: anime.rank,
episodes: anime.episodes || { sub: 0, dub: 0 }
}));
} catch (error) {
console.error('Error fetching top today:', error);
return [];
}
};
export const fetchTopWeek = async () => {
try {
const response = await fetch(`${API_BASE_URL}/home`);
if (!response.ok) throw new Error('Failed to fetch top week');
const data = await response.json();
// Map the top 10 animes to include all required fields
return (data.data.top10Animes?.week || []).map(anime => ({
id: anime.id,
name: anime.name,
poster: anime.poster,
rank: anime.rank,
episodes: anime.episodes || { sub: 0, dub: 0 }
}));
} catch (error) {
console.error('Error fetching top week:', error);
return [];
}
};
export const fetchTopMonth = async () => {
try {
const response = await fetch(`${API_BASE_URL}/home`);
if (!response.ok) throw new Error('Failed to fetch top month');
const data = await response.json();
// Map the top 10 animes to include all required fields
return (data.data.top10Animes?.month || []).map(anime => ({
id: anime.id,
name: anime.name,
poster: anime.poster,
rank: anime.rank,
episodes: anime.episodes || { sub: 0, dub: 0 }
}));
} catch (error) {
console.error('Error fetching top month:', error);
return [];
}
};