mirror of
https://github.com/JustAnimeCore/JustAnime.git
synced 2026-04-17 22:01:45 +00:00
fresh
This commit is contained in:
210
src/components/AnimeCalendar.js
Normal file
210
src/components/AnimeCalendar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/AnimeCard.js
Normal file
96
src/components/AnimeCard.js
Normal 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>
|
||||
);
|
||||
}
|
||||
435
src/components/AnimeDetails.js
Normal file
435
src/components/AnimeDetails.js
Normal 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>
|
||||
);
|
||||
}
|
||||
597
src/components/AnimeFilters.js
Normal file
597
src/components/AnimeFilters.js
Normal 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
101
src/components/AnimeInfo.js
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/AnimeTabs.js
Normal file
85
src/components/AnimeTabs.js
Normal 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>
|
||||
);
|
||||
}
|
||||
220
src/components/EpisodeList.js
Normal file
220
src/components/EpisodeList.js
Normal 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
230
src/components/GenreBar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/GenreList.js
Normal file
93
src/components/GenreList.js
Normal 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
703
src/components/Navbar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/SharedLayout.js
Normal file
85
src/components/SharedLayout.js
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
226
src/components/SpotlightCarousel.js
Normal file
226
src/components/SpotlightCarousel.js
Normal 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
159
src/components/TopLists.js
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/TrendingList.js
Normal file
55
src/components/TrendingList.js
Normal 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>
|
||||
);
|
||||
}
|
||||
1544
src/components/VideoPlayer.js
Normal file
1544
src/components/VideoPlayer.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user