mirror of
https://github.com/JustAnimeCore/JustAnime.git
synced 2026-04-17 22:01:45 +00:00
Landing page
This commit is contained in:
@@ -1,210 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { fetchSchedule } from '@/lib/api';
|
||||
|
||||
export default function AnimeCalendar() {
|
||||
const [selectedDay, setSelectedDay] = useState(getCurrentDayIndex());
|
||||
const [scheduleData, setScheduleData] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Add custom scrollbar styles
|
||||
useEffect(() => {
|
||||
// Add custom styles for the calendar scrollbar
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.schedule-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.schedule-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--card);
|
||||
}
|
||||
.schedule-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get current day index (0-6, Sunday is 0)
|
||||
function getCurrentDayIndex() {
|
||||
const dayIndex = new Date().getDay();
|
||||
return dayIndex; // Sunday is 0, Monday is 1, etc.
|
||||
}
|
||||
|
||||
// Get current date info for the header
|
||||
const getCurrentDateInfo = () => {
|
||||
const today = new Date();
|
||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
// Calculate the date for the selected day
|
||||
const currentDayIndex = today.getDay();
|
||||
let daysDiff = selectedDay - currentDayIndex;
|
||||
|
||||
// Always get the previous occurrence (or today if it's the current day)
|
||||
if (daysDiff > 0) {
|
||||
daysDiff -= 7; // Go back to previous week
|
||||
}
|
||||
|
||||
const selectedDate = new Date(today);
|
||||
selectedDate.setDate(today.getDate() + daysDiff);
|
||||
|
||||
return {
|
||||
day: dayNames[selectedDay],
|
||||
date: selectedDate.getDate(),
|
||||
month: monthNames[selectedDate.getMonth()]
|
||||
};
|
||||
};
|
||||
|
||||
const dateInfo = getCurrentDateInfo();
|
||||
|
||||
// Generate week days for the calendar
|
||||
const days = [
|
||||
{ label: 'Mon', value: 1 },
|
||||
{ label: 'Tue', value: 2 },
|
||||
{ label: 'Wed', value: 3 },
|
||||
{ label: 'Thu', value: 4 },
|
||||
{ label: 'Fri', value: 5 },
|
||||
{ label: 'Sat', value: 6 },
|
||||
{ label: 'Sun', value: 0 },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function loadScheduleData() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Get the date for the selected day
|
||||
const today = new Date();
|
||||
const currentDayIndex = today.getDay();
|
||||
let daysDiff = selectedDay - currentDayIndex;
|
||||
|
||||
if (daysDiff > 0) {
|
||||
daysDiff -= 7;
|
||||
}
|
||||
|
||||
const selectedDate = new Date(today);
|
||||
selectedDate.setDate(today.getDate() + daysDiff);
|
||||
|
||||
// Format date as YYYY-MM-DD
|
||||
const formattedDate = selectedDate.toISOString().split('T')[0];
|
||||
|
||||
// Fetch schedule data for the selected date
|
||||
const data = await fetchSchedule(formattedDate);
|
||||
|
||||
if (data && data.scheduledAnimes) {
|
||||
// Process and sort the scheduled animes by time
|
||||
const processedData = data.scheduledAnimes
|
||||
.map(anime => ({
|
||||
id: anime.id,
|
||||
title: anime.name,
|
||||
japaneseTitle: anime.jname,
|
||||
time: anime.time,
|
||||
airingTimestamp: anime.airingTimestamp,
|
||||
secondsUntilAiring: anime.secondsUntilAiring
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Convert time strings to comparable values (assuming 24-hour format)
|
||||
const timeA = a.time.split(':').map(Number);
|
||||
const timeB = b.time.split(':').map(Number);
|
||||
return (timeA[0] * 60 + timeA[1]) - (timeB[0] * 60 + timeB[1]);
|
||||
});
|
||||
|
||||
setScheduleData(processedData);
|
||||
} else {
|
||||
setScheduleData([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading schedule data:', error);
|
||||
setScheduleData([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadScheduleData();
|
||||
}, [selectedDay]);
|
||||
|
||||
return (
|
||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[var(--border)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">Release Calendar</h2>
|
||||
<div className="text-sm text-[var(--text-muted)]">
|
||||
{dateInfo.month} {dateInfo.date}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day selector */}
|
||||
<div className="flex justify-between">
|
||||
{days.map((day) => (
|
||||
<button
|
||||
key={day.value}
|
||||
onClick={() => setSelectedDay(day.value)}
|
||||
className={`
|
||||
flex-1 py-2 text-sm font-medium rounded-md transition-colors
|
||||
${selectedDay === day.value
|
||||
? 'bg-white text-[var(--background)]'
|
||||
: 'text-[var(--text-muted)] hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule list */}
|
||||
<div className="min-h-[375px] max-h-[490px] overflow-y-auto schedule-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-[375px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white/20 border-t-white"></div>
|
||||
</div>
|
||||
) : scheduleData.length > 0 ? (
|
||||
<div className="pt-3.5 space-y-2">
|
||||
{scheduleData.map((anime) => (
|
||||
<Link
|
||||
href={`/anime/${anime.id}`}
|
||||
key={anime.id}
|
||||
className="block px-3.5 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Time */}
|
||||
<div className="w-16 text-sm font-medium text-[var(--text-muted)]">
|
||||
{anime.time}
|
||||
</div>
|
||||
|
||||
{/* Anime info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-white line-clamp-1">
|
||||
{anime.title}
|
||||
</h3>
|
||||
{anime.japaneseTitle && (
|
||||
<p className="text-xs text-[var(--text-muted)] line-clamp-1">
|
||||
{anime.japaneseTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[375px] text-[var(--text-muted)] text-sm">
|
||||
No releases scheduled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { fetchAnimeEpisodes } from '@/lib/api';
|
||||
|
||||
export default function AnimeCard({ anime, isRecent }) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [firstEpisodeId, setFirstEpisodeId] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
// Fetch first episode ID when component mounts for recent anime
|
||||
useEffect(() => {
|
||||
const fetchFirstEpisode = async () => {
|
||||
// Only fetch for recent anime and if we don't already have the episode ID
|
||||
if (isRecent && anime?.id && !firstEpisodeId && !isLoading) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log(`[AnimeCard] Fetching episodes for anime: ${anime.id}`);
|
||||
const response = await fetchAnimeEpisodes(anime.id);
|
||||
console.log(`[AnimeCard] Episodes response for ${anime.name}:`, response);
|
||||
|
||||
if (response.episodes && response.episodes.length > 0) {
|
||||
// Check for the episode ID in the format expected by the watch page
|
||||
const firstEp = response.episodes[0];
|
||||
if (firstEp.id) {
|
||||
setFirstEpisodeId(firstEp.id);
|
||||
console.log(`[AnimeCard] First episode ID (id) for ${anime.name}: ${firstEp.id}`);
|
||||
} else if (firstEp.episodeId) {
|
||||
setFirstEpisodeId(firstEp.episodeId);
|
||||
console.log(`[AnimeCard] First episode ID (episodeId) for ${anime.name}: ${firstEp.episodeId}`);
|
||||
} else {
|
||||
// Create a fallback ID if neither id nor episodeId are available
|
||||
const fallbackId = `${anime.id}?ep=1`;
|
||||
setFirstEpisodeId(fallbackId);
|
||||
console.log(`[AnimeCard] Using fallback ID for ${anime.name}: ${fallbackId}`);
|
||||
}
|
||||
} else if (anime.id) {
|
||||
// If no episodes found, create a fallback ID
|
||||
const fallbackId = `${anime.id}?ep=1`;
|
||||
setFirstEpisodeId(fallbackId);
|
||||
console.log(`[AnimeCard] No episodes found for ${anime.name}, using fallback ID: ${fallbackId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AnimeCard] Error fetching episodes for ${anime.id}:`, error);
|
||||
// Even on error, try to use fallback
|
||||
if (anime.id) {
|
||||
const fallbackId = `${anime.id}?ep=1`;
|
||||
setFirstEpisodeId(fallbackId);
|
||||
console.log(`[AnimeCard] Error for ${anime.name}, using fallback ID: ${fallbackId}`);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchFirstEpisode();
|
||||
|
||||
// Clean up timer if component unmounts
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [anime?.id, anime?.name, isRecent, firstEpisodeId, isLoading]);
|
||||
|
||||
if (!anime) return null;
|
||||
|
||||
const handleImageError = () => {
|
||||
console.log("Image error for:", anime.name);
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
// Get image URL with fallback
|
||||
const imageSrc = imageError ? '/images/placeholder.png' : anime.poster;
|
||||
|
||||
// Generate appropriate links
|
||||
const infoLink = `/anime/${anime.id}`;
|
||||
|
||||
// Build the watch URL based on the first episode ID or fallback
|
||||
const watchLink = isRecent && firstEpisodeId
|
||||
? `/watch/${firstEpisodeId}`
|
||||
: isRecent
|
||||
? `/anime/${anime.id}` // Temporarily link to info page while loading
|
||||
: `/anime/${anime.id}`; // Non-recent anime always link to info
|
||||
|
||||
return (
|
||||
<div className="anime-card w-full flex flex-col">
|
||||
{/* Image card linking to watch page for recent anime, or info page otherwise */}
|
||||
<Link
|
||||
href={isRecent ? watchLink : infoLink}
|
||||
className="block w-full rounded-lg overflow-hidden transition-transform duration-300 hover:scale-[1.02] group"
|
||||
prefetch={false}
|
||||
>
|
||||
<div className="relative aspect-[2/3] rounded-lg overflow-hidden bg-gray-900 shadow-lg">
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black opacity-0 group-hover:opacity-60 transition-opacity duration-300 z-[3]"></div>
|
||||
|
||||
{/* Play button triangle - appears on hover */}
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-16 h-16 text-white drop-shadow-lg">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={anime.name || 'Anime'}
|
||||
fill
|
||||
className="object-cover rounded-lg"
|
||||
onError={handleImageError}
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 20vw"
|
||||
unoptimized={true}
|
||||
priority={false}
|
||||
/>
|
||||
|
||||
{/* Badges in bottom left */}
|
||||
<div className="absolute bottom-2 left-2 flex space-x-1 z-10">
|
||||
{/* Episode badges */}
|
||||
{anime.episodes && (
|
||||
<>
|
||||
{anime.episodes.sub > 0 && (
|
||||
<div className="bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded">
|
||||
SUB {anime.episodes.sub}
|
||||
</div>
|
||||
)}
|
||||
{anime.episodes.dub > 0 && (
|
||||
<div className="bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded">
|
||||
DUB {anime.episodes.dub}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Type badge */}
|
||||
{anime.type && (
|
||||
<div className="bg-black/70 text-white text-[10px] px-1.5 py-0.5 rounded">
|
||||
{anime.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Title linking to info page */}
|
||||
<Link
|
||||
href={infoLink}
|
||||
className="block mt-2"
|
||||
prefetch={false}
|
||||
>
|
||||
<h3 className="text-sm font-medium text-white line-clamp-2 hover:text-[var(--primary)] transition-colors">
|
||||
{anime.name}
|
||||
</h3>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import AnimeRow from './AnimeRow';
|
||||
import SeasonRow from './SeasonRow';
|
||||
import { fetchAnimeEpisodes } from '@/lib/api';
|
||||
|
||||
export default function AnimeDetails({ anime }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [activeVideo, setActiveVideo] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('synopsis');
|
||||
const [synopsisOverflows, setSynopsisOverflows] = useState(false);
|
||||
const [firstEpisodeId, setFirstEpisodeId] = useState(null);
|
||||
const [isLoadingEpisodes, setIsLoadingEpisodes] = useState(false);
|
||||
const synopsisRef = useRef(null);
|
||||
|
||||
// Check if synopsis overflows when component mounts or when content changes
|
||||
useEffect(() => {
|
||||
if (synopsisRef.current) {
|
||||
const element = synopsisRef.current;
|
||||
setSynopsisOverflows(element.scrollHeight > element.clientHeight);
|
||||
}
|
||||
}, [anime?.info?.description, activeTab]);
|
||||
|
||||
// Fetch first episode ID when component mounts
|
||||
useEffect(() => {
|
||||
const fetchFirstEpisode = async () => {
|
||||
if (anime?.info?.id) {
|
||||
setIsLoadingEpisodes(true);
|
||||
try {
|
||||
console.log(`[AnimeDetails] Fetching episodes for anime: ${anime.info.id}`);
|
||||
const response = await fetchAnimeEpisodes(anime.info.id);
|
||||
console.log('[AnimeDetails] Episodes response:', response);
|
||||
|
||||
if (response.episodes && response.episodes.length > 0) {
|
||||
// Log the first episode to check its structure
|
||||
console.log('[AnimeDetails] First episode:', response.episodes[0]);
|
||||
|
||||
// Get the first episode's id
|
||||
const firstEp = response.episodes[0];
|
||||
if (firstEp.id) {
|
||||
setFirstEpisodeId(firstEp.id);
|
||||
console.log(`[AnimeDetails] First episode ID found: ${firstEp.id}`);
|
||||
} else if (firstEp.episodeId) {
|
||||
// Fallback to episodeId if id is not available
|
||||
setFirstEpisodeId(firstEp.episodeId);
|
||||
console.log(`[AnimeDetails] Falling back to episodeId: ${firstEp.episodeId}`);
|
||||
} else {
|
||||
// If no episode ID is found in the API response, create a fallback ID
|
||||
const fallbackId = `${anime.info.id}?ep=1`;
|
||||
setFirstEpisodeId(fallbackId);
|
||||
console.log(`[AnimeDetails] Using fallback ID: ${fallbackId}`);
|
||||
}
|
||||
} else if (anime.info.id) {
|
||||
// If no episodes found but anime ID is available, use fallback
|
||||
const fallbackId = `${anime.info.id}?ep=1`;
|
||||
setFirstEpisodeId(fallbackId);
|
||||
console.log(`[AnimeDetails] No episodes found, using fallback ID: ${fallbackId}`);
|
||||
} else {
|
||||
console.warn('[AnimeDetails] No episodes found and no anime ID available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AnimeDetails] Error fetching episodes:', error);
|
||||
// Even on error, try to use fallback
|
||||
if (anime.info.id) {
|
||||
const fallbackId = `${anime.info.id}?ep=1`;
|
||||
setFirstEpisodeId(fallbackId);
|
||||
console.log(`[AnimeDetails] Error occurred, using fallback ID: ${fallbackId}`);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingEpisodes(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchFirstEpisode();
|
||||
}, [anime?.info?.id]);
|
||||
|
||||
// Add a useEffect to debug when and why firstEpisodeId changes
|
||||
useEffect(() => {
|
||||
console.log('[AnimeDetails] firstEpisodeId changed:', firstEpisodeId);
|
||||
}, [firstEpisodeId]);
|
||||
|
||||
if (!anime?.info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { info, moreInfo, relatedAnime, recommendations, seasons } = anime;
|
||||
const hasCharacters = info.characterVoiceActor?.length > 0 || info.charactersVoiceActors?.length > 0;
|
||||
const hasVideos = info.promotionalVideos && info.promotionalVideos.length > 0;
|
||||
|
||||
// Build the watch URL based on the first episode ID
|
||||
const watchUrl = firstEpisodeId
|
||||
? `/watch/${firstEpisodeId}`
|
||||
: ''; // Empty string if no episodes available - this shouldn't happen with our fallback
|
||||
|
||||
// Add debug log here
|
||||
console.log('[AnimeDetails] Rendered with watchUrl:', watchUrl, 'firstEpisodeId:', firstEpisodeId);
|
||||
|
||||
// Video modal for promotional videos
|
||||
const VideoModal = ({ video, onClose }) => {
|
||||
if (!video) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-3 backdrop-blur-sm">
|
||||
<div className="relative w-full max-w-4xl bg-[var(--card)] rounded-lg overflow-hidden shadow-2xl border border-gray-700 animate-fadeIn">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 z-10 bg-black/50 rounded-full p-1.5 hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 sm:h-6 sm:w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="aspect-video w-full">
|
||||
<iframe
|
||||
src={video.source}
|
||||
title={video.title || "Promotional Video"}
|
||||
allowFullScreen
|
||||
className="w-full h-full"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Format status with aired date
|
||||
const getStatusWithAired = () => {
|
||||
let status = moreInfo?.status || '';
|
||||
if (moreInfo?.aired) {
|
||||
status += ` (${moreInfo.aired})`;
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Video Modal */}
|
||||
{activeVideo && <VideoModal video={activeVideo} onClose={() => setActiveVideo(null)} />}
|
||||
|
||||
{/* Background Image with Gradient Overlay - Desktop Only */}
|
||||
<div className="absolute inset-0 h-[180px] md:h-[400px] overflow-hidden -z-10">
|
||||
{info.poster && (
|
||||
<>
|
||||
<Image
|
||||
src={info.poster}
|
||||
alt={info.name}
|
||||
fill
|
||||
className="object-cover opacity-18"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[rgba(0,0,0,0.6)] to-[var(--background)]"></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-3 md:px-4 pt-3 md:pt-10">
|
||||
{/* MOBILE LAYOUT - Only visible on mobile */}
|
||||
<div className="md:hidden">
|
||||
<div className="flex flex-col mb-5">
|
||||
{/* Mobile Header with Title + Rating */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-xl font-bold text-white pr-3">{info.name}</h1>
|
||||
{info.stats?.rating && (
|
||||
<div className="flex items-center bg-[var(--card)] px-2 py-1 rounded-md">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-3.5 w-3.5 text-yellow-400 mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span className="text-white text-xs font-medium">{info.stats.rating}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Japanese Title */}
|
||||
{moreInfo?.japanese && (
|
||||
<h2 className="text-xs text-gray-300 mt-[-0.25rem] mb-3">{moreInfo.japanese}</h2>
|
||||
)}
|
||||
|
||||
{/* Mobile Two-Column Layout */}
|
||||
<div className="flex gap-3">
|
||||
{/* Left Column - Poster */}
|
||||
<div className="w-2/5 flex-shrink-0">
|
||||
<div className="bg-[var(--card)] rounded-xl overflow-hidden shadow-lg border border-gray-800">
|
||||
<div className="relative aspect-[3/4] w-full">
|
||||
<Image
|
||||
src={info.poster}
|
||||
alt={info.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Info Card */}
|
||||
<div className="w-3/5 flex flex-col">
|
||||
{/* Type & Episodes on same row */}
|
||||
<div className="flex gap-2 mb-2">
|
||||
{info.stats?.type && (
|
||||
<div className="bg-[var(--card)] px-2 py-1 rounded-md text-[10px] text-white">{info.stats.type}</div>
|
||||
)}
|
||||
|
||||
{info.stats?.episodes && (
|
||||
<div className="bg-[var(--card)] px-2 py-1 rounded-md text-[10px] text-white grow">
|
||||
{info.stats.episodes.sub > 0 && `Sub: ${info.stats.episodes.sub}`}
|
||||
{info.stats.episodes.dub > 0 && info.stats.episodes.sub > 0 && ' • '}
|
||||
{info.stats.episodes.dub > 0 && `Dub: ${info.stats.episodes.dub}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clean Info Layout */}
|
||||
<div className="bg-[var(--card)] rounded-md p-2.5 text-[11px] space-y-1.5 mb-2">
|
||||
{/* Status */}
|
||||
{moreInfo?.status && (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-16">Status:</span>
|
||||
<span className="text-white">{getStatusWithAired()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality */}
|
||||
{info.stats?.quality && (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-16">Quality:</span>
|
||||
<span className="text-white">{info.stats.quality}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{info.stats?.duration && (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-16">Duration:</span>
|
||||
<span className="text-white">{info.stats.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Studio */}
|
||||
{moreInfo?.studios && (
|
||||
<div className="flex">
|
||||
<span className="text-gray-400 w-16">Studio:</span>
|
||||
<span className="text-white">{moreInfo.studios}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Genres */}
|
||||
{moreInfo?.genres && moreInfo.genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{moreInfo.genres.slice(0, 5).map((genre, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={`/genre/${genre.toLowerCase()}`}
|
||||
className="bg-[var(--card)] px-2 py-0.5 rounded-md text-[10px] text-gray-300 hover:text-white"
|
||||
>
|
||||
{genre}
|
||||
</Link>
|
||||
))}
|
||||
{moreInfo.genres.length > 5 && (
|
||||
<span className="text-[10px] text-gray-500 self-center">+{moreInfo.genres.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watch Button - Mobile */}
|
||||
{firstEpisodeId && (
|
||||
<Link
|
||||
href={watchUrl}
|
||||
className="bg-[#ffffff] text-[var(--background)] px-4 py-2.5 rounded-xl mt-3 hover:opacity-90 transition-opacity flex items-center justify-center font-medium text-sm w-full shadow-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4 mr-1.5"
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z" />
|
||||
</svg>
|
||||
<span>Start Watching</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DESKTOP LAYOUT - Only visible on desktop */}
|
||||
<div className="hidden md:flex md:flex-row gap-10 mb-8">
|
||||
{/* Poster */}
|
||||
<div className="w-1/4 max-w-[240px]">
|
||||
<div className="bg-[var(--card)] rounded-xl overflow-hidden shadow-lg border border-gray-800">
|
||||
<div className="relative aspect-[3/4] w-full">
|
||||
<Image
|
||||
src={info.poster}
|
||||
alt={info.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Watch Button - Desktop */}
|
||||
{firstEpisodeId && (
|
||||
<Link
|
||||
href={watchUrl}
|
||||
className="bg-[#ffffff] text-[var(--background)] px-6 py-3 rounded-xl mt-4 hover:opacity-90 transition-opacity flex items-center justify-center font-medium text-base w-full shadow-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-5 w-5 mr-2"
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z" />
|
||||
</svg>
|
||||
<span>Start Watching</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and Metadata */}
|
||||
<div className="flex-1 pt-2">
|
||||
{/* Title Section */}
|
||||
<div className="text-left">
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-white mb-2">
|
||||
{info.name}
|
||||
</h1>
|
||||
|
||||
{moreInfo?.japanese && (
|
||||
<h2 className="text-base md:text-lg text-gray-400 mb-2">{moreInfo.japanese}</h2>
|
||||
)}
|
||||
|
||||
{/* Synonyms */}
|
||||
{moreInfo?.synonyms && (
|
||||
<div className="mt-2 mb-4">
|
||||
<p className="text-sm text-gray-400 italic">{moreInfo.synonyms}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badges */}
|
||||
<div className="flex flex-wrap justify-start gap-2 my-4">
|
||||
{info.stats?.rating && (
|
||||
<div className="flex items-center bg-[var(--card)] px-3 py-1.5 rounded-full">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-yellow-400 mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span className="text-white text-sm font-medium">{info.stats.rating}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status with Aired Date */}
|
||||
{moreInfo?.status && (
|
||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
||||
{getStatusWithAired()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info.stats?.type && (
|
||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
||||
{info.stats.type}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info.stats?.episodes && (
|
||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
||||
{info.stats.episodes.sub > 0 && `SUB ${info.stats.episodes.sub}`}
|
||||
{info.stats.episodes.dub > 0 && info.stats.episodes.sub > 0 && ' | '}
|
||||
{info.stats.episodes.dub > 0 && `DUB ${info.stats.episodes.dub}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info.stats?.quality && (
|
||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
||||
{info.stats.quality}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info.stats?.duration && (
|
||||
<div className="bg-[var(--card)] px-3 py-1.5 rounded-full text-sm text-white">
|
||||
{info.stats.duration}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Genres & Studios */}
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Genres */}
|
||||
{moreInfo?.genres && moreInfo.genres.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-white text-base font-medium mb-3 text-left">Genres</h3>
|
||||
<div className="flex flex-wrap justify-start gap-2">
|
||||
{moreInfo.genres.map((genre, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={`/genre/${genre.toLowerCase()}`}
|
||||
className="px-3 py-1.5 bg-[var(--card)] text-gray-300 text-sm rounded-full whitespace-nowrap hover:text-white transition-colors hover:bg-[var(--card-hover)]"
|
||||
>
|
||||
{genre}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Studios */}
|
||||
{moreInfo?.studios && (
|
||||
<div>
|
||||
<h3 className="text-white text-base font-medium mb-3 text-left">Studios</h3>
|
||||
<div className="flex flex-wrap justify-start gap-2">
|
||||
<div className="px-3 py-1.5 bg-[var(--card)] text-gray-300 text-sm rounded-full hover:text-white">
|
||||
{moreInfo.studios}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs Section - Different for Mobile/Desktop */}
|
||||
<div className="bg-[var(--card)] rounded-lg mb-6 shadow-lg border border-gray-800">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-gray-800">
|
||||
{/* Synopsis Tab */}
|
||||
<button
|
||||
className={`px-4 py-2.5 md:py-3 text-sm md:text-base font-medium transition-colors flex-1 md:flex-none ${activeTab === 'synopsis' ? 'text-white border-b-2 border-[var(--primary)]' : 'text-gray-400 hover:text-white'}`}
|
||||
onClick={() => setActiveTab('synopsis')}
|
||||
>
|
||||
Synopsis
|
||||
</button>
|
||||
|
||||
{/* Characters Tab */}
|
||||
{hasCharacters && (
|
||||
<button
|
||||
className={`px-4 py-2.5 md:py-3 text-sm md:text-base font-medium transition-colors flex-1 md:flex-none ${activeTab === 'characters' ? 'text-white border-b-2 border-[var(--primary)]' : 'text-gray-400 hover:text-white'}`}
|
||||
onClick={() => setActiveTab('characters')}
|
||||
>
|
||||
Characters
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Videos Tab */}
|
||||
{hasVideos && (
|
||||
<button
|
||||
className={`px-4 py-2.5 md:py-3 text-sm md:text-base font-medium transition-colors flex-1 md:flex-none ${activeTab === 'videos' ? 'text-white border-b-2 border-[var(--primary)]' : 'text-gray-400 hover:text-white'}`}
|
||||
onClick={() => setActiveTab('videos')}
|
||||
>
|
||||
<span>Videos</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-3 md:p-5">
|
||||
{/* Synopsis Tab */}
|
||||
{activeTab === 'synopsis' && (
|
||||
<div>
|
||||
<p
|
||||
ref={synopsisRef}
|
||||
className={`text-gray-300 leading-relaxed text-xs md:text-base ${!isExpanded ? 'line-clamp-4 md:line-clamp-6' : ''}`}
|
||||
>
|
||||
{info.description || 'No description available for this anime.'}
|
||||
</p>
|
||||
{synopsisOverflows && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-[var(--primary)] hover:underline text-xs md:text-sm mt-2 md:mt-3 font-medium"
|
||||
>
|
||||
{isExpanded ? 'Show Less' : 'Read More'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Characters Tab */}
|
||||
{activeTab === 'characters' && hasCharacters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 max-h-[60vh] md:max-h-[70vh] overflow-y-auto">
|
||||
{(info.characterVoiceActor || info.charactersVoiceActors || []).map((item, index) => (
|
||||
<div key={index} className="bg-[var(--background)] rounded overflow-hidden flex">
|
||||
{/* Character Image */}
|
||||
<div className="relative w-[40px] md:w-[60px] h-[50px] md:h-[72px] flex-shrink-0">
|
||||
<Image
|
||||
src={item.character.poster}
|
||||
alt={item.character.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text content in the middle */}
|
||||
<div className="flex-1 py-1 md:py-2.5 px-2 md:px-3 flex flex-col justify-center min-w-0">
|
||||
<div className="flex justify-between items-center gap-1 md:gap-3">
|
||||
{/* Character Name */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-white font-medium text-xs md:text-sm truncate">{item.character.name}</p>
|
||||
<p className="text-[10px] md:text-xs text-gray-400 truncate">{item.character.cast || 'Main'}</p>
|
||||
</div>
|
||||
|
||||
{/* Voice Actor Name */}
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="text-white font-medium text-xs md:text-sm truncate">{item.voiceActor.name}</p>
|
||||
<p className="text-[10px] md:text-xs text-gray-400 truncate">{item.voiceActor.cast || 'Japanese'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Actor Image */}
|
||||
<div className="relative w-[40px] md:w-[60px] h-[50px] md:h-[72px] flex-shrink-0">
|
||||
<Image
|
||||
src={item.voiceActor.poster}
|
||||
alt={item.voiceActor.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Videos Tab */}
|
||||
{activeTab === 'videos' && hasVideos && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{info.promotionalVideos.map((video, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative aspect-video cursor-pointer group overflow-hidden rounded"
|
||||
onClick={() => setActiveVideo(video)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40 group-hover:bg-black/20 transition-all duration-300 flex items-center justify-center">
|
||||
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-[var(--primary)] flex items-center justify-center transform group-hover:scale-110 transition-transform duration-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 md:h-5 md:w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<Image
|
||||
src={video.thumbnail || '/images/video-placeholder.jpg'}
|
||||
alt={video.title || `Promotional Video ${index + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seasons Section */}
|
||||
{seasons && seasons.length > 0 && (
|
||||
<SeasonRow title="Seasons" seasons={seasons} />
|
||||
)}
|
||||
|
||||
{/* Related Anime Section */}
|
||||
{relatedAnime && relatedAnime.length > 0 && (
|
||||
<AnimeRow title="Related Anime" animeList={relatedAnime} />
|
||||
)}
|
||||
|
||||
{/* Recommendations Section */}
|
||||
{recommendations && recommendations.length > 0 && (
|
||||
<AnimeRow title="You May Also Like" animeList={recommendations} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { fetchGenres } from '@/lib/api';
|
||||
import { ChevronDownIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
// Helper function to capitalize first letter of each word
|
||||
const capitalizeFirstLetter = (string) => {
|
||||
if (!string) return '';
|
||||
return string.split(' ').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
).join(' ');
|
||||
};
|
||||
|
||||
export default function AnimeFilters({
|
||||
selectedGenre,
|
||||
onGenreChange,
|
||||
yearFilter,
|
||||
onYearChange,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
showGenreFilter = true,
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
selectedSeasons = [],
|
||||
onSeasonChange,
|
||||
selectedTypes = [],
|
||||
onTypeChange,
|
||||
selectedStatus = [],
|
||||
onStatusChange,
|
||||
selectedLanguages = [],
|
||||
onLanguageChange
|
||||
}) {
|
||||
const [genres, setGenres] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [dropdowns, setDropdowns] = useState({
|
||||
genre: false,
|
||||
season: false,
|
||||
year: false,
|
||||
type: false,
|
||||
status: false,
|
||||
language: false,
|
||||
sort: false
|
||||
});
|
||||
const dropdownRefs = useRef({
|
||||
genre: null,
|
||||
season: null,
|
||||
year: null,
|
||||
type: null,
|
||||
status: null,
|
||||
language: null,
|
||||
sort: null
|
||||
});
|
||||
|
||||
// Available years for filter (current year down to 2000 and 'older')
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = ['all', ...Array.from({ length: currentYear - 1999 }, (_, i) => (currentYear - i).toString()), 'older'];
|
||||
|
||||
// Seasons data
|
||||
const seasons = ['Winter', 'Spring', 'Summer', 'Fall'];
|
||||
|
||||
// Types data
|
||||
const types = ['TV', 'Movie', 'OVA', 'ONA', 'Special'];
|
||||
|
||||
// Status data
|
||||
const statuses = ['Ongoing', 'Completed', 'Upcoming'];
|
||||
|
||||
// Languages data
|
||||
const languages = ['Subbed', 'Dubbed', 'Chinese', 'English'];
|
||||
|
||||
// Fetch genres on component mount
|
||||
useEffect(() => {
|
||||
const getGenres = async () => {
|
||||
if (!showGenreFilter) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const genreData = await fetchGenres();
|
||||
// Capitalize each genre
|
||||
const capitalizedGenres = genreData ? genreData.map(capitalizeFirstLetter) : [];
|
||||
setGenres(capitalizedGenres);
|
||||
} catch (error) {
|
||||
console.error('Error fetching genres:', error);
|
||||
setError('Failed to load genres. Please try again later.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getGenres();
|
||||
}, [showGenreFilter]);
|
||||
|
||||
// Toggle dropdown visibility
|
||||
const toggleDropdown = (dropdown) => {
|
||||
setDropdowns(prev => {
|
||||
// Close other dropdowns when opening one
|
||||
const newState = {
|
||||
genre: false,
|
||||
season: false,
|
||||
year: false,
|
||||
type: false,
|
||||
status: false,
|
||||
language: false,
|
||||
sort: false,
|
||||
[dropdown]: !prev[dropdown]
|
||||
};
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize refs for each dropdown
|
||||
useEffect(() => {
|
||||
dropdownRefs.current = {
|
||||
genre: dropdownRefs.current.genre,
|
||||
season: dropdownRefs.current.season,
|
||||
year: dropdownRefs.current.year,
|
||||
type: dropdownRefs.current.type,
|
||||
status: dropdownRefs.current.status,
|
||||
language: dropdownRefs.current.language,
|
||||
sort: dropdownRefs.current.sort
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close all dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
// Check if the click was outside all dropdown containers
|
||||
let isOutside = true;
|
||||
Object.keys(dropdownRefs.current).forEach(key => {
|
||||
if (dropdownRefs.current[key] && dropdownRefs.current[key].contains(event.target)) {
|
||||
isOutside = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (isOutside) {
|
||||
setDropdowns({
|
||||
genre: false,
|
||||
season: false,
|
||||
year: false,
|
||||
type: false,
|
||||
status: false,
|
||||
language: false,
|
||||
sort: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Prevent dropdown from closing when selecting an item in multiselect
|
||||
const keepDropdownOpen = (e, dropdown) => {
|
||||
e.stopPropagation();
|
||||
// Don't toggle the dropdown state on item click for multi-select dropdowns
|
||||
};
|
||||
|
||||
const handleClearGenre = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onGenreChange) {
|
||||
onGenreChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle multi-select filter
|
||||
const handleMultiSelectToggle = (type, value, onChange) => {
|
||||
if (!onChange) return;
|
||||
|
||||
let updatedSelection;
|
||||
if (type.includes(value)) {
|
||||
updatedSelection = type.filter(item => item !== value);
|
||||
} else {
|
||||
updatedSelection = [...type, value];
|
||||
}
|
||||
onChange(updatedSelection);
|
||||
};
|
||||
|
||||
// Modify the onClick handlers for each button to prevent event propagation
|
||||
const handleGenreSelect = (e, genre) => {
|
||||
e.stopPropagation();
|
||||
if (onGenreChange) {
|
||||
onGenreChange(genre);
|
||||
// Close genre dropdown after selection since it's a single select
|
||||
setDropdowns(prev => ({ ...prev, genre: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleYearSelect = (e, year) => {
|
||||
e.stopPropagation();
|
||||
if (onYearChange) {
|
||||
onYearChange(year);
|
||||
// Close year dropdown after selection since it's a single select
|
||||
setDropdowns(prev => ({ ...prev, year: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortSelect = (e, sort) => {
|
||||
e.stopPropagation();
|
||||
if (onSortChange) {
|
||||
onSortChange(sort);
|
||||
// Close sort dropdown after selection since it's a single select
|
||||
setDropdowns(prev => ({ ...prev, sort: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMultiSelect = (e, type, value, onChange, dropdown) => {
|
||||
e.stopPropagation();
|
||||
let updatedSelection;
|
||||
if (type.includes(value)) {
|
||||
updatedSelection = type.filter(item => item !== value);
|
||||
} else {
|
||||
updatedSelection = [...type, value];
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange(updatedSelection);
|
||||
// Keep dropdown open for multiselect to allow multiple selections
|
||||
// Without closing the dropdown
|
||||
}
|
||||
};
|
||||
|
||||
// Add clear filter handlers
|
||||
const clearAllFilters = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onGenreChange) onGenreChange(null);
|
||||
if (onYearChange) onYearChange('all');
|
||||
if (onSortChange) onSortChange('default');
|
||||
if (onSeasonChange) onSeasonChange([]);
|
||||
if (onTypeChange) onTypeChange([]);
|
||||
if (onStatusChange) onStatusChange([]);
|
||||
if (onLanguageChange) onLanguageChange([]);
|
||||
};
|
||||
|
||||
const clearGenre = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onGenreChange) onGenreChange(null);
|
||||
};
|
||||
|
||||
const clearYear = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onYearChange) onYearChange('all');
|
||||
};
|
||||
|
||||
const clearSort = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onSortChange) onSortChange('default');
|
||||
};
|
||||
|
||||
const clearSeasons = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onSeasonChange) onSeasonChange([]);
|
||||
};
|
||||
|
||||
const clearTypes = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onTypeChange) onTypeChange([]);
|
||||
};
|
||||
|
||||
const clearStatus = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onStatusChange) onStatusChange([]);
|
||||
};
|
||||
|
||||
const clearLanguages = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onLanguageChange) onLanguageChange([]);
|
||||
};
|
||||
|
||||
// Get display text for filters
|
||||
const getYearDisplayText = () => {
|
||||
if (yearFilter === 'all') return 'Year';
|
||||
if (yearFilter === 'older') return 'Before 2000';
|
||||
return yearFilter;
|
||||
};
|
||||
|
||||
const getSortDisplayText = () => {
|
||||
switch (sortOrder) {
|
||||
case 'title-asc': return 'Title (A-Z)';
|
||||
case 'title-desc': return 'Title (Z-A)';
|
||||
case 'year-desc': return 'Newest First';
|
||||
case 'year-asc': return 'Oldest First';
|
||||
default: return 'Default';
|
||||
}
|
||||
};
|
||||
|
||||
// Check if any filter is active
|
||||
const isAnyFilterActive = () => {
|
||||
return selectedGenre !== null ||
|
||||
yearFilter !== 'all' ||
|
||||
sortOrder !== 'default' ||
|
||||
selectedSeasons.length > 0 ||
|
||||
selectedTypes.length > 0 ||
|
||||
selectedStatus.length > 0 ||
|
||||
selectedLanguages.length > 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Genre Filter */}
|
||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.genre = el}>
|
||||
<button
|
||||
onClick={() => toggleDropdown('genre')}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-white/80">
|
||||
{selectedGenre ? selectedGenre : 'Genre'}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<XMarkIcon
|
||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${!selectedGenre ? 'opacity-40' : 'opacity-100'}`}
|
||||
onClick={clearGenre}
|
||||
/>
|
||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.genre ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
{dropdowns.genre && (
|
||||
<div className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
||||
<div className="max-h-[250px] overflow-y-auto custom-scrollbar">
|
||||
{genres.map((genre) => (
|
||||
<button
|
||||
key={genre}
|
||||
onClick={(e) => handleGenreSelect(e, genre)}
|
||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
||||
selectedGenre === genre ? 'text-white font-medium' : 'text-white/70'
|
||||
}`}
|
||||
>
|
||||
{genre}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Year Filter */}
|
||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.year = el}>
|
||||
<button
|
||||
onClick={() => toggleDropdown('year')}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-white/80">
|
||||
{getYearDisplayText()}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<XMarkIcon
|
||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${yearFilter === 'all' ? 'opacity-40' : 'opacity-100'}`}
|
||||
onClick={clearYear}
|
||||
/>
|
||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.year ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
{dropdowns.year && (
|
||||
<div className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
||||
<div className="max-h-[250px] overflow-y-auto custom-scrollbar">
|
||||
{years.map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={(e) => handleYearSelect(e, year)}
|
||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
||||
yearFilter === year ? 'text-white font-medium' : 'text-white/70'
|
||||
}`}
|
||||
>
|
||||
{year === 'older' ? 'Before 2000' : year === 'all' ? 'All Years' : year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Season Filter */}
|
||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.season = el}>
|
||||
<button
|
||||
onClick={() => toggleDropdown('season')}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-white/80">
|
||||
{selectedSeasons.length > 0 ? `${selectedSeasons.length} Selected` : 'Season'}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<XMarkIcon
|
||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${selectedSeasons.length === 0 ? 'opacity-40' : 'opacity-100'}`}
|
||||
onClick={clearSeasons}
|
||||
/>
|
||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.season ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
{dropdowns.season && (
|
||||
<div onClick={(e) => keepDropdownOpen(e, 'season')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
||||
{seasons.map((season) => (
|
||||
<button
|
||||
key={season}
|
||||
onClick={(e) => handleMultiSelect(e, selectedSeasons, season, onSeasonChange, 'season')}
|
||||
className="w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors flex items-center justify-between"
|
||||
>
|
||||
<span className={`text-[13px] ${selectedSeasons.includes(season) ? 'text-white font-medium' : 'text-white/70'}`}>
|
||||
{season}
|
||||
</span>
|
||||
{selectedSeasons.includes(season) && (
|
||||
<CheckIcon className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format Filter */}
|
||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.type = el}>
|
||||
<button
|
||||
onClick={() => toggleDropdown('type')}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-white/80">
|
||||
{selectedTypes.length > 0 ? `${selectedTypes.length} Selected` : 'Format'}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<XMarkIcon
|
||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${selectedTypes.length === 0 ? 'opacity-40' : 'opacity-100'}`}
|
||||
onClick={clearTypes}
|
||||
/>
|
||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.type ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
{dropdowns.type && (
|
||||
<div onClick={(e) => keepDropdownOpen(e, 'type')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
||||
{types.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={(e) => handleMultiSelect(e, selectedTypes, type, onTypeChange, 'type')}
|
||||
className="w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors flex items-center justify-between"
|
||||
>
|
||||
<span className={`text-[13px] ${selectedTypes.includes(type) ? 'text-white font-medium' : 'text-white/70'}`}>
|
||||
{type}
|
||||
</span>
|
||||
{selectedTypes.includes(type) && (
|
||||
<CheckIcon className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.status = el}>
|
||||
<button
|
||||
onClick={() => toggleDropdown('status')}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-white/80">
|
||||
{selectedStatus.length > 0 ? `${selectedStatus.length} Selected` : 'Status'}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<XMarkIcon
|
||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${selectedStatus.length === 0 ? 'opacity-40' : 'opacity-100'}`}
|
||||
onClick={clearStatus}
|
||||
/>
|
||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.status ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
{dropdowns.status && (
|
||||
<div onClick={(e) => keepDropdownOpen(e, 'status')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
||||
{statuses.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={(e) => handleMultiSelect(e, selectedStatus, status, onStatusChange, 'status')}
|
||||
className="w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors flex items-center justify-between"
|
||||
>
|
||||
<span className={`text-[13px] ${selectedStatus.includes(status) ? 'text-white font-medium' : 'text-white/70'}`}>
|
||||
{status}
|
||||
</span>
|
||||
{selectedStatus.includes(status) && (
|
||||
<CheckIcon className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language Filter */}
|
||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.language = el}>
|
||||
<button
|
||||
onClick={() => toggleDropdown('language')}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-white/80">
|
||||
{selectedLanguages.length > 0 ? `${selectedLanguages.length} Selected` : 'Language'}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<XMarkIcon
|
||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${selectedLanguages.length === 0 ? 'opacity-40' : 'opacity-100'}`}
|
||||
onClick={clearLanguages}
|
||||
/>
|
||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.language ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
{dropdowns.language && (
|
||||
<div onClick={(e) => keepDropdownOpen(e, 'language')} className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
||||
{languages.map((language) => (
|
||||
<button
|
||||
key={language}
|
||||
onClick={(e) => handleMultiSelect(e, selectedLanguages, language, onLanguageChange, 'language')}
|
||||
className="w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors flex items-center justify-between"
|
||||
>
|
||||
<span className={`text-[13px] ${selectedLanguages.includes(language) ? 'text-white font-medium' : 'text-white/70'}`}>
|
||||
{language}
|
||||
</span>
|
||||
{selectedLanguages.includes(language) && (
|
||||
<CheckIcon className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Filter */}
|
||||
<div className="relative flex-1 min-w-[160px]" ref={el => dropdownRefs.current.sort = el}>
|
||||
<button
|
||||
onClick={() => toggleDropdown('sort')}
|
||||
className="flex items-center justify-between w-full px-4 py-2 rounded-lg bg-[#141414] hover:bg-[#1a1a1a] active:bg-[#1f1f1f] border border-white/[0.04] group transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-medium text-white/80">
|
||||
{getSortDisplayText()}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<XMarkIcon
|
||||
className={`w-3.5 h-3.5 text-white/60 mr-1 hover:text-white ${sortOrder === 'default' ? 'opacity-40' : 'opacity-100'}`}
|
||||
onClick={clearSort}
|
||||
/>
|
||||
<ChevronDownIcon className={`w-3.5 h-3.5 text-white/60 transition-transform ${dropdowns.sort ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
{dropdowns.sort && (
|
||||
<div className="absolute z-50 w-full mt-2 py-1 bg-[#141414] rounded-lg border border-white/[0.04] shadow-xl">
|
||||
<button
|
||||
onClick={(e) => handleSortSelect(e, 'default')}
|
||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
||||
sortOrder === 'default' ? 'text-white font-medium' : 'text-white/70'
|
||||
}`}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleSortSelect(e, 'title-asc')}
|
||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
||||
sortOrder === 'title-asc' ? 'text-white font-medium' : 'text-white/70'
|
||||
}`}
|
||||
>
|
||||
Title (A-Z)
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleSortSelect(e, 'title-desc')}
|
||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
||||
sortOrder === 'title-desc' ? 'text-white font-medium' : 'text-white/70'
|
||||
}`}
|
||||
>
|
||||
Title (Z-A)
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleSortSelect(e, 'year-desc')}
|
||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
||||
sortOrder === 'year-desc' ? 'text-white font-medium' : 'text-white/70'
|
||||
}`}
|
||||
>
|
||||
Newest First
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleSortSelect(e, 'year-asc')}
|
||||
className={`w-full px-4 py-1.5 text-left hover:bg-white/[0.03] transition-colors ${
|
||||
sortOrder === 'year-asc' ? 'text-white font-medium' : 'text-white/70'
|
||||
}`}
|
||||
>
|
||||
Oldest First
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear All Button - Always visible */}
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
className={`flex items-center justify-center gap-1 px-4 py-2 rounded-lg bg-[#1a1a1a] hover:bg-[#2a2a2a] border border-white/[0.04] transition-colors ${!isAnyFilterActive() ? 'opacity-50' : 'opacity-100'}`}
|
||||
>
|
||||
<XMarkIcon className="w-3.5 h-3.5 text-white/80" />
|
||||
<span className="text-[13px] font-medium text-white/80">Clear All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import AnimeCard from './AnimeCard';
|
||||
|
||||
export default function AnimeRow({ title, animeList }) {
|
||||
const scrollContainerRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const [showLeftButton, setShowLeftButton] = useState(false);
|
||||
const [showRightButton, setShowRightButton] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animeList || animeList.length <= 7) {
|
||||
setShowRightButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowRightButton(true);
|
||||
|
||||
const checkScroll = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
||||
setShowLeftButton(scrollLeft > 0);
|
||||
setShowRightButton(scrollLeft + clientWidth < scrollWidth - 10);
|
||||
};
|
||||
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
scrollContainer.addEventListener('scroll', checkScroll);
|
||||
|
||||
// Initial check
|
||||
checkScroll();
|
||||
|
||||
return () => {
|
||||
if (scrollContainer) {
|
||||
scrollContainer.removeEventListener('scroll', checkScroll);
|
||||
}
|
||||
};
|
||||
}, [animeList]);
|
||||
|
||||
const scroll = (direction) => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
// Calculate single card width based on viewport
|
||||
const isMobile = window.innerWidth < 640; // sm breakpoint in Tailwind
|
||||
const cardsPerRow = isMobile ? 3 : 7;
|
||||
const singleCardWidth = container.clientWidth / cardsPerRow;
|
||||
|
||||
if (direction === 'left') {
|
||||
container.scrollBy({ left: -singleCardWidth, behavior: 'smooth' });
|
||||
} else {
|
||||
container.scrollBy({ left: singleCardWidth, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
if (!animeList || animeList.length === 0) return null;
|
||||
|
||||
// Create groups of cards for pagination - 3 for mobile, 7 for larger screens
|
||||
const cardGroups = [];
|
||||
const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||
const groupSize = isMobileView ? 3 : 7;
|
||||
|
||||
for (let i = 0; i < animeList.length; i += groupSize) {
|
||||
cardGroups.push(animeList.slice(i, i + groupSize));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-semibold text-white">{title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{showLeftButton && (
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/70 flex items-center justify-center text-white hover:bg-black shadow-lg -ml-5"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-x-auto hide-scrollbar scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex snap-x snap-mandatory"
|
||||
>
|
||||
{cardGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
className="grid grid-cols-3 sm:grid-cols-7 gap-3 snap-start snap-always min-w-full px-1"
|
||||
>
|
||||
{group.map((anime, index) => (
|
||||
<div key={index}>
|
||||
<AnimeCard anime={anime} isRecent={true} />
|
||||
</div>
|
||||
))}
|
||||
{/* Add empty placeholders if needed to ensure slots are filled */}
|
||||
{Array.from({ length: (typeof window !== 'undefined' && window.innerWidth < 640) ?
|
||||
Math.max(0, 3 - group.length) :
|
||||
Math.max(0, 7 - group.length) }).map((_, index) => (
|
||||
<div key={`empty-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRightButton && (
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/70 flex items-center justify-center text-white hover:bg-black shadow-lg -mr-5"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import AnimeCard from './AnimeCard';
|
||||
import Link from 'next/link';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'topAiring', label: 'TOP AIRING' },
|
||||
{ id: 'popular', label: 'POPULAR' },
|
||||
{ id: 'latestCompleted', label: 'LATEST COMPLETED' }
|
||||
];
|
||||
|
||||
export default function AnimeTabs({ topAiring = [], popular = [], latestCompleted = [] }) {
|
||||
const [activeTab, setActiveTab] = useState('topAiring');
|
||||
|
||||
const getActiveList = () => {
|
||||
switch (activeTab) {
|
||||
case 'topAiring':
|
||||
return topAiring;
|
||||
case 'popular':
|
||||
return popular;
|
||||
case 'latestCompleted':
|
||||
return latestCompleted;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getViewAllLink = () => {
|
||||
switch (activeTab) {
|
||||
case 'topAiring':
|
||||
return '/top-airing';
|
||||
case 'popular':
|
||||
return '/most-popular';
|
||||
case 'latestCompleted':
|
||||
return '/latest-completed';
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
{/* Tabs Navigation */}
|
||||
<div className="flex items-center mb-6 border-b border-[var(--border)] overflow-x-auto scrollbar-none">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-3 sm:px-6 py-3 text-xs sm:text-sm font-medium transition-colors relative whitespace-nowrap flex-shrink-0 ${
|
||||
activeTab === tab.id
|
||||
? 'text-white'
|
||||
: 'text-[var(--text-muted)] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-[var(--primary)]"></div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<Link
|
||||
href={getViewAllLink()}
|
||||
className="text-[var(--text-muted)] hover:text-white text-xs sm:text-sm transition-colors flex items-center ml-auto px-3 sm:px-6 py-3 whitespace-nowrap flex-shrink-0"
|
||||
prefetch={false}
|
||||
>
|
||||
<span>View All</span>
|
||||
<svg className="ml-1 w-3 h-3 sm:w-4 sm:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Anime Grid */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{getActiveList().slice(0, 18).map((anime, index) => (
|
||||
<AnimeCard
|
||||
key={anime.id + '-' + index}
|
||||
anime={anime}
|
||||
isRecent={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
|
||||
export default function EpisodeList({ episodes, currentEpisode, onEpisodeClick, isDub = false }) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isGridView, setIsGridView] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeEpisodeId, setActiveEpisodeId] = useState(null);
|
||||
const episodesPerPage = 100;
|
||||
|
||||
// Update active episode when currentEpisode changes
|
||||
useEffect(() => {
|
||||
if (currentEpisode?.id) {
|
||||
setActiveEpisodeId(currentEpisode.id);
|
||||
}
|
||||
}, [currentEpisode]);
|
||||
|
||||
// Sync with URL to identify current episode
|
||||
useEffect(() => {
|
||||
const checkCurrentEpisode = () => {
|
||||
const path = window.location.pathname;
|
||||
const match = path.match(/\/watch\/(.+)$/);
|
||||
if (match) {
|
||||
const urlEpisodeId = match[1];
|
||||
setActiveEpisodeId(urlEpisodeId);
|
||||
|
||||
// Find the episode and update page
|
||||
const episode = episodes.find(ep => ep.id === urlEpisodeId);
|
||||
|
||||
if (episode) {
|
||||
const pageNumber = Math.ceil(episode.number / episodesPerPage);
|
||||
setCurrentPage(pageNumber);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check initially
|
||||
checkCurrentEpisode();
|
||||
|
||||
// Set up listener for URL changes using the History API
|
||||
const handleUrlChange = () => {
|
||||
checkCurrentEpisode();
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handleUrlChange);
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handleUrlChange);
|
||||
};
|
||||
}, [episodes, episodesPerPage]);
|
||||
|
||||
const filteredEpisodes = useMemo(() => {
|
||||
if (!searchQuery) return episodes;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return episodes.filter(episode =>
|
||||
episode.number.toString().includes(query) ||
|
||||
(episode.title && episode.title.toLowerCase().includes(query))
|
||||
);
|
||||
}, [episodes, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filteredEpisodes.length / episodesPerPage);
|
||||
const indexOfLastEpisode = currentPage * episodesPerPage;
|
||||
const indexOfFirstEpisode = indexOfLastEpisode - episodesPerPage;
|
||||
const currentEpisodes = filteredEpisodes.slice(indexOfFirstEpisode, indexOfLastEpisode);
|
||||
|
||||
const getPageRange = (pageNum) => {
|
||||
const start = (pageNum - 1) * episodesPerPage + 1;
|
||||
const end = Math.min(pageNum * episodesPerPage, filteredEpisodes.length);
|
||||
return `${start}-${end}`;
|
||||
};
|
||||
|
||||
const isCurrentEpisode = (episode) => {
|
||||
if (!episode || !episode.id || !activeEpisodeId) return false;
|
||||
return episode.id === activeEpisodeId;
|
||||
};
|
||||
|
||||
const handleEpisodeSelect = (episode, e) => {
|
||||
e.preventDefault();
|
||||
if (onEpisodeClick && episode.id) {
|
||||
// Use the episode ID directly as it's already in the correct format from the API
|
||||
console.log(`[EpisodeList] Selected episode: ${episode.number}, ID: ${episode.id}`);
|
||||
onEpisodeClick(episode.id);
|
||||
setActiveEpisodeId(episode.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll active episode into view when page changes or active episode changes
|
||||
useEffect(() => {
|
||||
if (activeEpisodeId) {
|
||||
setTimeout(() => {
|
||||
const activeElement = document.querySelector(`[data-episode-id="${activeEpisodeId}"]`);
|
||||
if (activeElement) {
|
||||
activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [activeEpisodeId, currentPage]);
|
||||
|
||||
return (
|
||||
<div className="bg-[#1a1a1a] rounded-xl shadow-2xl overflow-hidden h-[calc(90vh-6rem)]">
|
||||
{/* Header */}
|
||||
<div className="bg-[#242424] p-3 border-b border-gray-800 sticky top-0 z-40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-grow max-w-lg">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search episodes by name or number..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full bg-[#2a2a2a] text-white text-sm rounded-lg px-4 py-1.5 pl-9 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] placeholder-gray-500"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={currentPage}
|
||||
onChange={(e) => setCurrentPage(Number(e.target.value))}
|
||||
className="bg-[#2a2a2a] text-white text-sm rounded-lg px-2 py-1.5 border border-gray-700 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] min-w-[90px]"
|
||||
>
|
||||
{[...Array(totalPages)].map((_, index) => (
|
||||
<option key={index + 1} value={index + 1}>
|
||||
{getPageRange(index + 1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setIsGridView(!isGridView)}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white transition-colors bg-[#2a2a2a] hover:bg-[#333333]"
|
||||
title={isGridView ? "Switch to List View" : "Switch to Grid View"}
|
||||
>
|
||||
{isGridView ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Episodes Container */}
|
||||
<div className="overflow-y-auto h-[calc(100%-4rem)] scroll-smooth" id="episodes-container">
|
||||
<div className="p-4">
|
||||
{isGridView ? (
|
||||
// Grid View
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{currentEpisodes.map((episode) => (
|
||||
<button
|
||||
key={episode.number}
|
||||
data-episode-id={episode.id}
|
||||
onClick={(e) => handleEpisodeSelect(episode, e)}
|
||||
className={`group relative ${
|
||||
isCurrentEpisode(episode)
|
||||
? 'bg-[#2a2a2a] ring-2 ring-white z-30'
|
||||
: 'bg-[#2a2a2a] hover:bg-[#333333]'
|
||||
} rounded-lg transition-all duration-300 ease-out transform hover:scale-[1.02] hover:z-10`}
|
||||
>
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<div className={`flex items-center justify-center text-white p-1.5 ${
|
||||
isCurrentEpisode(episode) ? 'text-base font-bold' : 'text-sm font-medium'
|
||||
}`}>
|
||||
<span>{episode.number}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentEpisode(episode) && (
|
||||
<div className="absolute -top-1 -right-1 bg-white rounded-full p-0.5">
|
||||
<svg className="w-3 h-3 text-[#2a2a2a]" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// List View
|
||||
<div className="flex flex-col gap-1">
|
||||
{currentEpisodes.map((episode) => (
|
||||
<button
|
||||
key={episode.number}
|
||||
data-episode-id={episode.id}
|
||||
onClick={(e) => handleEpisodeSelect(episode, e)}
|
||||
className={`group flex items-center gap-3 py-2 px-3 rounded-lg transition-all duration-300 w-full text-left ${
|
||||
isCurrentEpisode(episode)
|
||||
? 'bg-[#2a2a2a] ring-2 ring-white z-30'
|
||||
: 'bg-[#2a2a2a] hover:bg-[#333333]'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex-shrink-0 w-8 h-8 rounded-lg ${
|
||||
isCurrentEpisode(episode)
|
||||
? 'bg-black/20'
|
||||
: 'bg-black/20'
|
||||
} flex items-center justify-center`}>
|
||||
<span className={`${
|
||||
isCurrentEpisode(episode)
|
||||
? 'text-base font-bold'
|
||||
: 'text-sm font-medium'
|
||||
} text-white`}>{episode.number}</span>
|
||||
</div>
|
||||
<div className="flex-grow min-w-0">
|
||||
<div className="text-sm text-white font-medium truncate">
|
||||
{episode.title || `Episode ${episode.number}`}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentEpisode(episode) && (
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { fetchGenres } from '@/lib/api';
|
||||
|
||||
export default function GenreBar() {
|
||||
const [genres, setGenres] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showLeftButton, setShowLeftButton] = useState(true); // Always show left button initially
|
||||
const [showRightButton, setShowRightButton] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const scrollContainerRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const [visibleGenres, setVisibleGenres] = useState(14);
|
||||
|
||||
// Function to capitalize first letter
|
||||
const capitalizeFirstLetter = (string) => {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
};
|
||||
|
||||
// Predefined genres exactly as specified - wrapped in useMemo to prevent recreation on every render
|
||||
const defaultGenres = useMemo(() => [
|
||||
"Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy",
|
||||
"Horror", "Mahou Shoujo", "Mecha", "Music", "Mystery", "Psychological",
|
||||
"Romance", "Sci-Fi", "Slice of Life", "Sports", "Supernatural", "Thriller"
|
||||
], []);
|
||||
|
||||
// Handle long names on mobile
|
||||
const getMobileGenreName = (genre) => {
|
||||
// Abbreviate long genre names for mobile view
|
||||
switch(genre) {
|
||||
case "Psychological": return "Psycho";
|
||||
case "Mahou Shoujo": return "Mahou";
|
||||
case "Supernatural": return "Super";
|
||||
case "Slice of Life": return "SoL";
|
||||
default: return genre;
|
||||
}
|
||||
};
|
||||
|
||||
// Detect mobile devices
|
||||
useEffect(() => {
|
||||
const checkIfMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
checkIfMobile();
|
||||
window.addEventListener('resize', checkIfMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkIfMobile);
|
||||
}, []);
|
||||
|
||||
// Calculate the number of genres that fit in the container
|
||||
useEffect(() => {
|
||||
const calculateVisibleGenres = () => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const containerWidth = container.offsetWidth;
|
||||
// Approximate width of each genre button
|
||||
const genreButtonWidth = isMobile ? 72 : 88; // Slightly larger on mobile to fit text
|
||||
const visibleCount = Math.floor((containerWidth - 80) / genreButtonWidth);
|
||||
// Minimum genres visible (smaller minimum on mobile)
|
||||
setVisibleGenres(Math.max(visibleCount, isMobile ? 4 : 8));
|
||||
}
|
||||
};
|
||||
|
||||
calculateVisibleGenres();
|
||||
window.addEventListener('resize', calculateVisibleGenres);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', calculateVisibleGenres);
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
// Force scroll position slightly to the right initially
|
||||
// to ensure there are genres on both sides for scrolling
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollLeft = 40; // Start slightly scrolled
|
||||
// Trigger scroll event to update button states
|
||||
scrollContainerRef.current.dispatchEvent(new Event('scroll'));
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setGenres(defaultGenres);
|
||||
setIsLoading(false);
|
||||
}, [defaultGenres]);
|
||||
|
||||
// Check scroll position to determine button visibility
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
||||
|
||||
// Show left button if not at the start
|
||||
setShowLeftButton(scrollLeft > 5);
|
||||
|
||||
// Show right button if not at the end
|
||||
setShowRightButton(scrollLeft < scrollWidth - clientWidth - 5);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer) {
|
||||
scrollContainer.addEventListener('scroll', handleScroll);
|
||||
// Initial check
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scroll left/right functions
|
||||
const scrollLeft = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const scrollAmount = isMobile ? -80 : -200;
|
||||
scrollContainerRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const scrollRight = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const scrollAmount = isMobile ? 80 : 200;
|
||||
scrollContainerRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
// Mobile-specific styles
|
||||
const mobileButtonStyle = {
|
||||
padding: '0.15rem 0.5rem',
|
||||
fontSize: '0.65rem',
|
||||
height: '1.5rem',
|
||||
minWidth: '4rem',
|
||||
maxWidth: '5.5rem',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<div className="flex space-x-2 md:space-x-4 py-2 animate-pulse justify-between px-4 md:px-8">
|
||||
{[...Array(isMobile ? 5 : visibleGenres)].map((_, i) => (
|
||||
<div key={i} className="h-6 md:h-7 bg-[#1f1f1f] rounded-md flex-1 max-w-[100px] min-w-[60px] md:min-w-[80px]"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
{/* Left fade effect */}
|
||||
<div className="absolute left-0 top-0 h-full w-6 md:w-16 z-10 pointer-events-none"
|
||||
style={{ background: 'linear-gradient(to right, var(--background) 30%, transparent 100%)' }}>
|
||||
</div>
|
||||
|
||||
{/* Left scroll button - only visible when not at the leftmost position */}
|
||||
{showLeftButton && (
|
||||
<button
|
||||
onClick={scrollLeft}
|
||||
className="absolute left-0 top-1/2 transform -translate-y-1/2 z-20 bg-[var(--background)] bg-opacity-40 backdrop-blur-sm rounded-full p-0.5 md:p-1 shadow-lg transition-opacity"
|
||||
aria-label="Scroll left"
|
||||
style={isMobile ? { left: '2px', padding: '2px' } : {}}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 md:w-5 md:h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable genre container */}
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex py-1.5 md:py-2 overflow-x-auto scrollbar-hide px-5 md:px-8"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
display: 'grid',
|
||||
gridAutoFlow: 'column',
|
||||
gridAutoColumns: `minmax(${Math.floor(100 / (isMobile ? 4.5 : visibleGenres))}%, ${Math.floor(100 / (isMobile ? 3 : visibleGenres - 2))}%)`,
|
||||
gap: isMobile ? '6px' : '8px',
|
||||
scrollSnapType: isMobile ? 'x mandatory' : 'none',
|
||||
WebkitOverflowScrolling: 'touch' // For smoother scrolling on iOS
|
||||
}}
|
||||
>
|
||||
{genres.map((genre) => (
|
||||
<Link
|
||||
key={genre}
|
||||
href={`/search?genre=${genre.toLowerCase()}`}
|
||||
className="bg-[#1f1f1f] text-white rounded-md hover:bg-white/20 transition-colors text-xs font-medium flex items-center justify-center h-6 md:h-7 px-1 md:px-2 scroll-snap-align-start"
|
||||
style={isMobile ? mobileButtonStyle : {}}
|
||||
title={genre} // Add tooltip showing full name
|
||||
>
|
||||
{isMobile ? getMobileGenreName(genre) : genre}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right fade effect */}
|
||||
<div className="absolute right-0 top-0 h-full w-6 md:w-16 z-10 pointer-events-none"
|
||||
style={{ background: 'linear-gradient(to left, var(--background) 30%, transparent 100%)' }}>
|
||||
</div>
|
||||
|
||||
{/* Right scroll button - only visible when not at the rightmost position */}
|
||||
{showRightButton && (
|
||||
<button
|
||||
onClick={scrollRight}
|
||||
className="absolute right-0 top-1/2 transform -translate-y-1/2 z-20 bg-[var(--background)] bg-opacity-40 backdrop-blur-sm rounded-full p-0.5 md:p-1 shadow-lg transition-opacity"
|
||||
aria-label="Scroll right"
|
||||
style={isMobile ? { right: '2px', padding: '2px' } : {}}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 md:w-5 md:h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchGenres } from '@/lib/api';
|
||||
|
||||
export default function GenreList() {
|
||||
const [genres, setGenres] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadGenres() {
|
||||
try {
|
||||
const genreData = await fetchGenres();
|
||||
setGenres(genreData || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching genres:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadGenres();
|
||||
}, []);
|
||||
|
||||
// Predefined popular genres if API doesn't return them
|
||||
const defaultGenres = [
|
||||
"Action", "Adventure", "Comedy", "Drama", "Fantasy",
|
||||
"Horror", "Mystery", "Romance", "Sci-Fi", "Slice of Life",
|
||||
"Supernatural", "Thriller", "Isekai", "Mecha", "Sports"
|
||||
];
|
||||
|
||||
// Use fetched genres or fallback to default genres
|
||||
const displayGenres = genres.length > 0 ? genres : defaultGenres;
|
||||
|
||||
// Show only first 12 genres if not showing all
|
||||
const visibleGenres = showAll ? displayGenres : displayGenres.slice(0, 12);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<div className="p-4 border-b border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold text-white">Genres</h2>
|
||||
</div>
|
||||
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 gap-3 animate-pulse">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="h-10 bg-[var(--border)] rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<div className="p-4 border-b border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold text-white">Genres</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-4 grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{visibleGenres.map((genre) => (
|
||||
<Link
|
||||
key={genre}
|
||||
href={`/search?genre=${genre.toLowerCase()}`}
|
||||
className="bg-[var(--background)] hover:bg-white/10 text-white text-sm text-center py-2 px-3 rounded transition-colors truncate"
|
||||
>
|
||||
{genre}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{displayGenres.length > 12 && (
|
||||
<div className="p-4 pt-0 text-center">
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="text-white/70 hover:text-white text-sm transition-colors inline-flex items-center"
|
||||
>
|
||||
<span>{showAll ? 'Show Less' : 'Show All'}</span>
|
||||
<svg
|
||||
className={`ml-1 w-4 h-4 transition-transform duration-300 ${showAll ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/Loader/AnimeInfo.loader.jsx
Normal file
58
src/components/Loader/AnimeInfo.loader.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Skeleton } from "@/src/components/ui/Skeleton/Skeleton";
|
||||
import CategoryCardLoader from "./CategoryCard.loader";
|
||||
import SidecardLoader from "./Sidecard.loader";
|
||||
|
||||
const SkeletonItems = ({ count, className }) => (
|
||||
[...Array(count)].map((_, index) => <Skeleton key={index} className={className} />)
|
||||
);
|
||||
|
||||
function AnimeInfoLoader() {
|
||||
return (
|
||||
<>
|
||||
<div className="relative grid grid-cols-[minmax(0,75%),minmax(0,25%)] h-fit w-full overflow-hidden mt-[64px] max-[1200px]:flex max-[1200px]:flex-col max-md:mt-[50px]">
|
||||
<Skeleton className="absolute inset-0 w-full h-full blur-lg z-[-900] bg-gray-500" animation={false} />
|
||||
<div className="flex items-start z-10 px-14 py-[70px] bg-[#252434] bg-opacity-70 gap-x-8 max-[1024px]:px-6 max-[1024px]:py-10 max-[1024px]:gap-x-4 max-[575px]:flex-col max-[575px]:items-center max-[575px]:justify-center">
|
||||
<Skeleton className="w-[200px] h-[270px] rounded-none" />
|
||||
<div className="flex flex-col ml-4 gap-y-5 w-full max-[575px]:items-center max-[575px]:justify-center max-[575px]:mt-0">
|
||||
<ul className="flex gap-x-2 items-center w-fit max-[1200px]:hidden">
|
||||
<SkeletonItems count={3} className="w-[40px] h-[15px] rounded-xl " />
|
||||
</ul>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<SkeletonItems count={2} className="w-[50%] h-[20px] rounded-xl " />
|
||||
</div>
|
||||
<div className="flex gap-x-[3px]">
|
||||
<SkeletonItems count={6} className="w-[30px] h-[20px] rounded-sm" />
|
||||
</div>
|
||||
<Skeleton className="w-[150px] h-[40px] rounded-3xl mt-4" />
|
||||
<div className="flex flex-col gap-y-2 mt-5 max-[575px]:hidden">
|
||||
<SkeletonItems count={3} className="w-[80%] h-[15px] rounded-3xl " />
|
||||
</div>
|
||||
<div className="flex gap-x-4 items-center mt-4">
|
||||
<Skeleton className="w-[60px] h-[60px] rounded-full bg-gray-500 max-[575px]:hidden" />
|
||||
<div className="flex flex-col w-fit gap-y-2">
|
||||
<SkeletonItems count={2} className="w-[150px] h-[10px] rounded-xl " />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#4c4b57c3] flex items-center px-8 max-[1200px]:py-10 max-[1200px]:bg-[#363544e0] max-[575px]:p-4">
|
||||
<div className="w-full flex flex-col h-fit gap-y-4">
|
||||
<SkeletonItems count={6} className="w-full h-[15px] rounded-xl" />
|
||||
<div className="flex gap-x-4 py-2 mt-4">
|
||||
<Skeleton className="w-[50px] h-[20px] " />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<SkeletonItems count={4} className="w-[30px] h-[20px] rounded-sm bg-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
<SkeletonItems count={2} className="w-[90%] h-[15px] rounded-xl " />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col px-4">
|
||||
<CategoryCardLoader className="mt-[60px]"/>
|
||||
<SidecardLoader className="mt-[60px]" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default AnimeInfoLoader;
|
||||
26
src/components/Loader/AtoZ.loader.jsx
Normal file
26
src/components/Loader/AtoZ.loader.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton";
|
||||
import CategoryCardLoader from "./CategoryCard.loader";
|
||||
|
||||
const SkeletonItems = ({ count, className }) => (
|
||||
[...Array(count)].map((_, index) => <Skeleton key={index} className={className} />)
|
||||
);
|
||||
|
||||
function AtoZLoader() {
|
||||
return (
|
||||
<div className="max-w-[1260px] mx-auto px-[15px] flex flex-col mt-[64px] max-md:mt-[50px]">
|
||||
<ul className="flex gap-x-4 mt-[50px] items-center w-fit max-[1200px]:hidden">
|
||||
<Skeleton className="w-[50px] h-[15px]" />
|
||||
<Skeleton className="w-[70px] h-[15px]" />
|
||||
</ul>
|
||||
<div className="flex flex-col gap-y-5 mt-6">
|
||||
<Skeleton className="w-[200px] h-[15px]" />
|
||||
<div className='flex gap-x-[7px] flex-wrap justify-start gap-y-2 max-md:justify-start'>
|
||||
<SkeletonItems count={20} className="w-[40px] h-[20px] rounded-sm"/>
|
||||
</div>
|
||||
</div>
|
||||
<CategoryCardLoader showLabelSkeleton={false}/>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
export default AtoZLoader;
|
||||
27
src/components/Loader/Cart.loader.jsx
Normal file
27
src/components/Loader/Cart.loader.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton"
|
||||
const SkeletonItems = ({ count, className }) => (
|
||||
[...Array(count)].map((_, index) => <Skeleton key={index} className={className} />)
|
||||
);
|
||||
function CartLoader() {
|
||||
return (
|
||||
<div className="flex flex-col w-1/4 space-y-7 max-[1200px]:w-full">
|
||||
<Skeleton className="w-[200px] h-[20px]" />
|
||||
<div className='w-full space-y-4 flex flex-col '>
|
||||
{[...Array(5)].map((item, index) => (
|
||||
<div key={index} style={{ borderBottom: "1px solid rgba(255, 255, 255, .075)" }} className="flex pb-4 items-center">
|
||||
<Skeleton className="w-[60px] h-[75px] rounded-none" />
|
||||
<div className='flex flex-col ml-4 space-y-2 w-full'>
|
||||
<Skeleton className='w-[90%] h-[15px]' />
|
||||
<div className='flex items-center w-fit space-x-1'>
|
||||
<SkeletonItems count={3} className="w-[30px] h-[15px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='w-[100px] h-[30px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CartLoader
|
||||
23
src/components/Loader/Category.loader.jsx
Normal file
23
src/components/Loader/Category.loader.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton"
|
||||
import CategoryCardLoader from "./CategoryCard.loader"
|
||||
import SidecardLoader from "./Sidecard.loader"
|
||||
|
||||
function CategoryLoader() {
|
||||
return (
|
||||
<div className='w-full flex flex-col gap-y-4 mt-[64px] max-md:mt-[50px]'>
|
||||
<div className="flex gap-x-4 items-center p-5 mt-4">
|
||||
<Skeleton className="w-[60px] h-[60px] rounded-full bg-gray-500 max-[575px]:hidden" />
|
||||
<div className="flex flex-col w-fit gap-y-2">
|
||||
<Skeleton className="w-[150px] h-[10px] rounded-xl " />
|
||||
<Skeleton className="w-[150px] h-[10px] rounded-xl " />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex max-[1200px]:flex-col max-[1200px]:gap-y-10">
|
||||
<CategoryCardLoader className={"mt-[0px]"} />
|
||||
<SidecardLoader />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoryLoader
|
||||
35
src/components/Loader/CategoryCard.loader.jsx
Normal file
35
src/components/Loader/CategoryCard.loader.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton";
|
||||
|
||||
function CategoryCardLoader({ className, showLabelSkeleton = true }) {
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
{showLabelSkeleton && (
|
||||
<Skeleton className="w-[200px] h-[20px] max-[320px]:w-[70px]" />
|
||||
)}
|
||||
<div className="grid grid-cols-6 gap-x-3 gap-y-8 mt-6 max-[1400px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-2">
|
||||
{[...Array(12)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col"
|
||||
style={{ height: "fit-content" }}
|
||||
>
|
||||
<div className="w-full relative">
|
||||
<Skeleton className="w-full h-[250px] object-cover max-[1200px]:h-[35vw] max-[758px]:h-[45vw] max-[478px]:h-[60vw] rounded-none" />
|
||||
<div className="absolute left-2 bottom-4 flex items-center justify-center w-fit space-x-1 z-20 max-[320px]:w-[80%] max-[320px]:left-0">
|
||||
<Skeleton className="w-[50px] h-[15px] bg-gray-600 max-[320px]:w-[40%]" />
|
||||
<Skeleton className="w-[50px] h-[15px] bg-gray-600 max-[320px]:w-[40%]" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="mt-1 w-[90%] h-[15px]" />
|
||||
<div className="flex items-center gap-x-2 w-full mt-2">
|
||||
<Skeleton className="w-[40%] h-[12px]" />
|
||||
<Skeleton className="w-[40%] h-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CategoryCardLoader;
|
||||
32
src/components/Loader/Home.loader.jsx
Normal file
32
src/components/Loader/Home.loader.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import CartLoader from "./Cart.loader";
|
||||
import CategoryCardLoader from "./CategoryCard.loader";
|
||||
import SidecardLoader from "./Sidecard.loader";
|
||||
import SpotlightLoader from "./Spotlight.loader";
|
||||
import Trendingloader from "./Trending.loader";
|
||||
function HomeLoader() {
|
||||
return (
|
||||
<div className="px-4 w-full h-full max-[1200px]:px-0 bg-[#3a395100]">
|
||||
<SpotlightLoader />
|
||||
<Trendingloader />
|
||||
<div className="mt-16 flex gap-6 max-[1200px]:px-4 max-[1200px]:grid max-[1200px]:grid-cols-2 max-[1200px]:mt-12 max-[1200px]:gap-y-10 max-[680px]:grid-cols-1">
|
||||
<CartLoader />
|
||||
<CartLoader />
|
||||
<CartLoader />
|
||||
<CartLoader />
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col max-[1200px]:px-4">
|
||||
<div>
|
||||
<CategoryCardLoader className="mt-[60px]" />
|
||||
<CategoryCardLoader className="mt-[60px]" />
|
||||
<CategoryCardLoader className="mt-[60px]" />
|
||||
</div>
|
||||
<div className="w-full mt-[60px]">
|
||||
<SidecardLoader />
|
||||
<SidecardLoader />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomeLoader;
|
||||
24
src/components/Loader/Loader.jsx
Normal file
24
src/components/Loader/Loader.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import AnimeInfoLoader from "./AnimeInfo.loader";
|
||||
import HomeLoader from "./Home.loader";
|
||||
import CategoryLoader from "./Category.loader";
|
||||
import AtoZLoader from "./AtoZ.loader";
|
||||
import ProducerLoader from "./Producer.loader";
|
||||
|
||||
const Loader = ({ type }) => {
|
||||
switch (type) {
|
||||
case "home":
|
||||
return <HomeLoader />;
|
||||
case "animeInfo":
|
||||
return <AnimeInfoLoader />;
|
||||
case "category":
|
||||
return <CategoryLoader />;
|
||||
case "producer":
|
||||
return <ProducerLoader />;
|
||||
case "AtoZ":
|
||||
return <AtoZLoader />;
|
||||
default:
|
||||
return <div className="loading-skeleton default-skeleton"></div>;
|
||||
}
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
15
src/components/Loader/Producer.loader.jsx
Normal file
15
src/components/Loader/Producer.loader.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import CategoryCardLoader from "./CategoryCard.loader";
|
||||
import SidecardLoader from "./Sidecard.loader";
|
||||
|
||||
function ProducerLoader() {
|
||||
return (
|
||||
<div className="w-full mt-[100px] flex flex-col gap-y-4 max-md:mt-[50px]">
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex max-[1200px]:flex-col max-[1200px]:gap-y-10">
|
||||
<CategoryCardLoader className={"mt-[0px]"} />
|
||||
<SidecardLoader />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProducerLoader;
|
||||
26
src/components/Loader/Sidecard.loader.jsx
Normal file
26
src/components/Loader/Sidecard.loader.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton";
|
||||
function SidecardLoader({ className }) {
|
||||
return (
|
||||
<div className={`flex flex-col space-y-6 ${className}`}>
|
||||
<Skeleton className='w-[200px] h-[15px]' />
|
||||
<div className='flex flex-col space-y-4 bg-[#2B2A3C] p-4 pt-8 w-full'>
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<div key={index} className='flex items-center gap-x-4'>
|
||||
<div className="flex pb-4 relative container items-center">
|
||||
<Skeleton className="w-[60px] h-[75px] rounded-md" />
|
||||
<div className='flex flex-col ml-4 space-y-2 w-[60%]'>
|
||||
<Skeleton className='w-[90%] h-[15px]' />
|
||||
<div className='flex flex-wrap items-center space-x-1'>
|
||||
<Skeleton className="w-[30%] h-[15px]" />
|
||||
<Skeleton className="w-[30%] h-[15px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidecardLoader;
|
||||
34
src/components/Loader/Spotlight.loader.jsx
Normal file
34
src/components/Loader/Spotlight.loader.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton"
|
||||
const SkeletonItems = ({ count, className }) => (
|
||||
[...Array(count)].map((_, index) => <Skeleton key={index} className={className} />)
|
||||
);
|
||||
function SpotlightLoader() {
|
||||
return (
|
||||
<section className="w-full h-[600px] max-[1390px]:h-[530px] max-[1300px]:h-[500px] max-md:h-[420px] relative">
|
||||
<div className="absolute flex flex-col left-0 bottom-[100px] w-[55%] p-4 z-10 max-[1390px]:w-[45%] max-[1390px]:bottom-[10px] max-[1300px]:w-[600px] max-[1120px]:w-[60%] max-md:w-[90%] max-[300px]:w-full">
|
||||
<Skeleton className="w-[400px] h-[20px] max-md:w-[180px]" />
|
||||
<Skeleton className="w-[70%] h-[20px] mt-6 text-left max-[1300px]:mt-4 max-sm:w-[80%] max-[320px]:w-full " />
|
||||
<div className="flex h-fit justify-center items-center w-fit space-x-5 mt-8 max-[1300px]:mt-6 max-md:hidden">
|
||||
<SkeletonItems count={2} className="w-[30px] h-[15px]" />
|
||||
<div className="flex space-x-3 w-fit">
|
||||
<Skeleton className="w-[80px] h-[15px]" />
|
||||
<div className='flex space-x-[1px] rounded-r-[5px] rounded-l-[5px] w-fit py-[1px] overflow-hidden'>
|
||||
<SkeletonItems count={2} className="w-[30px] h-[15px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 max-[1300px]:w-[500px] flex flex-col gap-y-2 max-[1120px]:w-[90%] max-md:hidden">
|
||||
<Skeleton className="w-full h-[13px]" />
|
||||
<Skeleton className="w-[85%] h-[13px]" />
|
||||
<Skeleton className="w-[70%] h-[13px]" />
|
||||
</div>
|
||||
<div className='flex gap-x-5 mt-10 max-md:mt-6 max-sm:w-full max-[320px]:flex-col max-[320px]:space-y-3'>
|
||||
<Skeleton className="w-[170px] h-[40px] max-[575px]:w-[120px] max-[575px]:h-[30px]" />
|
||||
<Skeleton className="w-[150px] h-[40px] max-[575px]:w-[120px] max-[575px]:h-[30px]" />
|
||||
</div>
|
||||
</div>
|
||||
</section >
|
||||
)
|
||||
}
|
||||
|
||||
export default SpotlightLoader
|
||||
34
src/components/Loader/Trending.loader.jsx
Normal file
34
src/components/Loader/Trending.loader.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton";
|
||||
|
||||
function TrendingLoader() {
|
||||
const [count, setCount] = useState(() => window.innerWidth < 720 ? 3 : window.innerWidth < 1300 ? 4 : 6);
|
||||
useEffect(() => {
|
||||
const updateCount = () => {
|
||||
if (window.innerWidth < 720) {
|
||||
setCount(3);
|
||||
} else if (window.innerWidth < 1300) {
|
||||
setCount(4);
|
||||
} else {
|
||||
setCount(6);
|
||||
}
|
||||
};
|
||||
updateCount();
|
||||
window.addEventListener("resize", updateCount);
|
||||
return () => window.removeEventListener("resize", updateCount);
|
||||
}, []);
|
||||
return (
|
||||
<div className="flex flex-col w-full mt-10 max-[1200px]:px-4">
|
||||
<Skeleton className="w-[150px] h-[20px] max-[400px]:w-[100px]" />
|
||||
<div className="w-full h-[250px] overflow-hidden flex mt-6 justify-around max-[1300px]:h-fit">
|
||||
{[...Array(count)].map((_, index) => (
|
||||
<div key={index}>
|
||||
<Skeleton className="w-[200px] h-full rounded-none max-[1300px]:w-[22vw] max-[1300px]:h-[30vw] max-[720px]:w-[25vw] max-[720px]:h-[35vw]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrendingLoader;
|
||||
21
src/components/Loader/VoiceActorlist.loader.jsx
Normal file
21
src/components/Loader/VoiceActorlist.loader.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from "../ui/Skeleton/Skeleton"
|
||||
|
||||
function VoiceActorlistLoader() {
|
||||
return (
|
||||
<div className="w-full h-fit grid grid-cols-2 gap-4 overflow-y-hidden max-sm:gap-2 max-md:h-[400px] max-md:flex max-md:flex-col">
|
||||
{[...Array(10)].map((_, index) => (
|
||||
<div key={index} className="h-[80px] p-4 rounded-md bg-[#444445]">
|
||||
<div className="flex h-full items-center gap-x-2">
|
||||
<Skeleton className="w-[45px] h-[45px] rounded-full max-sm:w-[30px] max-sm:h-[30px]" />
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Skeleton className="h-[10px] w-[100px] rounded-md max-[300px]:w-[50px] max-[300px]:h-[8px]" />
|
||||
<Skeleton className="h-[10px] w-[70px] rounded-md max-[300px]:w-[20px] max-[300px]:h-[8px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceActorlistLoader
|
||||
@@ -1,628 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
fetchSearchSuggestions,
|
||||
fetchMostPopular,
|
||||
fetchTopAiring,
|
||||
fetchRecentEpisodes,
|
||||
fetchMostFavorite,
|
||||
fetchTopUpcoming
|
||||
} from '@/lib/api';
|
||||
|
||||
export default function Navbar() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchSuggestions, setSearchSuggestions] = useState([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRandomLoading, setIsRandomLoading] = useState(false);
|
||||
const suggestionRef = useRef(null);
|
||||
const searchInputRef = useRef(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Track scroll position
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (window.scrollY > 10) {
|
||||
setIsScrolled(true);
|
||||
} else {
|
||||
setIsScrolled(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Update suggestions when search query changes
|
||||
useEffect(() => {
|
||||
const updateSuggestions = async () => {
|
||||
// Only search if we have at least 2 characters
|
||||
if (searchQuery.trim().length >= 2) {
|
||||
setIsLoading(true);
|
||||
setShowSuggestions(true); // Always show the suggestions container when typing
|
||||
|
||||
try {
|
||||
console.log(`Fetching suggestions for: ${searchQuery}`);
|
||||
const apiSuggestions = await fetchSearchSuggestions(searchQuery);
|
||||
console.log('API returned:', apiSuggestions);
|
||||
|
||||
if (Array.isArray(apiSuggestions) && apiSuggestions.length > 0) {
|
||||
// Take top 5 results
|
||||
setSearchSuggestions(apiSuggestions.slice(0, 5));
|
||||
} else {
|
||||
// Create a generic suggestion based on the search query
|
||||
setSearchSuggestions([{
|
||||
id: searchQuery.toLowerCase().replace(/\s+/g, '-'),
|
||||
title: `Search for "${searchQuery}"`,
|
||||
type: "SEARCH",
|
||||
image: null
|
||||
}]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in search component:', error);
|
||||
// Create a generic suggestion
|
||||
setSearchSuggestions([{
|
||||
id: searchQuery.toLowerCase().replace(/\s+/g, '-'),
|
||||
title: `Search for "${searchQuery}"`,
|
||||
type: "SEARCH",
|
||||
image: null
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setSearchSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounceTimer = setTimeout(() => {
|
||||
updateSuggestions();
|
||||
}, 300); // 300ms debounce time
|
||||
|
||||
return () => clearTimeout(debounceTimer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
suggestionRef.current &&
|
||||
!suggestionRef.current.contains(event.target) &&
|
||||
!searchInputRef.current?.contains(event.target)
|
||||
) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
// Navigate to search page regardless if search is empty or not
|
||||
router.push(searchQuery.trim() ? `/search?q=${encodeURIComponent(searchQuery)}` : '/search');
|
||||
setSearchQuery('');
|
||||
setShowSuggestions(false);
|
||||
setIsMenuOpen(false);
|
||||
};
|
||||
|
||||
// Handle suggestion item click
|
||||
const handleAnimeClick = (id) => {
|
||||
router.push(`/anime/${id}`);
|
||||
setSearchQuery('');
|
||||
setShowSuggestions(false);
|
||||
setIsMenuOpen(false);
|
||||
};
|
||||
|
||||
// Handle search by query click
|
||||
const handleSearchByQueryClick = () => {
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
setSearchQuery('');
|
||||
setShowSuggestions(false);
|
||||
setIsMenuOpen(false);
|
||||
};
|
||||
|
||||
// Helper function to render clear button
|
||||
const renderClearButton = () => {
|
||||
if (searchQuery) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Function to handle input focus
|
||||
const handleInputFocus = () => {
|
||||
if (searchQuery.trim().length >= 2) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle random anime click
|
||||
const handleRandomAnimeClick = async () => {
|
||||
setIsRandomLoading(true);
|
||||
try {
|
||||
// Randomly select a category to fetch from
|
||||
const categories = [
|
||||
{ name: 'Most Popular', fetch: fetchMostPopular },
|
||||
{ name: 'Top Airing', fetch: fetchTopAiring },
|
||||
{ name: 'Recent Episodes', fetch: fetchRecentEpisodes },
|
||||
{ name: 'Most Favorite', fetch: fetchMostFavorite },
|
||||
{ name: 'Top Upcoming', fetch: fetchTopUpcoming }
|
||||
];
|
||||
|
||||
// Select a random category
|
||||
const randomCategoryIndex = Math.floor(Math.random() * categories.length);
|
||||
const selectedCategory = categories[randomCategoryIndex];
|
||||
|
||||
console.log(`Fetching random anime from: ${selectedCategory.name}`);
|
||||
|
||||
// Fetch anime from the selected category - use a random page number to get more variety
|
||||
const randomPage = Math.floor(Math.random() * 5) + 1; // Random page between 1-5
|
||||
const animeList = await selectedCategory.fetch(randomPage);
|
||||
|
||||
if (animeList && animeList.results && animeList.results.length > 0) {
|
||||
// Skip the first few results as they tend to be more popular
|
||||
const skipCount = Math.min(5, Math.floor(animeList.results.length / 3));
|
||||
let availableAnime = animeList.results.slice(skipCount);
|
||||
|
||||
if (availableAnime.length === 0) {
|
||||
// If we've filtered out everything, use the original list
|
||||
availableAnime = animeList.results;
|
||||
}
|
||||
|
||||
// Get a random index
|
||||
const randomAnimeIndex = Math.floor(Math.random() * availableAnime.length);
|
||||
|
||||
// Get the random anime ID
|
||||
const randomAnimeId = availableAnime[randomAnimeIndex].id;
|
||||
|
||||
console.log(`Selected random anime: ${availableAnime[randomAnimeIndex].title} (ID: ${randomAnimeId})`);
|
||||
|
||||
// Navigate to the anime page
|
||||
router.push(`/anime/${randomAnimeId}`);
|
||||
} else {
|
||||
console.error('No anime found to select randomly from');
|
||||
|
||||
// Fallback to most popular if the chosen category fails, but use a higher page number
|
||||
const fallbackPage = Math.floor(Math.random() * 5) + 2; // Pages 2-6 for more obscure options
|
||||
const fallbackList = await fetchMostPopular(fallbackPage);
|
||||
|
||||
if (fallbackList && fallbackList.results && fallbackList.results.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * fallbackList.results.length);
|
||||
const randomAnimeId = fallbackList.results[randomIndex].id;
|
||||
router.push(`/anime/${randomAnimeId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching random anime:', error);
|
||||
} finally {
|
||||
setIsRandomLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className={`fixed w-full z-20 transition-all duration-300 ${
|
||||
isScrolled
|
||||
? 'backdrop-blur-xl shadow-md bg-[#0a0a0a]/80'
|
||||
: 'bg-transparent'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between h-16 px-2 sm:px-4 md:px-[4rem]">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link href="/home" className="flex items-center" prefetch={false}>
|
||||
<Image src="/Logo.png" alt="JustAnime Logo" width={80} height={38} className="h-[38px] w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search Bar - Desktop */}
|
||||
<div className="hidden sm:flex flex-1 max-w-lg mx-auto">
|
||||
<form onSubmit={handleSearch} className="flex items-center w-full">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search Anime"
|
||||
className="bg-[#1a1a1a] text-white pl-10 pr-8 py-2 rounded-md focus:outline-none w-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={handleInputFocus}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{renderClearButton()}
|
||||
|
||||
{/* Search Suggestions Dropdown */}
|
||||
{showSuggestions && (
|
||||
<div
|
||||
ref={suggestionRef}
|
||||
className="absolute mt-2 w-full bg-[#121212] rounded-md shadow-lg z-30 border border-gray-700 overflow-hidden"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-400 flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
) : searchSuggestions.length > 0 ? (
|
||||
<>
|
||||
<ul className="list-none m-0 p-0 w-full">
|
||||
{searchSuggestions.map((suggestion, index) => (
|
||||
<li key={index}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suggestion.type === 'SEARCH') {
|
||||
handleSearchByQueryClick();
|
||||
} else {
|
||||
handleAnimeClick(suggestion.id);
|
||||
}
|
||||
}}
|
||||
className="block p-2.5 text-sm text-white hover:bg-gray-700 cursor-pointer border-b border-gray-700 last:border-0 transition-colors duration-150 bg-[#121212] active:bg-gray-600"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 w-10 h-14 bg-gray-800 overflow-hidden rounded">
|
||||
{suggestion.image ? (
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={suggestion.image}
|
||||
alt={suggestion.title}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-xs text-gray-500">No img</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{suggestion.title}</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs">
|
||||
{suggestion.year && (
|
||||
<span className="text-gray-400">{suggestion.year}</span>
|
||||
)}
|
||||
{suggestion.type && suggestion.type !== 'SEARCH' && (
|
||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-300">{suggestion.type}</span>
|
||||
)}
|
||||
{suggestion.type === 'SEARCH' && (
|
||||
<span className="bg-blue-900 px-2 py-0.5 rounded text-blue-200">Search</span>
|
||||
)}
|
||||
{suggestion.episodes && (
|
||||
<span className="flex items-center text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4v16M17 4v16M3 8h18M3 16h18" />
|
||||
</svg>
|
||||
{suggestion.episodes}
|
||||
</span>
|
||||
)}
|
||||
{suggestion.rating && (
|
||||
<span className="flex items-center text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1 text-yellow-500" fill="currentColor" viewBox="0 0 24 24" stroke="none">
|
||||
<path d="M12 2l2.4 7.4H22l-6 4.6 2.3 7-6.3-4.6L5.7 21l2.3-7-6-4.6h7.6z" />
|
||||
</svg>
|
||||
{suggestion.rating}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSearchByQueryClick();
|
||||
}}
|
||||
className="block p-2 border-t border-gray-700 bg-[#121212] hover:bg-gray-700 cursor-pointer transition-colors duration-150 active:bg-gray-600 text-center"
|
||||
>
|
||||
<div className="text-sm text-gray-300 hover:text-white py-2 flex items-center justify-center">
|
||||
<span>VIEW ALL</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#1a1a1a] text-white p-2 rounded-md transition-colors duration-200 focus:outline-none ml-2 h-[38px] w-[38px] flex items-center justify-center cursor-pointer"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Random Anime Button */}
|
||||
<div className="ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRandomAnimeClick}
|
||||
disabled={isRandomLoading}
|
||||
className="bg-[#1a1a1a] text-white p-2 rounded-md transition-colors duration-200 focus:outline-none h-[38px] w-[38px] flex items-center justify-center cursor-pointer"
|
||||
aria-label="Random Anime"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" className="bi bi-shuffle" viewBox="0 0 16 16">
|
||||
<path fillRule="evenodd" d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Login Button - Desktop */}
|
||||
<div className="hidden sm:block flex-shrink-0">
|
||||
<Link
|
||||
href="#"
|
||||
className="bg-white text-black px-4 py-2 rounded-md hover:bg-gray-200"
|
||||
prefetch={false}
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="sm:hidden flex items-center ml-2">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-[#1a1a1a] focus:outline-none"
|
||||
>
|
||||
<svg
|
||||
className={`${isMenuOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg
|
||||
className={`${isMenuOpen ? 'block' : 'hidden'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{isMenuOpen && (
|
||||
<div className="sm:hidden absolute top-16 inset-x-0 bg-[var(--card)] shadow-lg border-t border-[var(--border)] z-10">
|
||||
<div className="px-4 pt-4 pb-6 space-y-4">
|
||||
<div className="mb-4">
|
||||
<form onSubmit={handleSearch} className="flex items-center">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Anime"
|
||||
className="bg-[#1a1a1a] text-white pl-10 pr-8 py-2 rounded-md focus:outline-none w-full text-sm"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={handleInputFocus}
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Mobile Clear Button */}
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile Search Suggestions */}
|
||||
{showSuggestions && (
|
||||
<div
|
||||
className="absolute mt-2 w-full bg-[#121212] rounded-md shadow-lg z-30 border border-gray-700 overflow-hidden"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-400 flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Loading...
|
||||
</div>
|
||||
) : searchSuggestions.length > 0 ? (
|
||||
<>
|
||||
<ul className="list-none m-0 p-0 w-full">
|
||||
{searchSuggestions.map((suggestion, index) => (
|
||||
<li key={index}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suggestion.type === 'SEARCH') {
|
||||
handleSearchByQueryClick();
|
||||
} else {
|
||||
handleAnimeClick(suggestion.id);
|
||||
}
|
||||
}}
|
||||
className="block p-2.5 text-sm text-white hover:bg-gray-700 cursor-pointer border-b border-gray-700 last:border-0 transition-colors duration-150 bg-[#121212] active:bg-gray-600"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 w-10 h-14 bg-gray-800 overflow-hidden rounded">
|
||||
{suggestion.image ? (
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={suggestion.image}
|
||||
alt={suggestion.title}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-xs text-gray-500">No img</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{suggestion.title}</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs">
|
||||
{suggestion.year && (
|
||||
<span className="text-gray-400">{suggestion.year}</span>
|
||||
)}
|
||||
{suggestion.type && suggestion.type !== 'SEARCH' && (
|
||||
<span className="bg-gray-800 px-2 py-0.5 rounded text-gray-300">{suggestion.type}</span>
|
||||
)}
|
||||
{suggestion.type === 'SEARCH' && (
|
||||
<span className="bg-blue-900 px-2 py-0.5 rounded text-blue-200">Search</span>
|
||||
)}
|
||||
{suggestion.episodes && (
|
||||
<span className="flex items-center text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4v16M17 4v16M3 8h18M3 16h18" />
|
||||
</svg>
|
||||
{suggestion.episodes}
|
||||
</span>
|
||||
)}
|
||||
{suggestion.rating && (
|
||||
<span className="flex items-center text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 mr-1 text-yellow-500" fill="currentColor" viewBox="0 0 24 24" stroke="none">
|
||||
<path d="M12 2l2.4 7.4H22l-6 4.6 2.3 7-6.3-4.6L5.7 21l2.3-7-6-4.6h7.6z" />
|
||||
</svg>
|
||||
{suggestion.rating}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSearchByQueryClick();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className="block p-2 border-t border-gray-700 bg-[#121212] hover:bg-gray-700 cursor-pointer transition-colors duration-150 active:bg-gray-600 text-center"
|
||||
>
|
||||
<div className="text-sm text-gray-300 hover:text-white py-2 flex items-center justify-center">
|
||||
<span>VIEW ALL</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Button - Mobile */}
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#1a1a1a] text-white p-2 rounded-md transition-colors duration-200 focus:outline-none ml-2 h-[34px] w-[34px] flex items-center justify-center cursor-pointer"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Random Anime Button - Mobile */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRandomAnimeClick}
|
||||
disabled={isRandomLoading}
|
||||
className="bg-[#1a1a1a] text-white p-2 rounded-md transition-colors duration-200 focus:outline-none ml-2 h-[34px] w-[34px] flex items-center justify-center cursor-pointer"
|
||||
aria-label="Random Anime"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" className="bi bi-shuffle" viewBox="0 0 16 16">
|
||||
<path fillRule="evenodd" d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z"/>
|
||||
<path d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[var(--border)]">
|
||||
<Link
|
||||
href="#"
|
||||
className="block px-3 py-2 text-base font-medium text-white bg-[var(--primary)] hover:bg-opacity-90 rounded-md"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
prefetch={false}
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function SeasonCard({ season }) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
if (!season) return null;
|
||||
|
||||
const handleImageError = () => {
|
||||
console.log("Image error for:", season.name);
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
// Get image URL with fallback
|
||||
const imageSrc = imageError ? '/images/placeholder.png' : season.poster;
|
||||
|
||||
// Generate link
|
||||
const infoLink = `/anime/${season.id}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={infoLink}
|
||||
className="block w-full rounded-lg overflow-hidden transition-transform duration-300 hover:scale-[1.02] group"
|
||||
prefetch={false}
|
||||
>
|
||||
<div className={`relative aspect-[3/1.5] rounded-lg overflow-hidden bg-gray-900 shadow-lg ${season.isCurrent ? 'border-2 border-white' : ''}`}>
|
||||
{/* Background image with blur */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-black opacity-60 z-[2]"></div>
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={season.name || 'Season'}
|
||||
fill
|
||||
className="object-cover blur-[2px]"
|
||||
onError={handleImageError}
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
unoptimized={true}
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 p-3">
|
||||
<div className="text-center">
|
||||
<h3 className="text-white font-bold text-lg line-clamp-1">
|
||||
{season.title || season.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import SeasonCard from './SeasonCard';
|
||||
|
||||
export default function SeasonRow({ title, seasons }) {
|
||||
const scrollContainerRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const [showLeftButton, setShowLeftButton] = useState(false);
|
||||
const [showRightButton, setShowRightButton] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!seasons || seasons.length <= 7) {
|
||||
setShowRightButton(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowRightButton(true);
|
||||
|
||||
const checkScroll = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
||||
setShowLeftButton(scrollLeft > 0);
|
||||
setShowRightButton(scrollLeft + clientWidth < scrollWidth - 10);
|
||||
};
|
||||
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
scrollContainer.addEventListener('scroll', checkScroll);
|
||||
|
||||
// Initial check
|
||||
checkScroll();
|
||||
|
||||
return () => {
|
||||
if (scrollContainer) {
|
||||
scrollContainer.removeEventListener('scroll', checkScroll);
|
||||
}
|
||||
};
|
||||
}, [seasons]);
|
||||
|
||||
// Updated effect to handle mobile view arrows
|
||||
useEffect(() => {
|
||||
if (!seasons) return;
|
||||
|
||||
// Check if we're on mobile and have more than 3 seasons
|
||||
const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||
const showArrowsOnMobile = isMobileView && seasons.length > 3;
|
||||
|
||||
// On desktop, show arrows if more than 7 seasons
|
||||
const showArrowsOnDesktop = !isMobileView && seasons.length > 7;
|
||||
|
||||
if (showArrowsOnMobile || showArrowsOnDesktop) {
|
||||
setShowRightButton(true);
|
||||
} else {
|
||||
setShowRightButton(false);
|
||||
}
|
||||
|
||||
// Listen for resize events to update arrow visibility
|
||||
const handleResize = () => {
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const showArrows = isMobile ? seasons.length > 3 : seasons.length > 7;
|
||||
setShowRightButton(showArrows);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [seasons]);
|
||||
|
||||
const scroll = (direction) => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
// Calculate single card width based on viewport
|
||||
const isMobile = window.innerWidth < 640; // sm breakpoint in Tailwind
|
||||
const cardsPerRow = isMobile ? 3 : 7;
|
||||
const singleCardWidth = container.clientWidth / cardsPerRow;
|
||||
|
||||
if (direction === 'left') {
|
||||
container.scrollBy({ left: -singleCardWidth, behavior: 'smooth' });
|
||||
} else {
|
||||
container.scrollBy({ left: singleCardWidth, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
if (!seasons || seasons.length === 0) return null;
|
||||
|
||||
// Create groups of cards for pagination - 3 for mobile, 7 for larger screens
|
||||
const seasonGroups = [];
|
||||
const isMobileView = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||
const groupSize = isMobileView ? 3 : 7;
|
||||
|
||||
for (let i = 0; i < seasons.length; i += groupSize) {
|
||||
seasonGroups.push(seasons.slice(i, i + groupSize));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-semibold text-white">{title || 'Seasons'}</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{showLeftButton && (
|
||||
<button
|
||||
onClick={() => scroll('left')}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/70 flex items-center justify-center text-white hover:bg-black shadow-lg -ml-5"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="overflow-x-auto hide-scrollbar scroll-smooth"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex snap-x snap-mandatory"
|
||||
>
|
||||
{seasonGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={groupIndex}
|
||||
className="grid grid-cols-3 sm:grid-cols-7 gap-3 snap-start snap-always min-w-full px-1"
|
||||
>
|
||||
{group.map((season, index) => (
|
||||
<div key={index}>
|
||||
<SeasonCard season={season} />
|
||||
</div>
|
||||
))}
|
||||
{/* Add empty placeholders if needed to ensure slots are filled */}
|
||||
{Array.from({ length: (typeof window !== 'undefined' && window.innerWidth < 640) ?
|
||||
Math.max(0, 3 - group.length) :
|
||||
Math.max(0, 7 - group.length) }).map((_, index) => (
|
||||
<div key={`empty-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRightButton && (
|
||||
<button
|
||||
onClick={() => scroll('right')}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/70 flex items-center justify-center text-white hover:bg-black shadow-lg -mr-5"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Navbar from './Navbar';
|
||||
import Image from 'next/image';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="bg-[#0a0a0a] text-gray-400 py-6 border-t border-gray-800">
|
||||
<div className="px-2 sm:px-4 md:px-[4rem] mx-auto">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="flex flex-col items-center md:items-start md:flex-row gap-6 w-full md:w-auto">
|
||||
<div className="flex w-full justify-center items-center md:justify-start md:w-auto">
|
||||
<div className="flex-1 flex justify-end pr-2">
|
||||
<Image src="/Logo.png" alt="JustAnime Logo" width={96} height={32} className="h-8 w-auto" />
|
||||
</div>
|
||||
<div className="h-8 w-px bg-gray-700 md:hidden"></div>
|
||||
<div className="flex-1 flex items-center pl-2 md:hidden">
|
||||
<div className="flex items-center space-x-3">
|
||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 md:hidden">
|
||||
<a href="/terms" className="hover:text-white transition-colors">Terms & Privacy</a>
|
||||
<a href="/dmca" className="hover:text-white transition-colors">DMCA</a>
|
||||
<a href="/contacts" className="hover:text-white transition-colors">Contacts</a>
|
||||
</div>
|
||||
<p className="text-xs max-w-md text-center md:text-left">This website does not retain any files on its server. Rather, it solely provides links to media content hosted by third-party services.</p>
|
||||
<div className="flex items-center space-x-4 md:hidden mt-4 hidden">
|
||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<a href="/terms" className="hover:text-white transition-colors">Terms & Privacy</a>
|
||||
<a href="/dmca" className="hover:text-white transition-colors">DMCA</a>
|
||||
<a href="/contacts" className="hover:text-white transition-colors">Contacts</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className="text-white hover:text-gray-300 transition-colors">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SharedLayout({ children }) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="pt-16 flex-grow">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination, EffectFade } from 'swiper/modules';
|
||||
import { fetchAnimeEpisodes } from '@/lib/api';
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/navigation';
|
||||
import 'swiper/css/pagination';
|
||||
import 'swiper/css/effect-fade';
|
||||
|
||||
const SpotlightCarousel = ({ items = [] }) => {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [autoplay, setAutoplay] = useState(true);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [episodeIds, setEpisodeIds] = useState({});
|
||||
const [loadingItems, setLoadingItems] = useState({});
|
||||
const intervalRef = useRef(null);
|
||||
const progressIntervalRef = useRef(null);
|
||||
|
||||
// Handle hydration mismatch
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Fetch first episode IDs for all spotlight items
|
||||
useEffect(() => {
|
||||
const fetchEpisodeData = async () => {
|
||||
// Create a copy to track what we're loading
|
||||
const newLoadingItems = { ...loadingItems };
|
||||
const episodeData = { ...episodeIds };
|
||||
|
||||
for (const item of items) {
|
||||
// Skip if we already have the episode ID or if it's already loading
|
||||
if (item.id && !episodeData[item.id] && !newLoadingItems[item.id]) {
|
||||
newLoadingItems[item.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update loading state
|
||||
setLoadingItems(newLoadingItems);
|
||||
|
||||
// Process items that need to be loaded
|
||||
for (const item of items) {
|
||||
if (item.id && !episodeData[item.id] && newLoadingItems[item.id]) {
|
||||
try {
|
||||
console.log(`[SpotlightCarousel] Fetching episodes for anime: ${item.id}`);
|
||||
const response = await fetchAnimeEpisodes(item.id);
|
||||
console.log(`[SpotlightCarousel] Episodes response for ${item.name}:`, response);
|
||||
|
||||
if (response.episodes && response.episodes.length > 0) {
|
||||
// Check for episode ID in the expected format
|
||||
const firstEp = response.episodes[0];
|
||||
if (firstEp.id) {
|
||||
episodeData[item.id] = firstEp.id;
|
||||
console.log(`[SpotlightCarousel] Found episode ID (id) for ${item.name}: ${firstEp.id}`);
|
||||
} else if (firstEp.episodeId) {
|
||||
episodeData[item.id] = firstEp.episodeId;
|
||||
console.log(`[SpotlightCarousel] Found episode ID (episodeId) for ${item.name}: ${firstEp.episodeId}`);
|
||||
} else {
|
||||
// Create a fallback ID if neither id nor episodeId are available
|
||||
episodeData[item.id] = `${item.id}?ep=1`;
|
||||
console.log(`[SpotlightCarousel] Using fallback ID for ${item.name}: ${item.id}?ep=1`);
|
||||
}
|
||||
} else {
|
||||
// If no episodes, use a fallback
|
||||
episodeData[item.id] = `${item.id}?ep=1`;
|
||||
console.log(`[SpotlightCarousel] No episodes for ${item.name}, using fallback: ${item.id}?ep=1`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SpotlightCarousel] Error fetching episodes for ${item.id}:`, error);
|
||||
// Even on error, try to use fallback
|
||||
episodeData[item.id] = `${item.id}?ep=1`;
|
||||
} finally {
|
||||
// Mark as no longer loading
|
||||
newLoadingItems[item.id] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update states
|
||||
setEpisodeIds(episodeData);
|
||||
setLoadingItems(newLoadingItems);
|
||||
};
|
||||
|
||||
if (items && items.length > 0) {
|
||||
fetchEpisodeData();
|
||||
}
|
||||
|
||||
// Clean up function
|
||||
return () => {
|
||||
if (intervalRef.current) clearTimeout(intervalRef.current);
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
};
|
||||
}, [items, episodeIds, loadingItems]);
|
||||
|
||||
// Autoplay functionality
|
||||
useEffect(() => {
|
||||
if (autoplay && items.length > 1) {
|
||||
// Clear any existing intervals
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
|
||||
// Set up new intervals
|
||||
setProgress(0);
|
||||
progressIntervalRef.current = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
const newProgress = prev + 1;
|
||||
return newProgress <= 100 ? newProgress : prev;
|
||||
});
|
||||
}, 50); // Update every 50ms to get smooth progress
|
||||
|
||||
intervalRef.current = setTimeout(() => {
|
||||
setCurrentIndex(prevIndex => (prevIndex + 1) % items.length);
|
||||
setProgress(0);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearTimeout(intervalRef.current);
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
};
|
||||
}, [autoplay, currentIndex, items.length]);
|
||||
|
||||
const handleDotClick = (index) => {
|
||||
setCurrentIndex(index);
|
||||
setProgress(0);
|
||||
// Reset autoplay timer when manually changing slides
|
||||
if (intervalRef.current) clearTimeout(intervalRef.current);
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
if (autoplay) {
|
||||
intervalRef.current = setTimeout(() => {
|
||||
setCurrentIndex((index + 1) % items.length);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => setAutoplay(false);
|
||||
const handleMouseLeave = () => setAutoplay(true);
|
||||
|
||||
// If no items or not on client yet, show loading state
|
||||
if (!isClient || !items.length) {
|
||||
return (
|
||||
<div className="w-full h-[250px] md:h-[450px] bg-[var(--card)] rounded-xl animate-pulse flex items-center justify-center mb-6 md:mb-10">
|
||||
<div className="text-center">
|
||||
<div className="h-10 w-40 bg-[var(--border)] rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-60 bg-[var(--border)] rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentItem = items[currentIndex];
|
||||
|
||||
// Get the watch URL for the current item
|
||||
const watchUrl = episodeIds[currentItem.id]
|
||||
? `/watch/${episodeIds[currentItem.id]}`
|
||||
: `/anime/${currentItem.id}`; // Direct to anime info if no episode ID
|
||||
|
||||
return (
|
||||
<div className="w-full mb-6 md:mb-10 spotlight-carousel">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination, EffectFade]}
|
||||
slidesPerView={1}
|
||||
effect="fade"
|
||||
navigation
|
||||
pagination={{ clickable: true }}
|
||||
autoplay={{
|
||||
delay: 5000,
|
||||
disableOnInteraction: false,
|
||||
}}
|
||||
loop={true}
|
||||
className="rounded-xl overflow-hidden"
|
||||
onSlideChange={(swiper) => {
|
||||
setCurrentIndex(swiper.realIndex);
|
||||
setProgress(0);
|
||||
// Reset autoplay timer when manually changing slides
|
||||
if (intervalRef.current) clearTimeout(intervalRef.current);
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
if (autoplay) {
|
||||
intervalRef.current = setTimeout(() => {
|
||||
setCurrentIndex((swiper.realIndex + 1) % items.length);
|
||||
}, 5000);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{items.map((anime, index) => (
|
||||
<SwiperSlide key={`spotlight-${anime.id}-${index}`}>
|
||||
<div className="relative w-full h-[250px] md:h-[450px]">
|
||||
{/* Background Image */}
|
||||
<Image
|
||||
src={anime.banner || anime.poster || '/LandingPage.jpg'}
|
||||
alt={anime.name || 'Anime spotlight'}
|
||||
fill
|
||||
priority={index < 2}
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
{/* Gradient Overlay */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `
|
||||
linear-gradient(to right,
|
||||
rgba(10,10,10,0.9) 0%,
|
||||
rgba(10,10,10,0.6) 25%,
|
||||
rgba(10,10,10,0.3) 40%,
|
||||
rgba(10,10,10,0) 60%),
|
||||
linear-gradient(to top,
|
||||
rgba(10,10,10,0.95) 0%,
|
||||
rgba(10,10,10,0.7) 15%,
|
||||
rgba(10,10,10,0.3) 30%,
|
||||
rgba(10,10,10,0) 50%)
|
||||
`
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="absolute inset-0 flex flex-col justify-end p-3 pb-12 md:p-8">
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between">
|
||||
{/* Left Side Content */}
|
||||
<div className="max-w-2xl">
|
||||
{/* Metadata first - Minimal boxed design */}
|
||||
<div className="flex items-center mb-2 md:mb-3 text-xs md:text-xs space-x-1.5 md:space-x-1.5">
|
||||
{anime.otherInfo?.map((info, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block px-2 md:px-1.5 py-1 md:py-0.5 bg-white/10 text-white/80 rounded-sm"
|
||||
>
|
||||
{info}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{anime.episodes && (
|
||||
<>
|
||||
{anime.episodes.sub > 0 && (
|
||||
<span className="inline-block px-2 md:px-1.5 py-1 md:py-0.5 bg-white/10 text-white/80 rounded-sm">
|
||||
SUB {anime.episodes.sub}
|
||||
</span>
|
||||
)}
|
||||
{anime.episodes.dub > 0 && (
|
||||
<span className="inline-block px-2 md:px-1.5 py-1 md:py-0.5 bg-white/10 text-white/80 rounded-sm">
|
||||
DUB {anime.episodes.dub}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title second */}
|
||||
<h2 className="text-lg md:text-4xl font-bold mb-2 md:mb-2 line-clamp-2 md:line-clamp-none">
|
||||
{anime.name || 'Anime Title'}
|
||||
</h2>
|
||||
|
||||
{/* Japanese Title */}
|
||||
{anime.jname && (
|
||||
<h3 className="text-sm md:text-lg text-white/70 mb-2 line-clamp-1">
|
||||
{anime.jname}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Description third - hidden on mobile, shown on desktop with exactly 3 lines */}
|
||||
<p className="hidden md:block text-base line-clamp-3 text-white/90 max-h-[4.5rem] overflow-hidden">
|
||||
{anime.description || 'No description available.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons - Below title on mobile, right side on desktop */}
|
||||
<div className="flex items-center space-x-2 md:space-x-4 mt-1 md:mt-0 md:absolute md:bottom-8 md:right-8">
|
||||
{/* Watch button - Uses episodeIds[anime.id] if available, otherwise links to anime details */}
|
||||
<Link
|
||||
href={episodeIds[anime.id] ? `/watch/${episodeIds[anime.id]}` : `/anime/${anime.id}`}
|
||||
className="bg-white hover:bg-gray-200 text-[#0a0a0a] font-medium text-xs md:text-base px-3 md:px-6 py-1.5 md:py-2 rounded flex items-center space-x-1.5 md:space-x-2 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 md:h-5 md:w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>WATCH NOW</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/anime/${anime.id}`}
|
||||
className="text-white border border-white/30 hover:bg-white/10 text-xs md:text-base px-3 md:px-6 py-1.5 md:py-2 rounded flex items-center space-x-1.5 md:space-x-2 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 md:h-5 md:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>DETAILS</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<style jsx global>{`
|
||||
.spotlight-carousel .swiper-button-next,
|
||||
.spotlight-carousel .swiper-button-prev {
|
||||
color: white;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.spotlight-carousel .swiper-button-next,
|
||||
.spotlight-carousel .swiper-button-prev {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.spotlight-carousel .swiper-button-next:after,
|
||||
.spotlight-carousel .swiper-button-prev:after {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.spotlight-carousel .swiper-button-next:after,
|
||||
.spotlight-carousel .swiper-button-prev:after {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.spotlight-carousel .swiper-pagination {
|
||||
bottom: 12px !important;
|
||||
}
|
||||
|
||||
.spotlight-carousel .swiper-pagination-bullet {
|
||||
background: white;
|
||||
opacity: 0.5;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin: 0 3px !important;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.spotlight-carousel .swiper-pagination {
|
||||
bottom: 20px !important;
|
||||
}
|
||||
|
||||
.spotlight-carousel .swiper-pagination-bullet {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: 0 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.spotlight-carousel .swiper-pagination-bullet-active {
|
||||
background: white;
|
||||
opacity: 1;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotlightCarousel;
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function TopLists({ topToday = [], topWeek = [], topMonth = [] }) {
|
||||
const [activeTab, setActiveTab] = useState('today');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'today', label: 'Today', data: topToday },
|
||||
{ id: 'week', label: 'Week', data: topWeek },
|
||||
{ id: 'month', label: 'Month', data: topMonth },
|
||||
];
|
||||
|
||||
// Add custom scrollbar styles
|
||||
useEffect(() => {
|
||||
// Add custom styles for the toplists scrollbar
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.toplists-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.toplists-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--card);
|
||||
}
|
||||
.toplists-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Find the active tab data
|
||||
const activeTabData = tabs.find(tab => tab.id === activeTab)?.data || [];
|
||||
|
||||
return (
|
||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<div className="p-4 border-b border-[var(--border)] flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.783-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-white">Top 10 Anime</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="grid grid-cols-3 border-b border-[var(--border)]">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-3 transition-colors text-sm font-medium ${
|
||||
activeTab === tab.id
|
||||
? 'text-white bg-[var(--background)] border-b-2 border-[var(--border)]'
|
||||
: 'text-[var(--text-muted)] hover:bg-[var(--background)]'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List content */}
|
||||
<div className="p-4">
|
||||
{activeTabData.length === 0 ? (
|
||||
<div className="py-8 text-center text-[var(--text-muted)]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.783-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<p className="text-sm">No data available</p>
|
||||
<p className="text-xs mt-1 opacity-70">Check another tab or come back later</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 min-h-[375px] max-h-[490px] overflow-y-auto pr-1 toplists-scrollbar">
|
||||
{activeTabData.slice(0, 10).map((anime, index) => (
|
||||
<Link
|
||||
href={`/anime/${anime.id}`}
|
||||
key={anime.id}
|
||||
className="block p-3 rounded hover:bg-white/5 transition-colors border border-[var(--border)] bg-[var(--card)] relative overflow-hidden"
|
||||
>
|
||||
{/* Top rank highlight for top 3 */}
|
||||
{index < 3 && (
|
||||
<div className="absolute top-0 left-0 w-1 h-full opacity-70"
|
||||
style={{
|
||||
background: index === 0 ? 'linear-gradient(to bottom, #303030, #1a1a1a)' :
|
||||
index === 1 ? 'linear-gradient(to bottom, #282828, #181818)' :
|
||||
'linear-gradient(to bottom, #202020, #161616)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
{/* Rank number with monochrome styling */}
|
||||
<div className="flex-shrink-0 w-8 flex items-center justify-center mr-3">
|
||||
<span
|
||||
className={`flex items-center justify-center w-7 h-7 rounded-lg text-sm font-bold ${
|
||||
index === 0 ? 'bg-white/20 text-white' :
|
||||
index === 1 ? 'bg-white/15 text-white' :
|
||||
index === 2 ? 'bg-white/10 text-white' :
|
||||
'bg-[var(--background)] text-white/70 border border-[var(--border)]'
|
||||
}`}
|
||||
>
|
||||
{anime.rank || index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Anime thumbnail with subtle shadow */}
|
||||
<div className="flex-shrink-0 w-12 h-16 relative rounded overflow-hidden mr-3 shadow-md">
|
||||
<Image
|
||||
src={anime.poster || '/images/placeholder.png'}
|
||||
alt={anime.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title */}
|
||||
<div className="mb-1">
|
||||
<h3 className="text-sm font-medium text-white line-clamp-1">
|
||||
{anime.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Episodes if available */}
|
||||
{anime.episodes && (
|
||||
<div className="flex items-center mb-1">
|
||||
{anime.episodes.sub > 0 && (
|
||||
<span className="text-xs bg-[var(--background)] text-[var(--text-muted)] px-1.5 py-0.5 rounded">
|
||||
SUB {anime.episodes.sub}
|
||||
</span>
|
||||
)}
|
||||
{anime.episodes.dub > 0 && (
|
||||
<span className="text-xs bg-[var(--background)] text-[var(--text-muted)] px-1.5 py-0.5 rounded ml-1">
|
||||
DUB {anime.episodes.dub}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function TrendingList({ trendingAnime = [] }) {
|
||||
return (
|
||||
<div className="mb-10 bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<div className="p-4 border-b border-[var(--border)] flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold text-white">Trending Now</h2>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[375px] max-h-[490px] overflow-y-auto toplists-scrollbar">
|
||||
<div className="pt-3.5 space-y-2">
|
||||
{trendingAnime.slice(0, 10).map((anime, index) => (
|
||||
<Link
|
||||
href={`/anime/${anime.id}`}
|
||||
key={anime.id || index}
|
||||
className="block px-3.5 py-3 hover:bg-white/5 transition-colors relative overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Rank number */}
|
||||
<div className="flex items-center justify-center w-8 text-lg font-bold text-[var(--text-muted)]">
|
||||
#{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Anime image */}
|
||||
<div className="relative w-[45px] h-[60px] flex-shrink-0">
|
||||
<Image
|
||||
src={anime.image || '/placeholder.png'}
|
||||
alt={anime.title}
|
||||
className="rounded object-cover"
|
||||
fill
|
||||
sizes="45px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anime info */}
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-white line-clamp-2">
|
||||
{anime.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
133
src/components/banner/Banner.css
Normal file
133
src/components/banner/Banner.css
Normal file
@@ -0,0 +1,133 @@
|
||||
.spotlight {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spotlight-overlay {
|
||||
width: 100.1%;
|
||||
height: 100.1%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom:0;
|
||||
background: radial-gradient(
|
||||
circle at 130% center,
|
||||
rgba(32, 31, 49, 0) 50%,
|
||||
rgba(32, 31, 49, 0.5) 60%,
|
||||
rgba(32, 31, 49, 1) 80%,
|
||||
rgba(32, 31, 49, 1) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to left,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
);
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
@media only screen and (max-width: 1300px) {
|
||||
.spotlight-overlay {
|
||||
background: radial-gradient(
|
||||
circle at 130% center,
|
||||
rgba(32, 31, 49, 0) 50%,
|
||||
rgba(32, 31, 49, 0.5) 60%,
|
||||
rgba(32, 31, 49, 1) 80%,
|
||||
rgba(32, 31, 49, 1) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to left,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 50%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 1200px) {
|
||||
.spotlight-overlay {
|
||||
background: radial-gradient(
|
||||
circle at 100% center,
|
||||
rgba(32, 31, 49, 0) 50%,
|
||||
rgba(32, 31, 49, 0.5) 60%,
|
||||
rgba(32, 31, 49, 1) 95%,
|
||||
rgba(32, 31, 49, 1) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to left,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 70%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 900px) {
|
||||
.spotlight-overlay {
|
||||
background: radial-gradient(
|
||||
circle at 60% center,
|
||||
rgba(32, 31, 49, 0) 50%,
|
||||
rgba(32, 31, 49, 0.5) 85%,
|
||||
rgba(32, 31, 49, 1) 95%,
|
||||
rgba(32, 31, 49, 1) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 70%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to left,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 70%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to right,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 15%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
136
src/components/banner/Banner.jsx
Normal file
136
src/components/banner/Banner.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faPlay,
|
||||
faClosedCaptioning,
|
||||
faMicrophone,
|
||||
faCalendar,
|
||||
faClock,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FaChevronRight } from "react-icons/fa";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import "./Banner.css";
|
||||
|
||||
function Banner({ item, index }) {
|
||||
const { language } = useLanguage();
|
||||
return (
|
||||
<section className="spotlight w-full h-full">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="absolute right-0 object-cover h-full w-[80%] bg-auto max-[1200px]:w-full max-[1200px]:bottom-0"
|
||||
/>
|
||||
<div className="spotlight-overlay"></div>
|
||||
<div className="absolute flex flex-col left-0 bottom-[50px] w-[55%] p-4 z-10 max-[1390px]:w-[45%] max-[1390px]:bottom-[10px] max-[1300px]:w-[600px] max-[1120px]:w-[60%] max-md:w-[90%] max-[300px]:w-full">
|
||||
<p className="text-[#ffbade] font-semibold text-[20px] w-fit max-[1300px]:text-[15px]">
|
||||
#{index + 1} Spotlight
|
||||
</p>
|
||||
<h3 className="text-white line-clamp-2 text-5xl font-bold mt-6 text-left max-[1390px]:text-[45px] max-[1300px]:text-3xl max-[1300px]:mt-4 max-md:text-2xl max-md:mt-1 max-[575px]:text-[22px] max-sm:leading-6 max-sm:w-[80%] max-[320px]:w-full ">
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</h3>
|
||||
<div className="flex h-fit justify-center items-center w-fit space-x-5 mt-8 max-[1300px]:mt-6 max-md:hidden">
|
||||
{item.tvInfo && (
|
||||
<>
|
||||
{item.tvInfo.showType && (
|
||||
<div className="flex space-x-1 justify-center items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[8px] bg-white px-[4px] py-[3px] rounded-full"
|
||||
/>
|
||||
<p className="text-white text-[16px]">
|
||||
{item.tvInfo.showType}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.tvInfo.duration && (
|
||||
<div className="flex space-x-1 justify-center items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className="text-white text-[14px]"
|
||||
/>
|
||||
<p className="text-white text-[17px]">
|
||||
{item.tvInfo.duration}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.tvInfo.releaseDate && (
|
||||
<div className="flex space-x-1 justify-center items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faCalendar}
|
||||
className="text-white text-[14px]"
|
||||
/>
|
||||
<p className="text-white text-[16px]">
|
||||
{item.tvInfo.releaseDate}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-3 w-fit">
|
||||
{item.tvInfo.quality && (
|
||||
<div className="bg-[#ffbade] py-[1px] px-[6px] rounded-md w-fit text-[11px] font-bold h-fit">
|
||||
{item.tvInfo.quality}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex space-x-[1px] rounded-r-[5px] rounded-l-[5px] w-fit py-[1px] overflow-hidden">
|
||||
{item.tvInfo.episodeInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] px-[4px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.episodeInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.tvInfo.episodeInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] px-[4px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-semibold">
|
||||
{item.tvInfo.episodeInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-[17px] font-sm mt-6 text-left line-clamp-3 max-[1200px]:line-clamp-2 max-[1300px]:w-[500px] max-[1120px]:w-[90%] max-md:hidden">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="flex gap-x-5 mt-10 max-md:mt-6 max-sm:w-full max-[320px]:flex-col max-[320px]:space-y-3">
|
||||
<button className="flex justify-center items-center bg-[#ffbade] px-4 py-2 rounded-3xl gap-x-2 max-[320px]:w-fit ">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[8px] bg-[#000000] px-[6px] py-[6px] rounded-full text-[#ffbade] max-[320px]:text-[6px]"
|
||||
/>
|
||||
<Link
|
||||
to={`/watch/${item.id}`}
|
||||
className="max-[1000px]:text-[15px] font-semibold max-[320px]:text-[12px]"
|
||||
>
|
||||
Watch Now
|
||||
</Link>
|
||||
</button>
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="flex bg-[#3B3A52] justify-center items-center px-4 py-2 rounded-3xl gap-x-2 max-[320px]:w-fit max-[320px]:px-3"
|
||||
>
|
||||
<p className="text-white max-[1000px]:text-[15px] font-semibold max-[320px]:text-[12px]">
|
||||
Detail
|
||||
</p>
|
||||
<FaChevronRight className="text-white max-[320px]:text-[10px]" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default Banner;
|
||||
10
src/components/cart/Cart.css
Normal file
10
src/components/cart/Cart.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, .3);
|
||||
display: inline-block;
|
||||
}
|
||||
132
src/components/cart/Cart.jsx
Normal file
132
src/components/cart/Cart.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClosedCaptioning,
|
||||
faMicrophone,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FaChevronRight } from "react-icons/fa";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import "./Cart.css";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import useToolTipPosition from "@/src/hooks/useToolTipPosition";
|
||||
import Qtip from "../qtip/Qtip";
|
||||
|
||||
function Cart({ label, data, path }) {
|
||||
const { language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [hoveredItem, setHoveredItem] = useState(null);
|
||||
const [hoverTimeout, setHoverTimeout] = useState(null);
|
||||
const { tooltipPosition, tooltipHorizontalPosition, cardRefs } =
|
||||
useToolTipPosition(hoveredItem, data);
|
||||
|
||||
const handleImageEnter = (item, index) => {
|
||||
if (hoverTimeout) clearTimeout(hoverTimeout);
|
||||
setHoveredItem(item.id + index);
|
||||
};
|
||||
|
||||
const handleImageLeave = () => {
|
||||
setHoverTimeout(
|
||||
setTimeout(() => {
|
||||
setHoveredItem(null);
|
||||
}, 300)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-1/4 space-y-7 max-[1200px]:w-full">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade] max-md:text-xl">
|
||||
{label}
|
||||
</h1>
|
||||
<div className="w-full space-y-4 flex flex-col">
|
||||
{data &&
|
||||
data.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{ borderBottom: "1px solid rgba(255, 255, 255, .075)" }}
|
||||
className="flex pb-4 items-center relative"
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="flex-shrink-0 w-[60px] h-[75px] rounded-md object-cover cursor-pointer"
|
||||
onClick={() => navigate(`/watch/${item.id}`)}
|
||||
onMouseEnter={() => handleImageEnter(item, index)}
|
||||
onMouseLeave={handleImageLeave}
|
||||
/>
|
||||
|
||||
{hoveredItem === item.id + index && window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition}
|
||||
${
|
||||
tooltipHorizontalPosition === "left-1/2"
|
||||
? "translate-x-[-100px]"
|
||||
: "translate-x-[-200px]"
|
||||
}
|
||||
z-[100000] transform transition-all duration-300 ease-in-out
|
||||
${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (hoverTimeout) clearTimeout(hoverTimeout);
|
||||
}}
|
||||
onMouseLeave={handleImageLeave}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col ml-4 space-y-2 w-full">
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="w-full line-clamp-2 text-[1em] font-[500] hover:cursor-pointer hover:text-[#ffbade] transform transition-all ease-out max-[1200px]:text-[14px]"
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
<div className="flex items-center flex-wrap w-fit space-x-1">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[4px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">{item.tvInfo.sub}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[4px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">{item.tvInfo.dub}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center w-fit pl-1 gap-x-1">
|
||||
<div className="dot"></div>
|
||||
<p className="text-[14px] text-[#D2D2D3]">
|
||||
{item.tvInfo.showType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
to={`/${path}`}
|
||||
className="flex w-fit items-baseline rounded-3xl gap-x-2 group"
|
||||
>
|
||||
<p className="text-white text-[17px] h-fit leading-4 group-hover:text-[#ffbade] transform transition-all ease-out">
|
||||
View more
|
||||
</p>
|
||||
<FaChevronRight className="text-white text-[10px] group-hover:text-[#ffbade] transform transition-all ease-out" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Cart;
|
||||
27
src/components/categorycard/CategoryCard.css
Normal file
27
src/components/categorycard/CategoryCard.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.overlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(32, 31, 49, 1) 0%,
|
||||
rgba(32, 31, 49, 0) 20%,
|
||||
rgba(32, 31, 49, 0) 100%
|
||||
);
|
||||
|
||||
z-index: 50;
|
||||
}
|
||||
.dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
display: inline-block;
|
||||
}
|
||||
340
src/components/categorycard/CategoryCard.jsx
Normal file
340
src/components/categorycard/CategoryCard.jsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClosedCaptioning,
|
||||
faMicrophone,
|
||||
faPlay,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FaChevronRight } from "react-icons/fa";
|
||||
import "./CategoryCard.css";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Qtip from "../qtip/Qtip";
|
||||
import useToolTipPosition from "@/src/hooks/useToolTipPosition";
|
||||
|
||||
const CategoryCard = React.memo(
|
||||
({
|
||||
label,
|
||||
data,
|
||||
showViewMore = true,
|
||||
className,
|
||||
categoryPage = false,
|
||||
cardStyle,
|
||||
path,
|
||||
limit,
|
||||
}) => {
|
||||
const { language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [showPlay, setShowPlay] = useState(false);
|
||||
if (limit) {
|
||||
data = data.slice(0, limit);
|
||||
}
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState({
|
||||
firstRow: [],
|
||||
remainingItems: [],
|
||||
});
|
||||
|
||||
const getItemsToRender = useCallback(() => {
|
||||
if (categoryPage) {
|
||||
const firstRow =
|
||||
window.innerWidth > 758 && data.length > 4 ? data.slice(0, 4) : [];
|
||||
const remainingItems =
|
||||
window.innerWidth > 758 && data.length > 4
|
||||
? data.slice(4)
|
||||
: data.slice(0);
|
||||
return { firstRow, remainingItems };
|
||||
}
|
||||
return { firstRow: [], remainingItems: data.slice(0) };
|
||||
}, [categoryPage, data]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setItemsToRender(getItemsToRender());
|
||||
};
|
||||
const newItems = getItemsToRender();
|
||||
setItemsToRender((prev) => {
|
||||
if (
|
||||
JSON.stringify(prev.firstRow) !== JSON.stringify(newItems.firstRow) ||
|
||||
JSON.stringify(prev.remainingItems) !==
|
||||
JSON.stringify(newItems.remainingItems)
|
||||
) {
|
||||
return newItems;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [getItemsToRender]);
|
||||
const [hoveredItem, setHoveredItem] = useState(null);
|
||||
const [hoverTimeout, setHoverTimeout] = useState(null);
|
||||
const { tooltipPosition, tooltipHorizontalPosition, cardRefs } =
|
||||
useToolTipPosition(hoveredItem, data);
|
||||
const handleMouseEnter = (item, index) => {
|
||||
const timeout = setTimeout(() => {
|
||||
setHoveredItem(item.id + index);
|
||||
setShowPlay(true);
|
||||
}, 400);
|
||||
setHoverTimeout(timeout);
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(hoverTimeout);
|
||||
setHoveredItem(null);
|
||||
setShowPlay(false);
|
||||
};
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px] capitalize">
|
||||
{label}
|
||||
</h1>
|
||||
{showViewMore && (
|
||||
<Link
|
||||
to={`/${path}`}
|
||||
className="flex w-fit items-baseline h-fit rounded-3xl gap-x-1 group"
|
||||
>
|
||||
<p className="text-white text-[12px] font-semibold h-fit leading-0 group-hover:text-[#ffbade] transition-all ease-out">
|
||||
View more
|
||||
</p>
|
||||
<FaChevronRight className="text-white text-[10px] group-hover:text-[#ffbade] transition-all ease-out" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
{categoryPage && (
|
||||
<div
|
||||
className={`grid grid-cols-4 gap-x-3 gap-y-8 transition-all duration-300 ease-in-out ${
|
||||
categoryPage && itemsToRender.firstRow.length > 0
|
||||
? "mt-8 max-[758px]:hidden"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{itemsToRender.firstRow.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col transition-transform duration-300 ease-in-out"
|
||||
style={{ height: "fit-content" }}
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<div
|
||||
className="w-full relative group hover:cursor-pointer"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${
|
||||
path === "top-upcoming"
|
||||
? `/${item.id}`
|
||||
: `/watch/${item.id}`
|
||||
}`
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => handleMouseEnter(item, index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hoveredItem === item.id + index && showPlay && (
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[40px] text-white absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[10000]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overlay"></div>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className={`w-full h-[320px] object-cover max-[1200px]:h-[35vw] max-[758px]:h-[45vw] max-[478px]:h-[60vw] group-hover:blur-[7px] transform transition-all duration-300 ease-in-out ultra-wide:h-[400px] ${cardStyle}`}
|
||||
/>
|
||||
</div>
|
||||
{(item.tvInfo?.rating === "18+" ||
|
||||
item?.adultContent === true) && (
|
||||
<div className="text-white px-2 rounded-md bg-[#FF5700] absolute top-2 left-2 flex items-center justify-center text-[14px] font-bold">
|
||||
18+
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 bottom-3 flex items-center justify-center w-fit space-x-1 z-[100] max-[270px]:flex-col max-[270px]:gap-y-[3px]">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[2px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[2px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.eps && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#a9a6b16f] rounded-[2px] px-[8px] text-white py-[2px]">
|
||||
<p className="text-[12px] font-extrabold">
|
||||
{item.tvInfo.eps}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hoveredItem === item.id + index &&
|
||||
window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition} z-[100000] transform transition-all duration-300 ease-in-out ${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="text-white font-semibold mt-1 item-title hover:text-[#FFBADE] hover:cursor-pointer line-clamp-1"
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
{item.description && (
|
||||
<div className="line-clamp-3 text-[13px] font-extralight text-[#b1b0b0] max-[1200px]:hidden">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-x-2 w-full mt-2 overflow-hidden">
|
||||
<div className="text-gray-400 text-[14px] text-nowrap overflow-hidden text-ellipsis">
|
||||
{item.tvInfo.showType.split(" ").shift()}
|
||||
</div>
|
||||
<div className="dot"></div>
|
||||
<div className="text-gray-400 text-[14px] text-nowrap overflow-hidden text-ellipsis">
|
||||
{item.tvInfo?.duration === "m" ||
|
||||
item.tvInfo?.duration === "?" ||
|
||||
item.duration === "m" ||
|
||||
item.duration === "?"
|
||||
? "N/A"
|
||||
: item.tvInfo?.duration || item.duration || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-6 gap-x-3 gap-y-8 mt-6 transition-all duration-300 ease-in-out max-[1400px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-2">
|
||||
{itemsToRender.remainingItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col transition-transform duration-300 ease-in-out"
|
||||
style={{ height: "fit-content" }}
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<div
|
||||
className="w-full relative group hover:cursor-pointer"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`${
|
||||
path === "top-upcoming"
|
||||
? `/${item.id}`
|
||||
: `/watch/${item.id}`
|
||||
}`
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => handleMouseEnter(item, index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{hoveredItem === item.id + index && showPlay && (
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[40px] text-white absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[10000]"
|
||||
/>
|
||||
)}
|
||||
<div className="overlay"></div>
|
||||
<div className="overflow-hidden">
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className={`w-full h-[250px] object-cover max-[1200px]:h-[35vw] max-[758px]:h-[45vw] max-[478px]:h-[60vw] ${cardStyle} group-hover:blur-[7px] transform transition-all duration-300 ease-in-out `}
|
||||
/>
|
||||
</div>
|
||||
{(item.tvInfo?.rating === "18+" ||
|
||||
item?.adultContent === true) && (
|
||||
<div className="text-white px-2 rounded-md bg-[#FF5700] absolute top-2 left-2 flex items-center justify-center text-[14px] font-bold">
|
||||
18+
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 bottom-4 flex items-center justify-center w-fit space-x-1 z-[100] max-[270px]:flex-col max-[270px]:gap-y-[3px]">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[2px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[2px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hoveredItem === item.id + index &&
|
||||
window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition} z-[100000] transform transition-all duration-300 ease-in-out ${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="text-white font-semibold mt-1 item-title hover:text-[#FFBADE] hover:cursor-pointer line-clamp-1"
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-x-2 w-full mt-2 overflow-hidden">
|
||||
<div className="text-gray-400 text-[14px] text-nowrap overflow-hidden text-ellipsis">
|
||||
{item.tvInfo.showType.split(" ").shift()}
|
||||
</div>
|
||||
<div className="dot"></div>
|
||||
<div className="text-gray-400 text-[14px] text-nowrap overflow-hidden text-ellipsis">
|
||||
{item.tvInfo?.duration === "m" ||
|
||||
item.tvInfo?.duration === "?" ||
|
||||
item.duration === "m" ||
|
||||
item.duration === "?"
|
||||
? "N/A"
|
||||
: item.tvInfo?.duration || item.duration || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CategoryCard.displayName = "CategoryCard";
|
||||
|
||||
export default CategoryCard;
|
||||
132
src/components/continue/ContinueWatching.jsx
Normal file
132
src/components/continue/ContinueWatching.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Navigation } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import "swiper/css";
|
||||
import "swiper/css/pagination";
|
||||
import "swiper/css/navigation";
|
||||
import { FaHistory, FaChevronLeft, FaChevronRight } from "react-icons/fa";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const ContinueWatching = () => {
|
||||
const [watchList, setWatchList] = useState([]);
|
||||
const { language } = useLanguage();
|
||||
const swiperRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const data = JSON.parse(localStorage.getItem("continueWatching") || "[]");
|
||||
setWatchList(data);
|
||||
}, []);
|
||||
|
||||
// Memoize watchList to avoid unnecessary re-renders
|
||||
const memoizedWatchList = useMemo(() => watchList, [watchList]);
|
||||
|
||||
const removeFromWatchList = (episodeId) => {
|
||||
setWatchList((prevList) => {
|
||||
const updatedList = prevList.filter(
|
||||
(item) => item.episodeId !== episodeId
|
||||
);
|
||||
localStorage.setItem("continueWatching", JSON.stringify(updatedList));
|
||||
return updatedList;
|
||||
});
|
||||
};
|
||||
|
||||
if (memoizedWatchList.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6 max-[1200px]:px-6 max-md:px-0">
|
||||
<div className="flex items-center justify-between max-md:pl-4">
|
||||
<div className="flex items-center gap-x-2 justify-center">
|
||||
<FaHistory className="text-[#ffbade]" />
|
||||
<h1 className="text-[#ffbade] text-2xl font-bold max-[450px]:text-xl max-[450px]:mb-1 max-[350px]:text-lg">
|
||||
Continue Watching
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-2 pr-2 max-[350px]:hidden">
|
||||
<button className="btn-prev bg-gray-700 text-white p-3 rounded-full hover:bg-gray-500 transition max-[768px]:p-2">
|
||||
<FaChevronLeft className="text-xs" />
|
||||
</button>
|
||||
<button className="btn-next bg-gray-700 text-white p-3 rounded-full hover:bg-gray-500 transition max-[768px]:p-2">
|
||||
<FaChevronRight className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto overflow-hidden z-[1] mt-6 max-[450px]:mt-3">
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
className="w-full h-full"
|
||||
slidesPerView={3}
|
||||
spaceBetween={15}
|
||||
breakpoints={{
|
||||
640: { slidesPerView: 4, spaceBetween: 15 },
|
||||
768: { slidesPerView: 4, spaceBetween: 15 },
|
||||
1024: { slidesPerView: 5, spaceBetween: 15 },
|
||||
1300: { slidesPerView: 6, spaceBetween: 15 },
|
||||
1600: { slidesPerView: 7, spaceBetween: 20 },
|
||||
}}
|
||||
modules={[Navigation]}
|
||||
navigation={{
|
||||
nextEl: ".btn-next",
|
||||
prevEl: ".btn-prev",
|
||||
}}
|
||||
>
|
||||
{memoizedWatchList.map((item, index) => (
|
||||
<SwiperSlide
|
||||
key={index}
|
||||
className="text-center flex justify-center items-center"
|
||||
>
|
||||
<div className="w-full h-auto pb-[140%] relative inline-block overflow-hidden">
|
||||
<button
|
||||
className="absolute top-2 right-2 bg-black text-white px-3 py-2 bg-opacity-60 rounded-full text-sm z-10 font-extrabold hover:bg-white hover:text-black transition-all"
|
||||
onClick={() => removeFromWatchList(item.episodeId)}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to={`/watch/${item?.id}?ep=${item.episodeId}`}
|
||||
className="inline-block bg-[#2a2c31] absolute left-0 top-0 w-full h-full group"
|
||||
>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item?.poster}`}
|
||||
alt={item?.title}
|
||||
className="block w-full h-full object-cover transition-all duration-300 ease-in-out group-hover:blur-[4px]"
|
||||
title={item?.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="text-[50px] text-white absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[10000] max-[450px]:text-[36px]"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
{item?.adultContent === true && (
|
||||
<div className="text-white px-2 rounded-md bg-[#FF5700] absolute top-2 left-2 flex items-center justify-center text-[14px] font-bold">
|
||||
18+
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 flex flex-col gap-y-2 right-0 p-2 bg-gradient-to-t from-black via-black/80 to-transparent max-[450px]:gap-y-1">
|
||||
<p className="text-white text-md font-bold text-left truncate max-[450px]:text-sm">
|
||||
{language === "EN"
|
||||
? item?.title
|
||||
: item?.japanese_title}
|
||||
</p>
|
||||
<p className="text-gray-300 text-sm font-semibold text-left max-[450px]:text-[12px]">
|
||||
Episode {item.episodeNum}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContinueWatching;
|
||||
15
src/components/episodelist/Episodelist.css
Normal file
15
src/components/episodelist/Episodelist.css
Normal file
@@ -0,0 +1,15 @@
|
||||
@keyframes glow {
|
||||
0% {
|
||||
box-shadow: 0 0 7px #ffbade;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px #ffbade;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 7px #ffbade;
|
||||
}
|
||||
}
|
||||
|
||||
.glow-animation {
|
||||
animation: glow 1.5s infinite;
|
||||
}
|
||||
303
src/components/episodelist/Episodelist.jsx
Normal file
303
src/components/episodelist/Episodelist.jsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import {
|
||||
faAngleDown,
|
||||
faCirclePlay,
|
||||
faList,
|
||||
faCheck,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import "./Episodelist.css";
|
||||
|
||||
function Episodelist({
|
||||
episodes,
|
||||
onEpisodeClick,
|
||||
currentEpisode,
|
||||
totalEpisodes,
|
||||
}) {
|
||||
const [activeEpisodeId, setActiveEpisodeId] = useState(currentEpisode);
|
||||
const { language } = useLanguage();
|
||||
const listContainerRef = useRef(null);
|
||||
const activeEpisodeRef = useRef(null);
|
||||
const [showDropDown, setShowDropDown] = useState(false);
|
||||
const [selectedRange, setSelectedRange] = useState([1, 100]);
|
||||
const [activeRange, setActiveRange] = useState("1-100");
|
||||
const [episodeNum, setEpisodeNum] = useState(currentEpisode);
|
||||
const dropDownRef = useRef(null);
|
||||
const [searchedEpisode, setSearchedEpisode] = useState(null);
|
||||
|
||||
const scrollToActiveEpisode = () => {
|
||||
if (activeEpisodeRef.current && listContainerRef.current) {
|
||||
const container = listContainerRef.current;
|
||||
const activeEpisode = activeEpisodeRef.current;
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const containerHeight = container.clientHeight;
|
||||
const activeEpisodeTop = activeEpisode.getBoundingClientRect().top;
|
||||
const activeEpisodeHeight = activeEpisode.clientHeight;
|
||||
const offset = activeEpisodeTop - containerTop;
|
||||
container.scrollTop =
|
||||
container.scrollTop +
|
||||
offset -
|
||||
containerHeight / 2 +
|
||||
activeEpisodeHeight / 2;
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
setActiveEpisodeId(episodeNum);
|
||||
}, [episodeNum]);
|
||||
useEffect(() => {
|
||||
scrollToActiveEpisode();
|
||||
}, [activeEpisodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropDownRef.current && !dropDownRef.current.contains(event.target)) {
|
||||
setShowDropDown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleChange(e) {
|
||||
const value = e.target.value;
|
||||
if (value.trim() === "") {
|
||||
const newRange = findRangeForEpisode(1);
|
||||
setSelectedRange(newRange);
|
||||
setActiveRange(`${newRange[0]}-${newRange[1]}`);
|
||||
setSearchedEpisode(null);
|
||||
} else if (!value || isNaN(value)) {
|
||||
setSearchedEpisode(null);
|
||||
} else if (
|
||||
!isNaN(value) &&
|
||||
parseInt(value, 10) > totalEpisodes &&
|
||||
episodeNum !== null
|
||||
) {
|
||||
const newRange = findRangeForEpisode(episodeNum);
|
||||
setSelectedRange(newRange);
|
||||
setActiveRange(`${newRange[0]}-${newRange[1]}`);
|
||||
setSearchedEpisode(null);
|
||||
} else if (!isNaN(value) && value.trim() !== "") {
|
||||
const num = parseInt(value, 10);
|
||||
const foundEpisode = episodes.find((item) => item?.episode_no === num);
|
||||
if (foundEpisode) {
|
||||
const newRange = findRangeForEpisode(num);
|
||||
setSelectedRange(newRange);
|
||||
setActiveRange(`${newRange[0]}-${newRange[1]}`);
|
||||
setSearchedEpisode(foundEpisode?.id);
|
||||
}
|
||||
} else {
|
||||
setSearchedEpisode(null);
|
||||
}
|
||||
}
|
||||
|
||||
function findRangeForEpisode(episodeNumber) {
|
||||
const step = 100;
|
||||
const start = Math.floor((episodeNumber - 1) / step) * step + 1;
|
||||
const end = Math.min(start + step - 1, totalEpisodes);
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
function generateRangeOptions(totalEpisodes) {
|
||||
const ranges = [];
|
||||
const step = 100;
|
||||
|
||||
for (let i = 0; i < totalEpisodes; i += step) {
|
||||
const start = i + 1;
|
||||
const end = Math.min(i + step, totalEpisodes);
|
||||
ranges.push(`${start}-${end}`);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (currentEpisode && episodeNum) {
|
||||
if (episodeNum < selectedRange[0] || episodeNum > selectedRange[1]) {
|
||||
const newRange = findRangeForEpisode(episodeNum);
|
||||
setSelectedRange(newRange);
|
||||
setActiveRange(`${newRange[0]}-${newRange[1]}`);
|
||||
}
|
||||
}
|
||||
}, [currentEpisode, totalEpisodes, episodeNum]);
|
||||
|
||||
const handleRangeSelect = (range) => {
|
||||
const [start, end] = range.split("-").map(Number);
|
||||
setSelectedRange([start, end]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const activeEpisode = episodes.find(
|
||||
(item) => item?.id.match(/ep=(\d+)/)?.[1] === activeEpisodeId
|
||||
);
|
||||
if (activeEpisode) {
|
||||
setEpisodeNum(activeEpisode?.episode_no);
|
||||
}
|
||||
}, [activeEpisodeId, episodes]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col w-full h-full max-[1200px]:max-h-[500px]">
|
||||
<div className="sticky top-0 z-10 flex flex-col gap-y-[5px] justify-start px-3 py-4 bg-[#0D0D15]">
|
||||
<h1 className="text-[13px] font-bold">List of episodes:</h1>
|
||||
{totalEpisodes > 100 && (
|
||||
<div className="w-full flex gap-x-4 items-center max-[1200px]:justify-between">
|
||||
<div className="min-w-fit flex text-[13px]">
|
||||
<div
|
||||
onClick={() => setShowDropDown((prev) => !prev)}
|
||||
className="text-white w-fit mt-1 text-[13px] relative cursor-pointer bg-[#0D0D15] flex justify-center items-center"
|
||||
ref={dropDownRef}
|
||||
>
|
||||
<FontAwesomeIcon icon={faList} />
|
||||
<div className="w-fit flex justify-center items-center gap-x-2 ml-4">
|
||||
<p className="text-white text-[12px]">
|
||||
EPS: {selectedRange[0]}-{selectedRange[1]}
|
||||
</p>
|
||||
<FontAwesomeIcon
|
||||
icon={faAngleDown}
|
||||
className="mt-[2px] text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
{showDropDown && (
|
||||
<div className="absolute flex flex-col top-full mt-[10px] left-0 z-30 bg-white w-[150px] max-h-[200px] overflow-y-auto rounded-l-[8px]">
|
||||
{generateRangeOptions(totalEpisodes).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleRangeSelect(item);
|
||||
setActiveRange(item);
|
||||
}}
|
||||
className={`hover:bg-gray-200 cursor-pointer text-black ${
|
||||
item === activeRange ? "bg-[#EFF0F4]" : ""
|
||||
}`}
|
||||
>
|
||||
<p className="font-semibold text-[12px] p-3 flex justify-between items-center">
|
||||
EPS: {item}
|
||||
{item === activeRange ? (
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-[1px] border-[#ffffff34] rounded-sm py-[4px] px-[8px] flex items-center gap-x-[10px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-transparent focus:outline-none rounded-sm text-[13px] font-bold placeholder:text-[12px] placeholder:font-medium"
|
||||
placeholder="Number of Ep"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={listContainerRef} className="w-full h-full overflow-y-auto">
|
||||
<div
|
||||
className={`${
|
||||
totalEpisodes > 30
|
||||
? "p-3 grid grid-cols-5 gap-1 max-[1200px]:grid-cols-12 max-[860px]:grid-cols-10 max-[575px]:grid-cols-8 max-[478px]:grid-cols-6 max-[350px]:grid-cols-5"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{totalEpisodes > 30
|
||||
? episodes
|
||||
.slice(selectedRange[0] - 1, selectedRange[1])
|
||||
.map((item, index) => {
|
||||
const episodeNumber = item?.id.match(/ep=(\d+)/)?.[1];
|
||||
const isActive =
|
||||
activeEpisodeId === episodeNumber ||
|
||||
currentEpisode === episodeNumber;
|
||||
const isSearched = searchedEpisode === item?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?.id}
|
||||
ref={isActive ? activeEpisodeRef : null}
|
||||
className={`flex items-center justify-center rounded-[3px] h-[30px] text-[13.5px] font-medium cursor-pointer group ${
|
||||
item?.filler
|
||||
? isActive
|
||||
? "bg-[#ffbade]"
|
||||
: "bg-gradient-to-r from-[#5a4944] to-[#645a4b]"
|
||||
: ""
|
||||
} md:hover:bg-[#67686F]
|
||||
md:hover:text-white
|
||||
${
|
||||
isActive
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-[#35373D] text-gray-400"
|
||||
} ${isSearched ? "glow-animation" : ""} `}
|
||||
onClick={() => {
|
||||
if (episodeNumber) {
|
||||
onEpisodeClick(episodeNumber);
|
||||
setActiveEpisodeId(episodeNumber);
|
||||
setSearchedEpisode(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
item?.filler
|
||||
? "text-white md:group-hover:text-[#ffbade]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{index + selectedRange[0]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: episodes?.map((item, index) => {
|
||||
const episodeNumber = item?.id.match(/ep=(\d+)/)?.[1];
|
||||
const isActive =
|
||||
activeEpisodeId === episodeNumber ||
|
||||
currentEpisode === episodeNumber;
|
||||
const isSearched = searchedEpisode === item?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?.id}
|
||||
ref={isActive ? activeEpisodeRef : null}
|
||||
className={`w-full pl-5 pr-2 py-3 flex items-center justify-start gap-x-8 cursor-pointer ${
|
||||
(index + 1) % 2 && !isActive
|
||||
? "bg-[#201F2D] text-gray-400"
|
||||
: "bg-none"
|
||||
} group md:hover:bg-[#2B2A42] ${
|
||||
isActive ? "text-[#ffbade] bg-[#2B2A42]" : ""
|
||||
} ${isSearched ? "glow-animation" : ""}`}
|
||||
onClick={() => {
|
||||
if (episodeNumber) {
|
||||
onEpisodeClick(episodeNumber);
|
||||
setActiveEpisodeId(episodeNumber);
|
||||
setSearchedEpisode(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-[14px] font-medium">{index + 1}</p>
|
||||
<div className="w-full flex items-center justify-between gap-x-[5px]">
|
||||
<h1 className="line-clamp-1 text-[15px] font-light group-hover:text-[#ffbade]">
|
||||
{language === "EN" ? item?.title : item?.japanese_title}
|
||||
</h1>
|
||||
{isActive && (
|
||||
<FontAwesomeIcon
|
||||
icon={faCirclePlay}
|
||||
className="w-[20px] h-[20px] text-[#ffbade]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Episodelist;
|
||||
21
src/components/error/Error.jsx
Normal file
21
src/components/error/Error.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FaChevronLeft } from "react-icons/fa"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
|
||||
function Error({ error }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="bg-[#201F31] w-full h-screen flex justify-center items-center">
|
||||
<div className="flex flex-col w-fit h-fit items-center justify-center">
|
||||
<img src="https://s1.gifyu.com/images/SBlOe.png" alt="" className="w-[300px] h-[300px] max-[500px]:w-[200px] max-[500px]:h-[200px]" />
|
||||
<h1 className="text-white text-[35px] leading-5 mt-7">{error === "404" ? "404 Error" : "Error"}</h1>
|
||||
<p className="mt-5">Oops! We couldn't find this page.</p>
|
||||
<button className="bg-[#ffbade] py-2 px-4 w-fit rounded-3xl text-black text-light flex items-center gap-x-2 mt-7">
|
||||
<FaChevronLeft className="text-[#ffbade] w-[20px] h-[20px] rounded-full p-1 bg-black" />
|
||||
<p onClick={() => navigate('/home')} className="text-[18px]">Back to homepage</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Error
|
||||
62
src/components/footer/Footer.jsx
Normal file
62
src/components/footer/Footer.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import logoTitle from "@/src/config/logoTitle.js";
|
||||
import website_name from "@/src/config/website.js";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="flex flex-col w-full mt-[100px] px-4 max-[500px]:px-0">
|
||||
<div
|
||||
style={{ borderBottom: "1px solid rgba(255, 255, 255, .075)" }}
|
||||
className="w-full text-left max-[500px]:hidden"
|
||||
>
|
||||
<img
|
||||
src="https://i.postimg.cc/SsKY6Y9f/2H76i57.png"
|
||||
alt={logoTitle}
|
||||
className="w-[200px] h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex py-5 flex-col w-full space-y-4 max-md:items-center max-[500px]:bg-[#373646]">
|
||||
<div className="flex w-fit items-center space-x-6 max-[500px]:hidden">
|
||||
<p className="text-2xl font-bold max-md:text-lg">A-Z LIST</p>
|
||||
<p
|
||||
style={{ borderLeft: "1px solid rgba(255, 255, 255, 0.6)" }}
|
||||
className="text-md font-semibold pl-6"
|
||||
>
|
||||
Searching anime order by alphabet name A to Z
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-x-[7px] flex-wrap justify-start gap-y-2 max-md:justify-start max-[500px]:hidden">
|
||||
{[
|
||||
"All",
|
||||
"#",
|
||||
"0-9",
|
||||
...Array.from({ length: 26 }, (_, i) =>
|
||||
String.fromCharCode(65 + i)
|
||||
),
|
||||
].map((item, index) => (
|
||||
<Link
|
||||
to={`az-list/${item === "All" ? "" : item}`}
|
||||
key={index}
|
||||
className="text-lg bg-[#373646] px-2 rounded-md font-bold hover:text-black hover:bg-[#FFBADE] hover:cursor-pointer transition-all ease-out"
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col w-full text-left space-y-2 pt-4 max-md:items-center max-[470px]:px-[5px]">
|
||||
<p className="text-[#9B9BA3] text-[16px] max-md:text-center max-md:text-[12px]">
|
||||
{website_name} does not host any files, it merely pulls streams from
|
||||
3rd party services. Legal issues should be taken up with the file
|
||||
hosts and providers. {website_name} is not responsible for any media
|
||||
files shown by the video providers.
|
||||
</p>
|
||||
<p className="text-[#9B9BA3] max-md:text-[14px]">
|
||||
© {website_name}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
54
src/components/genres/Genre.jsx
Normal file
54
src/components/genres/Genre.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function Genre({ data }) {
|
||||
const colors = [
|
||||
"#A4B389",
|
||||
"#FFBADE",
|
||||
"#935C5F",
|
||||
"#AD92BC",
|
||||
"#ABCCD8",
|
||||
"#D8B2AB",
|
||||
"#85E1CD",
|
||||
"#B7C996",
|
||||
];
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const toggleGenres = () => {
|
||||
setShowAll((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade]">Genres</h1>
|
||||
<div className="bg-[#373646] py-6 px-4 mt-6 max-[478px]:bg-transparent max-[478px]:px-0">
|
||||
<div className="grid grid-cols-3 grid-rows-2 gap-x-4 gap-y-3 w-full max-[478px]:flex max-[478px]:flex-wrap max-[478px]:gap-2">
|
||||
{data &&
|
||||
(showAll ? data : data.slice(0, 24)).map((item, index) => {
|
||||
const textColor = colors[index % colors.length];
|
||||
return (
|
||||
<Link
|
||||
to={`/genre/${item}`}
|
||||
key={index}
|
||||
className="rounded-[4px] py-2 px-3 hover:bg-[#555462] hover:cursor-pointer max-[478px]:bg-[#373646] max-[478px]:py-[6px]"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<div className="overflow-hidden text-left text-ellipsis text-nowrap font-bold">
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="w-full bg-[#555462d3] py-3 mt-4 hover:bg-[#555462] rounded-md font-bold transform transition-all ease-out"
|
||||
onClick={toggleGenres}
|
||||
>
|
||||
{showAll ? "Show less" : "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Genre);
|
||||
147
src/components/navbar/Navbar.jsx
Normal file
147
src/components/navbar/Navbar.jsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import logoTitle from "@/src/config/logoTitle";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faBars,
|
||||
faFilm,
|
||||
faRandom,
|
||||
faStar,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import Sidebar from "../sidebar/Sidebar";
|
||||
import { SearchProvider } from "@/src/context/SearchContext";
|
||||
import WebSearch from "../searchbar/WebSearch";
|
||||
import MobileSearch from "../searchbar/MobileSearch";
|
||||
import { FaTelegramPlane } from "react-icons/fa";
|
||||
|
||||
function Navbar() {
|
||||
const location = useLocation();
|
||||
const { language, toggleLanguage } = useLanguage();
|
||||
const [isNotHomePage, setIsNotHomePage] = useState(
|
||||
location.pathname !== "/" && location.pathname !== "/home"
|
||||
);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 0);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleHamburgerClick = () => {
|
||||
setIsSidebarOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseSidebar = () => {
|
||||
setIsSidebarOpen(false);
|
||||
};
|
||||
const handleRandomClick = () => {
|
||||
if (location.pathname === "/random") {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
setIsNotHomePage(
|
||||
location.pathname !== "/" && location.pathname !== "/home"
|
||||
);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<SearchProvider>
|
||||
<nav
|
||||
className={`fixed top-0 left-0 w-full h-16 z-[1000000] flex p-4 py-8 items-center justify-between transition-all duration-300 ease-in-out ${
|
||||
isNotHomePage ? "bg-[#201F31]" : "bg-opacity-0"
|
||||
} ${
|
||||
isScrolled ? "bg-[#2D2B44] bg-opacity-90 backdrop-blur-md" : ""
|
||||
} max-[600px]:h-fit max-[600px]:flex-col max-[1200px]:bg-opacity-100 max-[600px]:py-2`}
|
||||
>
|
||||
<div className="flex gap-x-6 items-center w-fit max-lg:w-full max-lg:justify-between">
|
||||
<div className="flex gap-x-6 items-center w-fit">
|
||||
<FontAwesomeIcon
|
||||
icon={faBars}
|
||||
className="text-2xl text-white mt-1 cursor-pointer"
|
||||
onClick={handleHamburgerClick}
|
||||
/>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-4xl font-bold max-[575px]:text-3xl cursor-pointer"
|
||||
>
|
||||
{logoTitle.slice(0, 3)}
|
||||
<span className="text-[#FFBADE]">{logoTitle.slice(3, 4)}</span>
|
||||
{logoTitle.slice(4)}
|
||||
</Link>
|
||||
</div>
|
||||
<WebSearch />
|
||||
</div>
|
||||
<div className="flex gap-x-7 items-center max-lg:hidden">
|
||||
{[
|
||||
{ icon: faRandom, label: "Random", path: "/random" },
|
||||
{ icon: faFilm, label: "Movie", path: "/movie" },
|
||||
{ icon: faStar, label: "Popular", path: "/most-popular" },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={
|
||||
item.path === "/random"
|
||||
? location.pathname === "/random"
|
||||
? "#"
|
||||
: "/random"
|
||||
: item.path
|
||||
}
|
||||
onClick={item.path === "/random" ? handleRandomClick : undefined}
|
||||
className="flex flex-col gap-y-1 items-center cursor-pointer"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={item.icon}
|
||||
className="text-[#ffbade] text-xl font-bold"
|
||||
/>
|
||||
<p className="text-[15px]">{item.label}</p>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col gap-y-1 items-center w-auto">
|
||||
<div className="flex">
|
||||
{["EN", "JP"].map((lang, index) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => toggleLanguage(lang)}
|
||||
className={`px-1 py-[1px] text-xs font-bold ${
|
||||
index === 0 ? "rounded-l-[3px]" : "rounded-r-[3px]"
|
||||
} ${
|
||||
language === lang
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-gray-600 text-white"
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="whitespace-nowrap text-[15px]">Anime name</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="https://t.me/zenime_discussion"
|
||||
className="flex flex-col gap-y-1 items-center cursor-pointer"
|
||||
>
|
||||
<FaTelegramPlane
|
||||
// icon={faTelegram}
|
||||
className="text-xl font-bold text-[#ffbade]"
|
||||
/>
|
||||
<p className="text-[15px] mb-[1px] text-white">Join Telegram</p>
|
||||
</Link>
|
||||
</div>
|
||||
<MobileSearch />
|
||||
</nav>
|
||||
<Sidebar isOpen={isSidebarOpen} onClose={handleCloseSidebar} />
|
||||
</SearchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
76
src/components/pageslider/PageSlider.jsx
Normal file
76
src/components/pageslider/PageSlider.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { faAngleDoubleLeft, faAngleDoubleRight, faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
function PageSlider({ page, totalPages, handlePageChange, start = false, style }) {
|
||||
const renderPageNumbers = () => {
|
||||
const pages = [];
|
||||
if (totalPages === 1) return null;
|
||||
if (totalPages <= 3) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
if (page === 1) {
|
||||
pages.push(1, 2, 3);
|
||||
} else if (page === 2) {
|
||||
pages.push(1, 2, 3, 4);
|
||||
} else if (page === totalPages) {
|
||||
pages.push(totalPages - 2, totalPages - 1, totalPages);
|
||||
} else if (page === totalPages - 1) {
|
||||
pages.push(totalPages - 3, totalPages - 2, totalPages - 1, totalPages);
|
||||
} else {
|
||||
pages.push(page - 2, page - 1, page, page + 1, page + 2);
|
||||
}
|
||||
}
|
||||
return pages.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handlePageChange(p)}
|
||||
className={`w-[40px] text-[15px] mx-1 flex justify-center items-center p-2 rounded-full font-bold ${page === p ? 'bg-[#ffbade] text-[#2B2A3C] cursor-default' : 'bg-[#2B2A3C] text-[#999] hover:text-[#ffbade]'} ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} `}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
));
|
||||
};
|
||||
return (
|
||||
<div className={`w-full flex ${start ? "justify-start" : "justify-center"} items-center mt-12 overflow-hidden`} style={style}>
|
||||
<div className="flex justify-center mt-4 w-fit">
|
||||
{page > 1 && totalPages > 2 && (
|
||||
<button
|
||||
onClick={() => handlePageChange(1)}
|
||||
className={`w-[40px] mx-1 p-2 ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} rounded-full text-[#999] text-[8px] hover:text-[#ffbade]`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDoubleLeft} />
|
||||
</button>
|
||||
)}
|
||||
{page > 1 && (
|
||||
<button
|
||||
onClick={() => { if (page > 0) handlePageChange(page - 1) }}
|
||||
className={`w-[40px] mx-1 p-2 ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} rounded-full text-[#999] text-[8px] hover:text-[#ffbade]`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</button>
|
||||
)}
|
||||
{renderPageNumbers()}
|
||||
{page < totalPages && (
|
||||
<button
|
||||
onClick={() => { if (page < totalPages) handlePageChange(page + 1) }}
|
||||
className={`w-[40px] mx-1 p-2 ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} rounded-full text-[#999] text-[8px] hover:text-[#ffbade]`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
)}
|
||||
{page < totalPages && totalPages > 2 && (
|
||||
<button
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
className={`w-[40px] mx-1 p-2 ${start ? "bg-[#353537]" : "bg-[#2B2A3C]"} rounded-full text-[#999] text-[8px] hover:text-[#ffbade]`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDoubleRight} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageSlider
|
||||
148
src/components/player/IframePlayer.jsx
Normal file
148
src/components/player/IframePlayer.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import { useEffect, useState } from "react";
|
||||
import BouncingLoader from "../ui/bouncingloader/Bouncingloader";
|
||||
import axios from "axios";
|
||||
|
||||
export default function IframePlayer({
|
||||
animeId,
|
||||
episodeId,
|
||||
serverName,
|
||||
servertype,
|
||||
animeInfo,
|
||||
episodeNum,
|
||||
episodes,
|
||||
playNext,
|
||||
autoNext,
|
||||
}) {
|
||||
const api_url=import.meta.env.VITE_API_URL;
|
||||
const baseURL =
|
||||
serverName.toLowerCase() === "hd-1"
|
||||
? import.meta.env.VITE_BASE_IFRAME_URL
|
||||
: serverName.toLowerCase() === "hd-4"
|
||||
? import.meta.env.VITE_BASE_IFRAME_URL_2
|
||||
: undefined;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false);
|
||||
const [iframeSrc, setIframeSrc] = useState("");
|
||||
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(
|
||||
episodes?.findIndex(
|
||||
(episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadIframeUrl = async () => {
|
||||
setLoading(true);
|
||||
setIframeLoaded(false);
|
||||
setIframeSrc("");
|
||||
|
||||
const lowerName = serverName.toLowerCase();
|
||||
|
||||
if (lowerName === "hd-1" || lowerName === "hd-4") {
|
||||
setIframeSrc(`${baseURL}/${episodeId}/${servertype}`);
|
||||
} else if (lowerName === "hd-2" || lowerName === "hd-3") {
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`${api_url}/stream?id=${animeId}?ep=${episodeId}&server=${serverName}&type=${servertype}`
|
||||
);
|
||||
|
||||
const link = res?.data?.results?.streamingLink?.link;
|
||||
if (link) {
|
||||
setIframeSrc(`${link}&_debug=true`);
|
||||
} else {
|
||||
console.error("Streaming link not found in response");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch streaming link:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadIframeUrl();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [episodeId, servertype, serverName, animeInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (episodes?.length > 0) {
|
||||
const newIndex = episodes.findIndex(
|
||||
(episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId
|
||||
);
|
||||
setCurrentEpisodeIndex(newIndex);
|
||||
}
|
||||
}, [episodeId, episodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event) => {
|
||||
const { currentTime, duration } = event.data;
|
||||
if (typeof currentTime === "number" && typeof duration === "number") {
|
||||
if (
|
||||
currentTime >= duration &&
|
||||
currentEpisodeIndex < episodes?.length - 1 &&
|
||||
autoNext
|
||||
) {
|
||||
playNext(episodes[currentEpisodeIndex + 1].id.match(/ep=(\d+)/)?.[1]);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [autoNext, currentEpisodeIndex, episodes, playNext]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setIframeLoaded(false);
|
||||
return () => {
|
||||
const continueWatching = JSON.parse(localStorage.getItem("continueWatching")) || [];
|
||||
const newEntry = {
|
||||
id: animeInfo?.id,
|
||||
data_id: animeInfo?.data_id,
|
||||
episodeId,
|
||||
episodeNum,
|
||||
adultContent: animeInfo?.adultContent,
|
||||
poster: animeInfo?.poster,
|
||||
title: animeInfo?.title,
|
||||
japanese_title: animeInfo?.japanese_title,
|
||||
};
|
||||
if (!newEntry.data_id) return;
|
||||
const existingIndex = continueWatching.findIndex(
|
||||
(item) => item.data_id === newEntry.data_id
|
||||
);
|
||||
if (existingIndex !== -1) {
|
||||
continueWatching[existingIndex] = newEntry;
|
||||
} else {
|
||||
continueWatching.push(newEntry);
|
||||
}
|
||||
localStorage.setItem("continueWatching", JSON.stringify(continueWatching));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [episodeId, servertype]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
{/* Loader Overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 flex justify-center items-center bg-black bg-opacity-50 z-10 transition-opacity duration-500 ${
|
||||
loading ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<BouncingLoader />
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
key={`${episodeId}-${servertype}-${serverName}-${iframeSrc}`}
|
||||
src={iframeSrc}
|
||||
allowFullScreen
|
||||
className={`w-full h-full transition-opacity duration-500 ${
|
||||
iframeLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => {
|
||||
setIframeLoaded(true);
|
||||
setTimeout(() => setLoading(false), 1000);
|
||||
}}
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/components/player/Player.css
Normal file
59
src/components/player/Player.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.art-subtitle {
|
||||
padding-inline: 0px !important;
|
||||
gap: 2px !important;
|
||||
}
|
||||
.art-volume-panel {
|
||||
padding-bottom: 20px !important;
|
||||
}
|
||||
.art-settings {
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
.art-subtitle {
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
.art-subtitle-line {
|
||||
min-width: fit-content;
|
||||
background-color: rgba(0, 0, 0, 0.479) !important;
|
||||
padding-inline: 3px !important;
|
||||
}
|
||||
.art-subtitle-line,
|
||||
.art-subtitle-line * {
|
||||
font-size: inherit !important;
|
||||
color: inherit !important;
|
||||
line-height: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
white-space: inherit !important;
|
||||
}
|
||||
@media screen and (max-width: 370px) {
|
||||
.art-progress {
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
.art-controls-left .art-control {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
.art-controls-right .art-control {
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
.art-controls-right .art-control svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.art-controls-left .art-control svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.art-state .art-icon svg {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 350px) {
|
||||
.art-controls-right .art-control svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.art-controls-left .art-control svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
494
src/components/player/Player.jsx
Normal file
494
src/components/player/Player.jsx
Normal file
@@ -0,0 +1,494 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import Hls from "hls.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Artplayer from "artplayer";
|
||||
import artplayerPluginChapter from "./artPlayerPluinChaper";
|
||||
import autoSkip from "./autoSkip";
|
||||
import artplayerPluginVttThumbnail from "./artPlayerPluginVttThumbnail";
|
||||
import {
|
||||
backward10Icon,
|
||||
backwardIcon,
|
||||
captionIcon,
|
||||
forward10Icon,
|
||||
forwardIcon,
|
||||
fullScreenOffIcon,
|
||||
fullScreenOnIcon,
|
||||
loadingIcon,
|
||||
logo,
|
||||
muteIcon,
|
||||
pauseIcon,
|
||||
pipIcon,
|
||||
playIcon,
|
||||
playIconLg,
|
||||
settingsIcon,
|
||||
volumeIcon,
|
||||
} from "./PlayerIcons";
|
||||
import "./Player.css";
|
||||
import website_name from "@/src/config/website";
|
||||
import getChapterStyles from "./getChapterStyle";
|
||||
import artplayerPluginHlsControl from "artplayer-plugin-hls-control";
|
||||
import artplayerPluginUploadSubtitle from "./artplayerPluginUploadSubtitle";
|
||||
|
||||
Artplayer.LOG_VERSION = false;
|
||||
Artplayer.CONTEXTMENU = false;
|
||||
|
||||
const KEY_CODES = {
|
||||
M: "m",
|
||||
I: "i",
|
||||
F: "f",
|
||||
V: "v",
|
||||
SPACE: " ",
|
||||
ARROW_UP: "arrowup",
|
||||
ARROW_DOWN: "arrowdown",
|
||||
ARROW_RIGHT: "arrowright",
|
||||
ARROW_LEFT: "arrowleft",
|
||||
};
|
||||
|
||||
export default function Player({
|
||||
streamUrl,
|
||||
subtitles,
|
||||
thumbnail,
|
||||
intro,
|
||||
outro,
|
||||
serverName,
|
||||
autoSkipIntro,
|
||||
autoPlay,
|
||||
autoNext,
|
||||
episodeId,
|
||||
episodes,
|
||||
playNext,
|
||||
animeInfo,
|
||||
episodeNum,
|
||||
streamInfo,
|
||||
}) {
|
||||
const artRef = useRef(null);
|
||||
const leftAtRef = useRef(0);
|
||||
const proxy = import.meta.env.VITE_PROXY_URL;
|
||||
const m3u8proxy = import.meta.env.VITE_M3U8_PROXY_URL?.split(",") || [];
|
||||
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(
|
||||
episodes?.findIndex(
|
||||
(episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (episodes?.length > 0) {
|
||||
const newIndex = episodes.findIndex(
|
||||
(episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId
|
||||
);
|
||||
setCurrentEpisodeIndex(newIndex);
|
||||
}
|
||||
}, [episodeId, episodes]);
|
||||
useEffect(() => {
|
||||
const applyChapterStyles = () => {
|
||||
const existingStyles = document.querySelectorAll(
|
||||
"style[data-chapter-styles]"
|
||||
);
|
||||
existingStyles.forEach((style) => style.remove());
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.setAttribute("data-chapter-styles", "true");
|
||||
const styles = getChapterStyles(intro, outro);
|
||||
styleElement.textContent = styles;
|
||||
document.head.appendChild(styleElement);
|
||||
return () => {
|
||||
styleElement.remove();
|
||||
};
|
||||
};
|
||||
|
||||
if (streamUrl || intro || outro) {
|
||||
const cleanup = applyChapterStyles();
|
||||
return cleanup;
|
||||
}
|
||||
}, [streamUrl, intro, outro]);
|
||||
|
||||
const playM3u8 = (video, url, art) => {
|
||||
if (Hls.isSupported()) {
|
||||
if (art.hls) art.hls.destroy();
|
||||
const hls = new Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
art.hls = hls;
|
||||
|
||||
art.on("destroy", () => hls.destroy());
|
||||
|
||||
// hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
// console.error("HLS.js error:", data);
|
||||
// });
|
||||
video.addEventListener("timeupdate", () => {
|
||||
const currentTime = Math.round(video.currentTime);
|
||||
const duration = Math.round(video.duration);
|
||||
if (duration > 0 && currentTime >= duration) {
|
||||
art.pause();
|
||||
if (currentEpisodeIndex < episodes?.length - 1 && autoNext) {
|
||||
playNext(
|
||||
episodes[currentEpisodeIndex + 1].id.match(/ep=(\d+)/)?.[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = url;
|
||||
video.addEventListener("timeupdate", () => {
|
||||
const currentTime = Math.round(video.currentTime);
|
||||
const duration = Math.round(video.duration);
|
||||
if (duration > 0 && currentTime >= duration) {
|
||||
art.pause();
|
||||
if (currentEpisodeIndex < episodes?.length - 1 && autoNext) {
|
||||
playNext(
|
||||
episodes[currentEpisodeIndex + 1].id.match(/ep=(\d+)/)?.[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log("Unsupported playback format: m3u8");
|
||||
}
|
||||
};
|
||||
|
||||
const createChapters = () => {
|
||||
const chapters = [];
|
||||
if (intro?.start !== 0 || intro?.end !== 0) {
|
||||
chapters.push({ start: intro.start, end: intro.end, title: "intro" });
|
||||
}
|
||||
if (outro?.start !== 0 || outro?.end !== 0) {
|
||||
chapters.push({ start: outro.start, end: outro.end, title: "outro" });
|
||||
}
|
||||
return chapters;
|
||||
};
|
||||
|
||||
const handleKeydown = (event, art) => {
|
||||
const tagName = event.target.tagName.toLowerCase();
|
||||
|
||||
if (tagName === "input" || tagName === "textarea") return;
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case KEY_CODES.M:
|
||||
art.muted = !art.muted;
|
||||
break;
|
||||
case KEY_CODES.I:
|
||||
art.pip = !art.pip;
|
||||
break;
|
||||
case KEY_CODES.F:
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
art.fullscreen = !art.fullscreen;
|
||||
break;
|
||||
case KEY_CODES.V:
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
art.subtitle.show = !art.subtitle.show;
|
||||
break;
|
||||
case KEY_CODES.SPACE:
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
art.playing ? art.pause() : art.play();
|
||||
break;
|
||||
case KEY_CODES.ARROW_UP:
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
art.volume = Math.min(art.volume + 0.1, 1);
|
||||
break;
|
||||
case KEY_CODES.ARROW_DOWN:
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
art.volume = Math.max(art.volume - 0.1, 0);
|
||||
break;
|
||||
case KEY_CODES.ARROW_RIGHT:
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
art.currentTime = Math.min(art.currentTime + 10, art.duration);
|
||||
break;
|
||||
case KEY_CODES.ARROW_LEFT:
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
art.currentTime = Math.max(art.currentTime - 10, 0);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!streamUrl || !artRef.current) return;
|
||||
const iframeUrl = streamInfo?.streamingLink?.iframe;
|
||||
const headers = {};
|
||||
headers.referer=new URL(iframeUrl).origin+"/";
|
||||
const art = new Artplayer({
|
||||
url:
|
||||
m3u8proxy[Math.floor(Math.random() * m3u8proxy?.length)] +
|
||||
encodeURIComponent(streamUrl) +
|
||||
"&headers=" +
|
||||
encodeURIComponent(JSON.stringify(headers)),
|
||||
container: artRef.current,
|
||||
type: "m3u8",
|
||||
autoplay: autoPlay,
|
||||
volume: 1,
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
pip: true,
|
||||
hotkey: false,
|
||||
fullscreen: true,
|
||||
mutex: true,
|
||||
playsInline: true,
|
||||
lock: true,
|
||||
airplay: true,
|
||||
autoOrientation: true,
|
||||
fastForward: true,
|
||||
aspectRatio: true,
|
||||
plugins: [
|
||||
artplayerPluginHlsControl({
|
||||
quality: {
|
||||
setting: true,
|
||||
getName: (level) => level.height + "P",
|
||||
title: "Quality",
|
||||
auto: "Auto",
|
||||
},
|
||||
}),
|
||||
artplayerPluginUploadSubtitle(),
|
||||
artplayerPluginChapter({ chapters: createChapters() }),
|
||||
],
|
||||
subtitle: {
|
||||
style: {
|
||||
color: "#fff",
|
||||
"font-weight": "400",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
"margin-bottom": "2rem",
|
||||
},
|
||||
escape: false,
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
name: website_name,
|
||||
html: logo,
|
||||
tooltip: website_name,
|
||||
style: {
|
||||
opacity: 1,
|
||||
position: "absolute",
|
||||
top: "5px",
|
||||
right: "5px",
|
||||
transition: "opacity 0.5s ease-out",
|
||||
},
|
||||
},
|
||||
{
|
||||
html: "",
|
||||
style: {
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 0,
|
||||
width: "20%",
|
||||
height: "100%",
|
||||
transform: "translateX(-50%)",
|
||||
},
|
||||
disable: !Artplayer.utils.isMobile,
|
||||
click: () => art.toggle(),
|
||||
},
|
||||
{
|
||||
name: "rewind",
|
||||
html: "",
|
||||
style: { position: "absolute", left: 0, top: 0, width: "40%", height: "100%" },
|
||||
disable: !Artplayer.utils.isMobile,
|
||||
click: () => {
|
||||
art.controls.show = !art.controls.show;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forward",
|
||||
html: "",
|
||||
style: { position: "absolute", right: 0, top: 0, width: "40%", height: "100%" },
|
||||
disable: !Artplayer.utils.isMobile,
|
||||
click: () => {
|
||||
art.controls.show = !art.controls.show;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "backwardIcon",
|
||||
html: backwardIcon,
|
||||
style: {
|
||||
position: "absolute",
|
||||
left: "25%",
|
||||
top: "50%",
|
||||
transform: "translate(50%,-50%)",
|
||||
opacity: 0,
|
||||
transition: "opacity 0.5s ease-in-out",
|
||||
},
|
||||
disable: !Artplayer.utils.isMobile,
|
||||
},
|
||||
{
|
||||
name: "forwardIcon",
|
||||
html: forwardIcon,
|
||||
style: {
|
||||
position: "absolute",
|
||||
right: "25%",
|
||||
top: "50%",
|
||||
transform: "translate(50%, -50%)",
|
||||
opacity: 0,
|
||||
transition: "opacity 0.5s ease-in-out",
|
||||
},
|
||||
disable: !Artplayer.utils.isMobile,
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
html: backward10Icon,
|
||||
position: "right",
|
||||
tooltip: "Backward 10s",
|
||||
click: () => {
|
||||
art.currentTime = Math.max(art.currentTime - 10, 0);
|
||||
},
|
||||
},
|
||||
{
|
||||
html: forward10Icon,
|
||||
position: "right",
|
||||
tooltip: "Forward 10s",
|
||||
click: () => {
|
||||
art.currentTime = Math.min(art.currentTime + 10, art.duration);
|
||||
},
|
||||
},
|
||||
],
|
||||
icons: {
|
||||
play: playIcon,
|
||||
pause: pauseIcon,
|
||||
setting: settingsIcon,
|
||||
volume: volumeIcon,
|
||||
pip: pipIcon,
|
||||
volumeClose: muteIcon,
|
||||
state: playIconLg,
|
||||
loading: loadingIcon,
|
||||
fullscreenOn: fullScreenOnIcon,
|
||||
fullscreenOff: fullScreenOffIcon,
|
||||
},
|
||||
customType: { m3u8: playM3u8 },
|
||||
});
|
||||
art.on("resize", () => {
|
||||
art.subtitle.style({
|
||||
fontSize:
|
||||
(art.width > 500 ? art.width * 0.02 : art.width * 0.03) + "px",
|
||||
});
|
||||
});
|
||||
art.on("ready", () => {
|
||||
const continueWatchingList = JSON.parse(localStorage.getItem("continueWatching")) || [];
|
||||
const currentEntry = continueWatchingList.find((item) => item.episodeId === episodeId);
|
||||
if (currentEntry?.leftAt) art.currentTime = currentEntry.leftAt;
|
||||
|
||||
art.on("video:timeupdate", () => {
|
||||
leftAtRef.current = Math.floor(art.currentTime);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
art.layers[website_name].style.opacity = 0;
|
||||
}, 2000);
|
||||
|
||||
const defaultSubtitle = subtitles?.find((sub) => sub.label.toLowerCase() === "english");
|
||||
if (defaultSubtitle) {
|
||||
art.subtitle.switch(defaultSubtitle.file, {
|
||||
name: defaultSubtitle.label,
|
||||
default: true,
|
||||
});
|
||||
}
|
||||
|
||||
const skipRanges = [
|
||||
...(intro.start != null && intro.end != null ? [[intro.start + 1, intro.end - 1]] : []),
|
||||
...(outro.start != null && outro.end != null ? [[outro.start + 1, outro.end]] : []),
|
||||
];
|
||||
autoSkipIntro && art.plugins.add(autoSkip(skipRanges));
|
||||
|
||||
document.addEventListener("keydown", (event) => handleKeydown(event, art));
|
||||
|
||||
art.subtitle.style({
|
||||
fontSize: (art.width > 500 ? art.width * 0.02 : art.width * 0.03) + "px",
|
||||
});
|
||||
|
||||
if (thumbnail) {
|
||||
art.plugins.add(
|
||||
artplayerPluginVttThumbnail({
|
||||
vtt: `${proxy}${thumbnail}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
const $rewind = art.layers["rewind"];
|
||||
const $forward = art.layers["forward"];
|
||||
Artplayer.utils.isMobile &&
|
||||
art.proxy($rewind, "dblclick", () => {
|
||||
art.currentTime = Math.max(0, art.currentTime - 10);
|
||||
art.layers["backwardIcon"].style.opacity = 1;
|
||||
setTimeout(() => {
|
||||
art.layers["backwardIcon"].style.opacity = 0;
|
||||
}, 300);
|
||||
});
|
||||
Artplayer.utils.isMobile &&
|
||||
art.proxy($forward, "dblclick", () => {
|
||||
art.currentTime = Math.max(0, art.currentTime + 10);
|
||||
art.layers["forwardIcon"].style.opacity = 1;
|
||||
setTimeout(() => {
|
||||
art.layers["forwardIcon"].style.opacity = 0;
|
||||
}, 300);
|
||||
});
|
||||
if (subtitles?.length > 0) {
|
||||
const defaultEnglishSub =
|
||||
subtitles.find((sub) => sub.label.toLowerCase() === "english" && sub.default) ||
|
||||
subtitles.find((sub) => sub.label.toLowerCase() === "english");
|
||||
|
||||
art.setting.add({
|
||||
name: "captions",
|
||||
icon: captionIcon,
|
||||
html: "Subtitle",
|
||||
tooltip: defaultEnglishSub?.label || "default",
|
||||
position: "right",
|
||||
selector: [
|
||||
{
|
||||
html: "Display",
|
||||
switch: true,
|
||||
onSwitch: (item) => {
|
||||
item.tooltip = item.switch ? "Hide" : "Show";
|
||||
art.subtitle.show = !item.switch;
|
||||
return !item.switch;
|
||||
},
|
||||
},
|
||||
...subtitles.map((sub) => ({
|
||||
default: sub.label.toLowerCase() === "english" && sub === defaultEnglishSub,
|
||||
html: sub.label,
|
||||
url: sub.file,
|
||||
})),
|
||||
],
|
||||
onSelect: (item) => {
|
||||
art.subtitle.switch(item.url, { name: item.html });
|
||||
return item.html;
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (art && art.destroy) {
|
||||
art.destroy(false);
|
||||
}
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
const continueWatching = JSON.parse(localStorage.getItem("continueWatching")) || [];
|
||||
const newEntry = {
|
||||
id: animeInfo?.id,
|
||||
data_id: animeInfo?.data_id,
|
||||
episodeId,
|
||||
episodeNum,
|
||||
adultContent: animeInfo?.adultContent,
|
||||
poster: animeInfo?.poster,
|
||||
title: animeInfo?.title,
|
||||
japanese_title: animeInfo?.japanese_title,
|
||||
leftAt: leftAtRef.current,
|
||||
};
|
||||
|
||||
if (!newEntry.data_id) return;
|
||||
|
||||
const existingIndex = continueWatching.findIndex((item) => item.data_id === newEntry.data_id);
|
||||
if (existingIndex !== -1) {
|
||||
continueWatching[existingIndex] = newEntry;
|
||||
} else {
|
||||
continueWatching.push(newEntry);
|
||||
}
|
||||
localStorage.setItem("continueWatching", JSON.stringify(continueWatching));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [streamUrl, subtitles, intro, outro]);
|
||||
|
||||
return <div ref={artRef} className="w-full h-full"></div>;
|
||||
}
|
||||
103
src/components/player/PlayerIcons.jsx
Normal file
103
src/components/player/PlayerIcons.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
const backward10Icon = `<svg viewBox="-5 -10 75 75" xmlns="http://www.w3.org/2000/svg" width="35" height="35">
|
||||
<path d="M11.9199 45H7.20508V26.5391L2.60645 28.3154V24.3975L11.4219 20.7949H11.9199V45ZM30.1013 35.0059C30.1013 38.3483 29.4926 40.9049 28.2751 42.6758C27.0687 44.4466 25.3422 45.332 23.0954 45.332C20.8708 45.332 19.1498 44.4743 17.9323 42.7588C16.726 41.0322 16.1006 38.5641 16.0564 35.3545V30.7891C16.0564 27.4577 16.6596 24.9121 17.8659 23.1523C19.0723 21.3815 20.8044 20.4961 23.0622 20.4961C25.32 20.4961 27.0521 21.3704 28.2585 23.1191C29.4649 24.8678 30.0792 27.3636 30.1013 30.6064V35.0059ZM25.3864 30.1084C25.3864 28.2048 25.1983 26.777 24.822 25.8252C24.4457 24.8734 23.8591 24.3975 23.0622 24.3975C21.5681 24.3975 20.7933 26.1406 20.738 29.627V35.6533C20.738 37.6012 20.9262 39.0511 21.3025 40.0029C21.6898 40.9548 22.2875 41.4307 23.0954 41.4307C23.8591 41.4307 24.4236 40.988 24.7888 40.1025C25.1651 39.2061 25.3643 37.8392 25.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M11.9894 5.45398V0L2 7.79529L11.9894 15.5914V10.3033H47.0886V40.1506H33.2442V45H52V5.45398H11.9894Z" fill="white"/>
|
||||
</svg>`;
|
||||
|
||||
const forward10Icon = `
|
||||
<svg viewBox="-5 -10 75 75" xmlns="http://www.w3.org/2000/svg" width="35" height="35">
|
||||
<path d="M29.9199 45H25.2051V26.5391L20.6064 28.3154V24.3975L29.4219 20.7949H29.9199V45ZM48.1013 35.0059C48.1013 38.3483 47.4926 40.9049 46.2751 42.6758C45.0687 44.4466 43.3422 45.332 41.0954 45.332C38.8708 45.332 37.1498 44.4743 35.9323 42.7588C34.726 41.0322 34.1006 38.5641 34.0564 35.3545V30.7891C34.0564 27.4577 34.6596 24.9121 35.8659 23.1523C37.0723 21.3815 38.8044 20.4961 41.0622 20.4961C43.32 20.4961 45.0521 21.3704 46.2585 23.1191C47.4649 24.8678 48.0792 27.3636 48.1013 30.6064V35.0059ZM43.3864 30.1084C43.3864 28.2048 43.1983 26.777 42.822 25.8252C42.4457 24.8734 41.8591 24.3975 41.0622 24.3975C39.5681 24.3975 38.7933 26.1406 38.738 29.627V35.6533C38.738 37.6012 38.9262 39.0511 39.3025 40.0029C39.6898 40.9548 40.2875 41.4307 41.0954 41.4307C41.8591 41.4307 42.4236 40.988 42.7888 40.1025C43.1651 39.2061 43.3643 37.8392 43.3864 36.002V30.1084Z" fill="white"/>
|
||||
<path d="M40.0106 5.45398V0L50 7.79529L40.0106 15.5914V10.3033H4.9114V40.1506H18.7558V45H2.01875e-06V5.45398H40.0106Z" fill="white"/>
|
||||
</svg>`;
|
||||
|
||||
const forwardIcon = `<svg viewBox="0 0 512 512" width="30" height="30">
|
||||
<path d="M500.5 231.4l-192-160C287.9 54.3 256 68.6 256 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2zm-256 0l-192-160C31.9 54.3 0 68.6 0 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2z"/>
|
||||
</svg>`;
|
||||
|
||||
const backwardIcon = `<svg viewBox="0 0 512 512" width="30" height="30" transform="scale(-1, 1)">
|
||||
<path d="M500.5 231.4l-192-160C287.9 54.3 256 68.6 256 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2zm-256 0l-192-160C31.9 54.3 0 68.6 0 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2z"/>
|
||||
</svg>`;
|
||||
|
||||
const volumeIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24"><path d="M116.5,42.8v154.4c0,2.8-1.7,3.6-3.8,1.7l-54.1-48H29c-2.8,0-5.2-2.3-5.2-5.2V94.3c0-2.8,2.3-5.2,5.2-5.2h29.6l54.1-48C114.8,39.2,116.5,39.9,116.5,42.8z"/><path d="M136.2,160v-20c11.1,0,20-8.9,20-20s-8.9-20-20-20V80c22.1,0,40,17.9,40,40S158.3,160,136.2,160z"/><path d="M216.2,120c0-44.2-35.8-80-80-80v20c33.1,0,60,26.9,60,60s-26.9,60-60,60v20C180.4,199.9,216.1,164.1,216.2,120z" fill="#fff"/></svg>`;
|
||||
|
||||
const muteIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24">
|
||||
<path d="M116.4,42.8v154.5c0,2.8-1.7,3.6-3.8,1.7l-54.1-48.1H28.9c-2.8,0-5.2-2.3-5.2-5.2V94.2c0-2.8,2.3-5.2,5.2-5.2h29.6l54.1-48.1C114.6,39.1,116.4,39.9,116.4,42.8z M212.3,96.4l-14.6-14.6l-23.6,23.6l-23.6-23.6l-14.6,14.6l23.6,23.6l-23.6,23.6l14.6,14.6l23.6-23.6l23.6,23.6l14.6-14.6L188.7,120L212.3,96.4z"
|
||||
fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const captionIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 16 240 240" width="28" height="28">
|
||||
<path d="M215,40H25c-2.7,0-5,2.2-5,5v150c0,2.7,2.2,5,5,5h190c2.7,0,5-2.2,5-5V45C220,42.2,217.8,40,215,40z M108.1,137.7c0.7-0.7,1.5-1.5,2.4-2.3l6.6,7.8c-2.2,2.4-5,4.4-8,5.8c-8,3.5-17.3,2.4-24.3-2.9c-3.9-3.6-5.9-8.7-5.5-14v-25.6c0-2.7,0.5-5.3,1.5-7.8c0.9-2.2,2.4-4.3,4.2-5.9c5.7-4.5,13.2-6.2,20.3-4.6c3.3,0.5,6.3,2,8.7,4.3c1.3,1.3,2.5,2.6,3.5,4.2l-7.1,6.9c-2.4-3.7-6.5-5.9-10.9-5.9c-2.4-0.2-4.8,0.7-6.6,2.3c-1.7,1.7-2.5,4.1-2.4,6.5v25.6C90.4,141.7,102,143.5,108.1,137.7z M152.9,137.7c0.7-0.7,1.5-1.5,2.4-2.3l6.6,7.8c-2.2,2.4-5,4.4-8,5.8c-8,3.5-17.3,2.4-24.3-2.9c-3.9-3.6-5.9-8.7-5.5-14v-25.6c0-2.7,0.5-5.3,1.5-7.8c0.9-2.2,2.4-4.3,4.2-5.9c5.7-4.5,13.2-6.2,20.3-4.6c3.3,0.5,6.3,2,8.7,4.3c1.3,1.3,2.5,2.6,3.5,4.2l-7.1,6.9c-2.4-3.7-6.5-5.9-10.9-5.9c-2.4-0.2-4.8,0.7-6.6,2.3c-1.7,1.7-2.5,4.1-2.4,6.5v25.6C135.2,141.7,146.8,143.5,152.9,137.7z"
|
||||
fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
const captionOffIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 16 240 240" width="28" height="28"><path d="M99.4,97.8c-2.4-0.2-4.8,0.7-6.6,2.3c-1.7,1.7-2.5,4.1-2.4,6.5v25.6c0,9.6,11.6,11.4,17.7,5.5c0.7-0.7,1.5-1.5,2.4-2.3l6.6,7.8c-2.2,2.4-5,4.4-8,5.8c-8,3.5-17.3,2.4-24.3-2.9c-3.9-3.6-5.9-8.7-5.5-14v-25.6c0-2.7,0.5-5.3,1.5-7.8c0.9-2.2,2.4-4.3,4.2-5.9c5.7-4.5,13.2-6.2,20.3-4.6c3.3,0.5,6.3,2,8.7,4.3c1.3,1.3,2.5,2.6,3.5,4.2l-7.1,6.9C107.9,100,103.8,97.8,99.4,97.8z M144.1,97.8c-2.4-0.2-4.8,0.7-6.6,2.3c-1.7,1.7-2.5,4.1-2.4,6.5v25.6c0,9.6,11.6,11.4,17.7,5.5c0.7-0.7,1.5-1.5,2.4-2.3l6.6,7.8c-2.2,2.4-5,4.4-8,5.8c-8,3.5-17.3,2.4-24.3-2.9c-3.9-3.6-5.9-8.7-5.5-14v-25.6c0-2.7,0.5-5.3,1.5-7.8c0.9-2.2,2.4-4.3,4.2-5.9c5.7-4.5,13.2-6.2,20.3-4.6c3.3,0.5,6.3,2,8.7,4.3c1.3,1.3,2.5,2.6,3.5,4.2l-7.1,6.9C152.6,100,148.5,97.8,144.1,97.8L144.1,97.8z M200,60v120H40V60H200 M215,40H25c-2.7,0-5,2.2-5,5v150c0,2.7,2.2,5,5,5h190c2.7,0,5-2.2,5-5V45C220,42.2,217.8,40,215,40z" fill="#fff"/></svg>`;
|
||||
|
||||
const pipOffIcon = `<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M20 5.75V9.75H22V4.78C22 4.21116 21.5389 3.75 20.97 3.75H2.03C1.46116 3.75 1 4.21113 1 4.78V17.72C1 18.2889 1.46119 18.75 2.03 18.75H12V16.75H3V5.75H20ZM14 13.25C14 12.6977 14.4477 12.25 15 12.25H22C22.5523 12.25 23 12.6977 23 13.25V19.25C23 19.8023 22.5523 20.25 22 20.25H15C14.4477 20.25 14 19.8023 14 19.25V13.25ZM10 9.25L8.20711 11.0429L10.7071 13.5429L9.29289 14.9571L6.79289 12.4571L5 14.25V9.25H10Z" fill="#fff"/>
|
||||
</svg>`;
|
||||
|
||||
const loadingIcon = `<svg width="80" height="80" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_l9ve{animation:spinner_rcyq 1.2s cubic-bezier(0.52,.6,.25,.99) infinite}.spinner_cMYp{animation-delay:.4s}.spinner_gHR3{animation-delay:.8s}@keyframes spinner_rcyq{0%{transform:translate(12px,12px) scale(0);opacity:1}100%{transform:translate(0,0) scale(1);opacity:0}}</style><path class="spinner_l9ve" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" transform="translate(12, 12) scale(0)"/><path class="spinner_l9ve spinner_cMYp" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" transform="translate(12, 12) scale(0)"/><path class="spinner_l9ve spinner_gHR3" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,20a9,9,0,1,1,9-9A9,9,0,0,1,12,21Z" transform="translate(12, 12) scale(0)"/></svg>`;
|
||||
|
||||
const pipIcon = `<svg width="24" height="24" viewBox="0 0 24 24" style="margin-bottom: 3px; vertical-align: middle;" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 5.125V9.125H22V4.155C22 3.58616 21.5389 3.125 20.97 3.125H2.03C1.46116 3.125 1 3.58613 1 4.155V17.095C1 17.6639 1.46119 18.125 2.03 18.125H12V16.125H3V5.125H20ZM14 11.875C14 11.3227 14.4477 10.875 15 10.875H22C22.5523 10.875 23 11.3227 23 11.875V17.875C23 18.4273 22.5523 18.875 22 18.875H15C14.4477 18.875 14 18.4273 14 17.875V11.875ZM6 12.375L7.79289 10.5821L5.29288 8.0821L6.7071 6.66788L9.20711 9.16789L11 7.375V12.375H6Z" fill="white"/>
|
||||
</svg>`;
|
||||
|
||||
const playIconLg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="80" height="80"><path d="M62.8,199.5c-1,0.8-2.4,0.6-3.3-0.4c-0.4-0.5-0.6-1.1-0.5-1.8V42.6c-0.2-1.3,0.7-2.4,1.9-2.6c0.7-0.1,1.3,0.1,1.9,0.4l154.7,77.7c2.1,1.1,2.1,2.8,0,3.8L62.8,199.5z" fill="white"/></svg>`;
|
||||
|
||||
const playIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24"><path d="M62.8,199.5c-1,0.8-2.4,0.6-3.3-0.4c-0.4-0.5-0.6-1.1-0.5-1.8V42.6c-0.2-1.3,0.7-2.4,1.9-2.6c0.7-0.1,1.3,0.1,1.9,0.4l154.7,77.7c2.1,1.1,2.1,2.8,0,3.8L62.8,199.5z"/></svg>`;
|
||||
|
||||
const pauseIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24"><path d="M100,194.9c0.2,2.6-1.8,4.8-4.4,5c-0.2,0-0.4,0-0.6,0H65c-2.6,0.2-4.8-1.8-5-4.4c0-0.2,0-0.4,0-0.6V45c-0.2-2.6,1.8-4.8,4.4-5c0.2,0,0.4,0,0.6,0h30c2.6-0.2,4.8,1.8,5,4.4c0,0.2,0,0.4,0,0.6V194.9z M180,45.1c0.2-2.6-1.8-4.8-4.4-5c-0.2,0-0.4,0-0.6,0h-30c-2.6-0.2-4.8,1.8-5,4.4c0,0.2,0,0.4,0,0.6V195c-0.2,2.6,1.8,4.8,4.4,5c0.2,0,0.4,0,0.6,0h30c2.6,0.2,4.8-1.8,5-4.4c0-0.2,0-0.4,0-0.6V45.1z"/></svg>`;
|
||||
|
||||
const uploadIcon = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width="24"
|
||||
height="24"
|
||||
style="vertical-align: middle;">
|
||||
<path fill-rule="evenodd" d="M8 0a5.53 5.53 0 0 0-3.594 1.342c-.766.66-1.321 1.52-1.464 2.383C1.266 4.095 0 5.555 0 7.318 0 9.366 1.708 11 3.781 11H7.5V5.707L5.354 7.854a.5.5 0 1 1-.708-.708l3-3a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 5.707V11h4.188C14.502 11 16 9.57 16 7.773c0-1.636-1.242-2.969-2.834-3.194C12.923 1.999 10.69 0 8 0m-.5 14.5V11h1v3.5a.5.5 0 0 1-1 0"/>
|
||||
</svg>`;
|
||||
|
||||
const settingsIcon = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 240 240"
|
||||
width="24"
|
||||
height="24"
|
||||
style="margin-bottom: 5px; vertical-align: middle;">
|
||||
<path d="M204,145l-25-14c0.8-3.6,1.2-7.3,1-11c0.2-3.7-0.2-7.4-1-11l25-14c2.2-1.6,3.1-4.5,2-7l-16-26c-1.2-2.1-3.8-2.9-6-2l-25,14c-6-4.2-12.3-7.9-19-11V35c0.2-2.6-1.8-4.8-4.4-5c-0.2,0-0.4,0-0.6,0h-30c-2.6-0.2-4.8,1.8-5,4.4c0,0.2,0,0.4,0,0.6v28c-6.7,3.1-13,6.7-19,11L56,60c-2.2-0.9-4.8-0.1-6,2L35,88c-1.6,2.2-1.3,5.3,0.9,6.9c0,0,0.1,0,0.1,0.1l25,14c-0.8,3.6-1.2,7.3-1,11c-0.2,3.7,0.2,7.4,1,11l-25,14c-2.2,1.6-3.1,4.5-2,7l16,26c1.2,2.1,3.8,2.9,6,2l25-14c5.7,4.6,12.2,8.3,19,11v28c-0.2,2.6,1.8,4.8,4.4,5c0.2,0,0.4,0,0.6,0h30c2.6,0.2,4.8-1.8,5-4.4c0-0.2,0-0.4,0-0.6v-28c7-2.3,13.5-6,19-11l25,14c2.5,1.3,5.6,0.4,7-2l15-26C206.7,149.4,206,146.7,204,145z M120,149.9c-16.5,0-30-13.4-30-30s13.4-30,30-30s30,13.4,30,30c0.3,16.3-12.6,29.7-28.9,30C120.7,149.9,120.4,149.9,120,149.9z"/>
|
||||
</svg>`;
|
||||
|
||||
const fullScreenOnIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="24" height="24" style="margin-bottom: 5px; vertical-align: middle;"><path d="M96.3,186.1c1.9,1.9,1.3,4-1.4,4.4l-50.6,8.4c-1.8,0.5-3.7-0.6-4.2-2.4c-0.2-0.6-0.2-1.2,0-1.7l8.4-50.6c0.4-2.7,2.4-3.4,4.4-1.4l14.5,14.5l28.2-28.2l14.3,14.3l-28.2,28.2L96.3,186.1z M195.8,39.1l-50.6,8.4c-2.7,0.4-3.4,2.4-1.4,4.4l14.5,14.5l-28.2,28.2l14.3,14.3l28.2-28.2l14.5,14.5c1.9,1.9,4,1.3,4.4-1.4l8.4-50.6c0.5-1.8-0.6-3.6-2.4-4.2C197,39,196.4,39,195.8,39.1L195.8,39.1z" fill="#fff"/></svg>`;
|
||||
|
||||
const fullScreenOffIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240"width="24" height="24" style="margin-bottom: 5px; vertical-align: middle;"><path d="M109.2,134.9l-8.4,50.1c-0.4,2.7-2.4,3.3-4.4,1.4L82,172l-27.9,27.9l-14.2-14.2l27.9-27.9l-14.4-14.4c-1.9-1.9-1.3-3.9,1.4-4.4l50.1-8.4c1.8-0.5,3.6,0.6,4.1,2.4C109.4,133.7,109.4,134.3,109.2,134.9L109.2,134.9z M172.1,82.1L200,54.2L185.8,40l-27.9,27.9l-14.4-14.4c-1.9-1.9-3.9-1.3-4.4,1.4l-8.4,50.1c-0.5,1.8,0.6,3.6,2.4,4.1c0.5,0.2,1.2,0.2,1.7,0l50.1-8.4c2.7-0.4,3.3-2.4,1.4-4.4L172.1,82.1z"/></svg>`;
|
||||
|
||||
const logo = `<p style="display: flex; gap: 7px; align-items: center; background-color:#1F2020; padding:5px;padding-inline:7px; border-radius:5px">
|
||||
<b style="color: #ffbade;">Powered by</b>
|
||||
<span style="font-size: 14px;">
|
||||
Zen<span style="color: #ffbade;">!</span>me
|
||||
</span>
|
||||
</p>
|
||||
`;
|
||||
|
||||
export {
|
||||
backward10Icon,
|
||||
forward10Icon,
|
||||
backwardIcon,
|
||||
forwardIcon,
|
||||
playIcon,
|
||||
playIconLg,
|
||||
pauseIcon,
|
||||
loadingIcon,
|
||||
uploadIcon,
|
||||
settingsIcon,
|
||||
pipIcon,
|
||||
pipOffIcon,
|
||||
volumeIcon,
|
||||
muteIcon,
|
||||
captionIcon,
|
||||
captionOffIcon,
|
||||
fullScreenOnIcon,
|
||||
fullScreenOffIcon,
|
||||
logo,
|
||||
};
|
||||
72
src/components/player/artPlayerPluginVttThumbnail.js
Normal file
72
src/components/player/artPlayerPluginVttThumbnail.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import getVttArray from "./getVttArray";
|
||||
|
||||
export default function artplayerPluginVttThumbnail(option) {
|
||||
return async (art) => {
|
||||
const {
|
||||
constructor: {
|
||||
utils: { setStyle, isMobile, addClass },
|
||||
},
|
||||
template: { $progress },
|
||||
} = art;
|
||||
|
||||
let timer = null;
|
||||
const thumbnails = await getVttArray(option.vtt);
|
||||
|
||||
function showThumbnails($control, find, width) {
|
||||
setStyle($control, "backgroundImage", `url(${find.url})`);
|
||||
setStyle($control, "height", `${find.h}px`);
|
||||
setStyle($control, "width", `${find.w}px`);
|
||||
setStyle($control, "backgroundPosition", `-${find.x}px -${find.y}px`);
|
||||
if (width <= find.w / 2) {
|
||||
setStyle($control, "left", 0);
|
||||
} else if (width > $progress.clientWidth - find.w / 2) {
|
||||
setStyle($control, "left", `${$progress.clientWidth - find.w}px`);
|
||||
} else {
|
||||
setStyle($control, "left", `${width - find.w / 2}px`);
|
||||
}
|
||||
}
|
||||
|
||||
art.controls.add({
|
||||
name: "vtt-thumbnail",
|
||||
position: "top",
|
||||
index: 20,
|
||||
style: option.style || {},
|
||||
mounted($control) {
|
||||
addClass($control, "art-control-thumbnails");
|
||||
art.on("setBar", async (type, percentage, event) => {
|
||||
const isMobileDroging = type === "played" && event && isMobile;
|
||||
|
||||
if (type === "hover" || isMobileDroging) {
|
||||
const width = $progress.clientWidth * percentage;
|
||||
const second = percentage * art.duration;
|
||||
setStyle($control, "display", "flex");
|
||||
|
||||
const find = thumbnails.find(
|
||||
(item) => second >= item.start && second <= item.end
|
||||
);
|
||||
if (!find) return setStyle($control, "display", "none");
|
||||
|
||||
if (width > 0 && width < $progress.clientWidth) {
|
||||
showThumbnails($control, find, width);
|
||||
} else {
|
||||
if (!isMobile) {
|
||||
setStyle($control, "display", "none");
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobileDroging) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
setStyle($control, "display", "none");
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
name: "artplayerPluginVttThumbnail",
|
||||
};
|
||||
};
|
||||
}
|
||||
211
src/components/player/artPlayerPluinChaper.js
Normal file
211
src/components/player/artPlayerPluinChaper.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import style from "./pluginChapterStyle.js";
|
||||
|
||||
export default function artplayerPluginChapter(option = {}) {
|
||||
return (art) => {
|
||||
const { $player } = art.template;
|
||||
const { setStyle, append, clamp, query, isMobile, addClass, removeClass } =
|
||||
art.constructor.utils;
|
||||
|
||||
const html = `
|
||||
<div class="art-chapter">
|
||||
<div class="art-chapter-inner">
|
||||
<div class="art-progress-hover"></div>
|
||||
<div class="art-progress-loaded"></div>
|
||||
<div class="art-progress-played"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let titleTimer = null;
|
||||
let $chapters = [];
|
||||
|
||||
const $progress = art.query(".art-control-progress");
|
||||
const $inner = art.query(".art-control-progress-inner");
|
||||
const $control = append($inner, '<div class="art-chapters"></div>');
|
||||
const $title = append($inner, '<div class="art-chapter-title"></div>');
|
||||
|
||||
function showTitle({ $chapter, width }) {
|
||||
const title = $chapter.dataset.title.trim();
|
||||
if (title) {
|
||||
setStyle($title, "display", "flex");
|
||||
$title.innerText = title;
|
||||
const titleWidth = $title.clientWidth;
|
||||
if (width <= titleWidth / 2) {
|
||||
setStyle($title, "left", 0);
|
||||
} else if (width > $inner.clientWidth - titleWidth / 2) {
|
||||
setStyle($title, "left", `${$inner.clientWidth - titleWidth}px`);
|
||||
} else {
|
||||
setStyle($title, "left", `${width - titleWidth / 2}px`);
|
||||
}
|
||||
} else {
|
||||
setStyle($title, "display", "none");
|
||||
}
|
||||
}
|
||||
|
||||
function update(chapters = []) {
|
||||
$chapters = [];
|
||||
$control.innerText = "";
|
||||
removeClass($player, "artplayer-plugin-chapter");
|
||||
|
||||
if (!Array.isArray(chapters)) return;
|
||||
if (!chapters.length) return;
|
||||
if (!art.duration) return;
|
||||
|
||||
chapters = chapters.sort((a, b) => a.start - b.start);
|
||||
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const chapter = chapters[i];
|
||||
const nextChapter = chapters[i + 1];
|
||||
|
||||
if (chapter.end === Infinity) {
|
||||
chapter.end = art.duration;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof chapter.start !== "number" ||
|
||||
typeof chapter.end !== "number" ||
|
||||
typeof chapter.title !== "string"
|
||||
) {
|
||||
throw new Error("Illegal chapter data type");
|
||||
}
|
||||
|
||||
if (
|
||||
chapter.start < 0 ||
|
||||
chapter.end > Math.ceil(art.duration) ||
|
||||
chapter.start >= chapter.end
|
||||
) {
|
||||
throw new Error("Illegal chapter time point");
|
||||
}
|
||||
|
||||
if (nextChapter && chapter.end > nextChapter.start) {
|
||||
throw new Error("Illegal chapter time point");
|
||||
}
|
||||
}
|
||||
|
||||
if (chapters[0].start > 0) {
|
||||
chapters.unshift({ start: 0, end: chapters[0].start, title: "" });
|
||||
}
|
||||
|
||||
if (chapters[chapters.length - 1].end < art.duration) {
|
||||
chapters.push({
|
||||
start: chapters[chapters.length - 1].end,
|
||||
end: art.duration,
|
||||
title: "",
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < chapters.length - 1; i++) {
|
||||
if (chapters[i].end !== chapters[i + 1].start) {
|
||||
chapters.splice(i + 1, 0, {
|
||||
start: chapters[i].end,
|
||||
end: chapters[i + 1].start,
|
||||
title: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$chapters = chapters.map((chapter) => {
|
||||
const $chapter = append($control, html);
|
||||
const start = clamp(chapter.start, 0, art.duration);
|
||||
const end = clamp(chapter.end, 0, art.duration);
|
||||
const duration = end - start;
|
||||
const percentage = duration / art.duration;
|
||||
$chapter.dataset.start = start;
|
||||
$chapter.dataset.end = end;
|
||||
$chapter.dataset.duration = duration;
|
||||
$chapter.dataset.title = chapter.title.trim();
|
||||
$chapter.style.width = `${percentage * 100}%`;
|
||||
|
||||
return {
|
||||
$chapter,
|
||||
$hover: query(".art-progress-hover", $chapter),
|
||||
$loaded: query(".art-progress-loaded", $chapter),
|
||||
$played: query(".art-progress-played", $chapter),
|
||||
};
|
||||
});
|
||||
|
||||
addClass($player, "artplayer-plugin-chapter");
|
||||
art.emit("setBar", "loaded", art.loaded || 0);
|
||||
}
|
||||
|
||||
art.on("setBar", (type, percentage, event) => {
|
||||
if (!$chapters.length) return;
|
||||
|
||||
for (let i = 0; i < $chapters.length; i++) {
|
||||
const { $chapter, $loaded, $played, $hover } = $chapters[i];
|
||||
|
||||
const $target = {
|
||||
hover: $hover,
|
||||
loaded: $loaded,
|
||||
played: $played,
|
||||
}[type];
|
||||
|
||||
if (!$target) return;
|
||||
|
||||
const width = $control.clientWidth * percentage;
|
||||
const currentTime = art.duration * percentage;
|
||||
const duration = parseFloat($chapter.dataset.duration);
|
||||
const start = parseFloat($chapter.dataset.start);
|
||||
const end = parseFloat($chapter.dataset.end);
|
||||
|
||||
if (currentTime < start) {
|
||||
setStyle($target, "width", 0);
|
||||
}
|
||||
|
||||
if (currentTime > end) {
|
||||
setStyle($target, "width", "100%");
|
||||
}
|
||||
|
||||
if (currentTime >= start && currentTime <= end) {
|
||||
const percentage = (currentTime - start) / duration;
|
||||
setStyle($target, "width", `${percentage * 100}%`);
|
||||
|
||||
if (isMobile) {
|
||||
if (type === "played" && event) {
|
||||
showTitle({ $chapter, width });
|
||||
clearTimeout(titleTimer);
|
||||
titleTimer = setTimeout(() => {
|
||||
setStyle($title, "display", "none");
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
if (type === "hover") {
|
||||
showTitle({ $chapter, width });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!isMobile) {
|
||||
art.proxy($progress, "mouseleave", () => {
|
||||
if (!$chapters.length) return;
|
||||
setStyle($title, "display", "none");
|
||||
});
|
||||
}
|
||||
|
||||
art.once("video:loadedmetadata", () => update(option.chapters));
|
||||
|
||||
return {
|
||||
name: "artplayerPluginChapter",
|
||||
update: ({ chapters }) => update(chapters),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
const id = "artplayer-plugin-chapter";
|
||||
const $style = document.getElementById(id);
|
||||
if ($style) {
|
||||
$style.textContent = style;
|
||||
} else {
|
||||
const $style = document.createElement("style");
|
||||
$style.id = id;
|
||||
$style.textContent = style;
|
||||
document.head.appendChild($style);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window["artplayerPluginChapter"] = artplayerPluginChapter;
|
||||
}
|
||||
49
src/components/player/artplayerPluginUploadSubtitle.js
Normal file
49
src/components/player/artplayerPluginUploadSubtitle.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { uploadIcon } from "./PlayerIcons";
|
||||
|
||||
export default function artplayerPluginUploadSubtitle() {
|
||||
return (art) => {
|
||||
const { getExt } = art.constructor.utils;
|
||||
|
||||
art.setting.add({
|
||||
html: `
|
||||
<div class="subtitle-upload-wrapper" style="position: relative;">
|
||||
<input
|
||||
type="file"
|
||||
name="subtitle-upload"
|
||||
id="subtitle-upload"
|
||||
style="display: none;"
|
||||
/>
|
||||
<label
|
||||
for="subtitle-upload"
|
||||
class="subtitle-upload-label"
|
||||
style="cursor: pointer; user-select: none;"
|
||||
>
|
||||
Upload Subtitle
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
icon: uploadIcon,
|
||||
onClick(setting, $setting) {
|
||||
const $input = $setting.querySelector("input[name='subtitle-upload']");
|
||||
const $label = $setting.querySelector(".subtitle-upload-label");
|
||||
|
||||
art.proxy($input, "change", (event) => {
|
||||
const file = event.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
art.subtitle.switch(url, {
|
||||
type: getExt(file.name),
|
||||
});
|
||||
|
||||
event.target.value = null;
|
||||
|
||||
// Update UI
|
||||
$label.textContent = file.name;
|
||||
art.notice.show = `Upload Subtitle :${file.name}`;
|
||||
setting.tooltip = file.name;
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
74
src/components/player/autoSkip.js
Normal file
74
src/components/player/autoSkip.js
Normal file
@@ -0,0 +1,74 @@
|
||||
export default function autoSkip(option) {
|
||||
function validateRanges(ranges) {
|
||||
if (!Array.isArray(ranges)) {
|
||||
throw new TypeError("Option must be an array of time ranges");
|
||||
}
|
||||
|
||||
ranges.forEach((range, index) => {
|
||||
if (!Array.isArray(range) || range.length !== 2) {
|
||||
throw new TypeError(
|
||||
`Range at index ${index} must be an array of two numbers`
|
||||
);
|
||||
}
|
||||
|
||||
const [start, end] = range;
|
||||
if (
|
||||
typeof start !== "number" ||
|
||||
(typeof end !== "number" && end !== Infinity)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Range at index ${index} must contain valid numbers or Infinity`
|
||||
);
|
||||
}
|
||||
|
||||
if (start > end && end !== Infinity) {
|
||||
throw new RangeError(
|
||||
`In range at index ${index}, start time must be less than end time`
|
||||
);
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
const prevEnd = ranges[index - 1][1];
|
||||
if (prevEnd !== Infinity && start <= prevEnd) {
|
||||
throw new RangeError(
|
||||
`Range at index ${index} overlaps with the previous range`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
validateRanges(option);
|
||||
return (art) => {
|
||||
let skipRanges = option;
|
||||
|
||||
function updateRanges() {
|
||||
const duration = art.duration;
|
||||
skipRanges = skipRanges.map(([start, end]) => [
|
||||
start,
|
||||
end === Infinity ? duration : end,
|
||||
]);
|
||||
}
|
||||
|
||||
function checkAndSkip() {
|
||||
const currentTime = art.currentTime;
|
||||
for (const [start, end] of skipRanges) {
|
||||
if (currentTime >= start && currentTime < end) {
|
||||
art.seek = end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
art.on("video:timeupdate", checkAndSkip);
|
||||
art.on("video:loadedmetadata", updateRanges);
|
||||
|
||||
return {
|
||||
name: "autoSkip",
|
||||
update(newOption = []) {
|
||||
validateRanges(newOption);
|
||||
skipRanges = newOption;
|
||||
updateRanges();
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
82
src/components/player/getChapterStyle.js
Normal file
82
src/components/player/getChapterStyle.js
Normal file
@@ -0,0 +1,82 @@
|
||||
export default function getChapterStyles(intro, outro) {
|
||||
let styles = `
|
||||
.art-chapters {
|
||||
gap: 0px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
if (intro && outro) {
|
||||
if (
|
||||
intro.start === 0 &&
|
||||
intro.end === 0 &&
|
||||
outro.start === 0 &&
|
||||
outro.end === 0
|
||||
) {
|
||||
styles += ``;
|
||||
} else if (
|
||||
intro.start === 0 &&
|
||||
intro.end === 0 &&
|
||||
outro.start !== 0 &&
|
||||
outro.end !== 0
|
||||
) {
|
||||
styles += `
|
||||
.art-chapter:nth-child(2) {
|
||||
background-color: #fdd253;
|
||||
transform: scaleY(0.6);
|
||||
}
|
||||
`;
|
||||
} else if (
|
||||
intro.start === 0 &&
|
||||
intro.end !== 0 &&
|
||||
outro.start === 0 &&
|
||||
outro.end === 0
|
||||
) {
|
||||
styles += `
|
||||
.art-chapter:nth-child(1){
|
||||
background-color: #fdd253;
|
||||
transform: scaleY(0.6);
|
||||
}
|
||||
`;
|
||||
} else if (
|
||||
intro.start === 0 &&
|
||||
intro.end !== 0 &&
|
||||
outro.start !== 0 &&
|
||||
outro.end !== 0
|
||||
) {
|
||||
styles += `
|
||||
.art-chapter:nth-child(1),
|
||||
.art-chapter:nth-child(3) {
|
||||
background-color: #fdd253;
|
||||
transform: scaleY(0.6);
|
||||
}
|
||||
`;
|
||||
} else if (
|
||||
intro.start !== 0 &&
|
||||
intro.end !== 0 &&
|
||||
outro.start === 0 &&
|
||||
outro.end === 0
|
||||
) {
|
||||
styles += `
|
||||
.art-chapter:nth-child(2) {
|
||||
background-color: #fdd253;
|
||||
transform: scaleY(0.6);
|
||||
}
|
||||
`;
|
||||
} else if (
|
||||
intro.start !== 0 &&
|
||||
intro.end !== 0 &&
|
||||
outro.start !== 0 &&
|
||||
outro.end !== 0
|
||||
) {
|
||||
styles += `
|
||||
.art-chapter:nth-child(2),
|
||||
.art-chapter:nth-child(4) {
|
||||
background-color: #fdd253;
|
||||
transform: scaleY(0.6);
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return styles;
|
||||
}
|
||||
101
src/components/player/getVttArray.js
Normal file
101
src/components/player/getVttArray.js
Normal file
@@ -0,0 +1,101 @@
|
||||
function padEnd(str, targetLength, padString) {
|
||||
if (str.length > targetLength) {
|
||||
return String(str);
|
||||
} else {
|
||||
targetLength = targetLength - str.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length);
|
||||
}
|
||||
return String(str) + padString.slice(0, targetLength);
|
||||
}
|
||||
}
|
||||
|
||||
function t2d(time) {
|
||||
var arr = time.split(".");
|
||||
var left = arr[0].split(":") || [];
|
||||
var right = padEnd(arr[1] || "0", 3, "0");
|
||||
var ms = Number(right) / 1000;
|
||||
|
||||
var h = Number(left[left.length - 3] || 0) * 3600;
|
||||
var m = Number(left[left.length - 2] || 0) * 60;
|
||||
var s = Number(left[left.length - 1] || 0);
|
||||
return h + m + s + ms;
|
||||
}
|
||||
|
||||
export default async function getVttArray(vttUrl = "") {
|
||||
const vttString = await (await fetch(vttUrl)).text();
|
||||
let lines = vttString.split(/\r?\n/).filter((item) => item.trim());
|
||||
const vttArray = [];
|
||||
|
||||
//checking if the WEBVTT header is present
|
||||
const isWebVTTHeader = lines[0].trim().toUpperCase() === "WEBVTT";
|
||||
|
||||
let startIndex = 0;
|
||||
let increment = 2;
|
||||
|
||||
// Check if the first line is an index line
|
||||
const indexLineReg = /^\d+$/; // Regex to match lines containing only digits
|
||||
|
||||
if (!isWebVTTHeader && indexLineReg.test(lines[0].trim())) {
|
||||
// console.log("WEBVTT not present but index line is present");
|
||||
increment = 3; // Set increment to 3 if an index line is present
|
||||
startIndex = 1; // Start from the second line
|
||||
} else if (isWebVTTHeader) {
|
||||
// If WEBVTT is present, check the next line
|
||||
// console.log("WEBVTT lines is present checking if index line is present...");
|
||||
const indexLine = lines[1];
|
||||
if (indexLine && indexLineReg.test(indexLine.trim())) {
|
||||
// console.log("Index line is present");
|
||||
increment = 3; // Set increment to 3 if an index line is present
|
||||
startIndex = 2; // Start from the line after the index
|
||||
} else {
|
||||
// console.log("Index line is not present");
|
||||
startIndex = 1; // If no index line, start from the line after WEBVTT
|
||||
increment = 2; // Set increment to 2
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startIndex; i < lines.length; i += increment) {
|
||||
const time = lines[i];
|
||||
const text = lines[i + 1];
|
||||
if (!text.trim()) continue;
|
||||
|
||||
// console.log(`Processing time line: ${time}`); // Logging processing timestamps
|
||||
|
||||
const timeReg =
|
||||
/((?:[0-9]{2}:)?(?:[0-9]{2}:)?[0-9]{2}(?:.[0-9]{3})?)(?: ?--> ?)((?:[0-9]{2}:)?(?:[0-9]{2}:)?[0-9]{2}(?:.[0-9]{3})?)/;
|
||||
const timeMatch = time.match(timeReg);
|
||||
|
||||
if (!timeMatch) {
|
||||
// console.warn(`Failed to match time: ${time}`); // Log failed matches
|
||||
continue; // Skip to the next loop iteration if match fails
|
||||
}
|
||||
|
||||
const textReg = /(.*)#(\w{4})=(.*)/i;
|
||||
const textMatch = text.match(textReg);
|
||||
const start = Math.floor(t2d(timeMatch[1]));
|
||||
const end = Math.floor(t2d(timeMatch[2]));
|
||||
|
||||
let url = textMatch[1];
|
||||
const isAbsoluteUrl = /^\/|((https?|ftp|file):\/\/)/i.test(url);
|
||||
if (!isAbsoluteUrl) {
|
||||
const urlArr = vttUrl.split("/");
|
||||
urlArr.pop();
|
||||
urlArr.push(url);
|
||||
url = urlArr.join("/");
|
||||
}
|
||||
|
||||
const result = { start, end, url };
|
||||
|
||||
const keys = textMatch[2].split("");
|
||||
const values = textMatch[3].split(",");
|
||||
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
result[keys[j]] = values[j];
|
||||
}
|
||||
|
||||
vttArray.push(result);
|
||||
}
|
||||
|
||||
return vttArray;
|
||||
}
|
||||
55
src/components/player/pluginChapterStyle.js
Normal file
55
src/components/player/pluginChapterStyle.js
Normal file
@@ -0,0 +1,55 @@
|
||||
export default `
|
||||
.artplayer-plugin-chapter .art-control-progress-inner {
|
||||
height: 100% !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-hover,
|
||||
.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-loaded,
|
||||
.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-played {
|
||||
display: none !important;
|
||||
}
|
||||
.artplayer-plugin-chapter .art-control-thumbnails {
|
||||
bottom: calc(var(--art-bottom-gap) + 64px) !important;
|
||||
}
|
||||
.artplayer-plugin-chapter .art-chapters {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
transform: scaleY(1.25);
|
||||
}
|
||||
.artplayer-plugin-chapter .art-chapters .art-chapter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: height var(--art-transition-duration) ease;
|
||||
background-color: var(--art-progress-color);
|
||||
}
|
||||
.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner {
|
||||
height: 100%;
|
||||
}
|
||||
.artplayer-plugin-chapter .art-chapter-title {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
top: -50px;
|
||||
left: 0;
|
||||
padding: 3px 5px;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
border-radius: var(--art-border-radius);
|
||||
white-space: nowrap;
|
||||
background-color: var(--art-tip-background);
|
||||
}
|
||||
`;
|
||||
102
src/components/producer/Producer.jsx
Normal file
102
src/components/producer/Producer.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import Error from "../error/Error";
|
||||
import Topten from "../topten/Topten";
|
||||
import Genre from "../genres/Genre";
|
||||
import SidecardLoader from "../Loader/Sidecard.loader";
|
||||
import PageSlider from "../pageslider/PageSlider";
|
||||
import CategoryCard from "../categorycard/CategoryCard";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHomeInfo } from "@/src/context/HomeInfoContext";
|
||||
import getProducer from "@/src/utils/getProducer.utils";
|
||||
import Loader from "../Loader/Loader";
|
||||
|
||||
function Producer() {
|
||||
const { id } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [producerInfo, setProducerInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const page = parseInt(searchParams.get("page")) || 1;
|
||||
const { homeInfo, homeInfoLoading } = useHomeInfo();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
const fetchProducerInfo = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getProducer(id, page);
|
||||
setProducerInfo(data.data);
|
||||
setTotalPages(data.totalPages);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
console.error("Error fetching category info:", err);
|
||||
}
|
||||
};
|
||||
fetchProducerInfo();
|
||||
window.scrollTo(0, 0);
|
||||
}, [id, page]);
|
||||
if (loading) return <Loader type="producer" />;
|
||||
if (error) {
|
||||
navigate("/error-page");
|
||||
return <Error />;
|
||||
}
|
||||
if (!producerInfo) {
|
||||
navigate("/404-not-found-page");
|
||||
return null;
|
||||
}
|
||||
const handlePageChange = (newPage) => {
|
||||
setSearchParams({ page: newPage });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-y-4 mt-[100px] max-md:mt-[50px]">
|
||||
{producerInfo ? (
|
||||
<div className="w-full px-4 grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex max-[1200px]:flex-col max-[1200px]:gap-y-10">
|
||||
{page > totalPages ? (
|
||||
<p className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px] max-[300px]:leading-6">
|
||||
You came a long way, go back <br className="max-[300px]:hidden" />
|
||||
nothing is here
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{producerInfo && (
|
||||
<CategoryCard
|
||||
label={
|
||||
(id.charAt(0).toUpperCase() + id.slice(1))
|
||||
.split("-")
|
||||
.join(" ") + " Anime"
|
||||
}
|
||||
data={producerInfo}
|
||||
showViewMore={false}
|
||||
className={"mt-0"}
|
||||
categoryPage={true}
|
||||
/>
|
||||
)}
|
||||
<PageSlider
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-y-10">
|
||||
{homeInfoLoading ? (
|
||||
<SidecardLoader />
|
||||
) : (
|
||||
<>
|
||||
{homeInfo && homeInfo.topten && (
|
||||
<Topten data={homeInfo.topten} className="mt-0" />
|
||||
)}
|
||||
{homeInfo?.genres && <Genre data={homeInfo.genres} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Error />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default Producer;
|
||||
159
src/components/qtip/Qtip.jsx
Normal file
159
src/components/qtip/Qtip.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import BouncingLoader from "../ui/bouncingloader/Bouncingloader";
|
||||
import getQtip from "@/src/utils/getQtip.utils";
|
||||
import { useState, useEffect } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faPlay,
|
||||
faStar,
|
||||
faClosedCaptioning,
|
||||
faMicrophone,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function Qtip({ id }) {
|
||||
const [qtip, setQtip] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQtipInfo = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getQtip(id);
|
||||
setQtip(data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching anime info:", err);
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchQtipInfo();
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className="w-[320px] h-fit rounded-xl p-4 flex justify-center items-center bg-[#3e3c50] bg-opacity-70 backdrop-blur-[10px] z-50">
|
||||
{loading || error || !qtip ? (
|
||||
<BouncingLoader />
|
||||
) : (
|
||||
<div className="w-full flex flex-col justify-start gap-y-2">
|
||||
<h1 className="text-xl font-semibold text-white text-[13px] leading-6">
|
||||
{qtip.title}
|
||||
</h1>
|
||||
<div className="w-full flex items-center relative mt-2">
|
||||
{qtip?.rating && (
|
||||
<div className="flex gap-x-2 items-center">
|
||||
<FontAwesomeIcon icon={faStar} className="text-[#ffc107]" />
|
||||
<p className="text-[#b7b7b8]">{qtip.rating}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex ml-4 gap-x-[1px] overflow-hidden rounded-md items-center h-fit">
|
||||
{qtip?.quality && (
|
||||
<div className="bg-[#ffbade] px-[7px] w-fit flex justify-center items-center py-[1px] text-black">
|
||||
<p className="text-[12px] font-semibold">{qtip.quality}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-x-[1px] w-fit items-center py-[1px]">
|
||||
{qtip?.subCount && (
|
||||
<div className="flex gap-x-1 justify-center items-center bg-[#B0E3AF] px-[7px] text-black">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
<p className="text-[13px] font-semibold">{qtip.subCount}</p>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.dubCount && (
|
||||
<div className="flex gap-x-1 justify-center items-center bg-[#B9E7FF] px-[7px] text-black">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[13px]"
|
||||
/>
|
||||
<p className="text-[13px] font-semibold">{qtip.dubCount}</p>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.episodeCount && (
|
||||
<div className="flex gap-x-1 justify-center items-center bg-[#a199a3] px-[7px] text-black">
|
||||
<p className="text-[13px] font-semibold">
|
||||
{qtip.episodeCount}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{qtip?.type && (
|
||||
<div className="absolute right-0 top-0 justify-center items-center rounded-sm bg-[#ffbade] px-[6px] text-black">
|
||||
<p className="font-semibold text-[13px]">{qtip.type}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{qtip?.description && (
|
||||
<p className="text-[#d7d7d8] text-[13px] leading-4 font-light line-clamp-3 mt-1">
|
||||
{qtip.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col mt-1">
|
||||
{qtip?.japaneseTitle && (
|
||||
<div className="leading-4">
|
||||
<span className="text-[#b7b7b8] text-[13px]">
|
||||
Japanese:
|
||||
</span>
|
||||
<span className="text-[13px]">{qtip.japaneseTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.Synonyms && (
|
||||
<div className="leading-4">
|
||||
<span className="text-[#b7b7b8] text-[13px]">
|
||||
Synonyms:
|
||||
</span>
|
||||
<span className="text-[13px]">{qtip.Synonyms}</span>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.airedDate && (
|
||||
<div className="leading-4">
|
||||
<span className="text-[#b7b7b8] text-[13px]">Aired: </span>
|
||||
<span className="text-[13px]">{qtip.airedDate}</span>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.status && (
|
||||
<div className="leading-4">
|
||||
<span className="text-[#b7b7b8] text-[13px]">
|
||||
Status:
|
||||
</span>
|
||||
<span className="text-[13px]">{qtip.status}</span>
|
||||
</div>
|
||||
)}
|
||||
{qtip?.genres && (
|
||||
<div className="leading-4 flex flex-wrap text-wrap">
|
||||
<span className="text-[#b7b7b8] text-[13px]">
|
||||
Genres:
|
||||
</span>
|
||||
{qtip.genres.map((genre, index) => (
|
||||
<Link
|
||||
to={`/genre/${genre}`}
|
||||
key={index}
|
||||
className="text-[13px] hover:text-[#ffbade]"
|
||||
>
|
||||
<span>
|
||||
{genre}
|
||||
{index === qtip.genres.length - 1 ? "" : ","}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={qtip.watchLink}
|
||||
className="w-[80%] flex mt-4 justify-center items-center gap-x-2 bg-[#ffbade] py-[9px] rounded-3xl"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlay} className="text-[14px] text-black" />
|
||||
<p className="text-[14px] font-semibold text-black">Watch Now</p>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Qtip;
|
||||
241
src/components/schedule/Schedule.jsx
Normal file
241
src/components/schedule/Schedule.jsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import getSchedInfo from "../../utils/getScheduleInfo.utils";
|
||||
import { Pagination, Navigation } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { FaChevronLeft, FaChevronRight } from "react-icons/fa";
|
||||
import BouncingLoader from "../ui/bouncingloader/Bouncingloader";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPlay } from "@fortawesome/free-solid-svg-icons";
|
||||
import "./schedule.css";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const Schedule = () => {
|
||||
const [dates, setDates] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [currentActiveIndex, setCurrentActiveIndex] = useState(null);
|
||||
const [scheduleData, setscheduleData] = useState([]);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const cardRefs = useRef([]);
|
||||
const swiperRef = useRef(null);
|
||||
const currentDate = new Date();
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const monthName = currentDate.toLocaleString("default", { month: "short" });
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const GMTOffset = `GMT ${
|
||||
new Date().getTimezoneOffset() > 0 ? "-" : "+"
|
||||
}${String(Math.floor(Math.abs(new Date().getTimezoneOffset()) / 60)).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}:${String(Math.abs(new Date().getTimezoneOffset()) % 60).padStart(2, "0")}`;
|
||||
const months = [];
|
||||
|
||||
useEffect(() => {
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(year, month, day);
|
||||
const dayname = date.toLocaleString("default", { weekday: "short" });
|
||||
const yearr = date.getFullYear();
|
||||
const monthh = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dayy = String(date.getDate()).padStart(2, "0");
|
||||
const fulldate = `${yearr}-${monthh}-${dayy}`;
|
||||
months.push({ day, monthName, dayname, fulldate });
|
||||
}
|
||||
setDates(months);
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const todayIndex = dates.findIndex(
|
||||
(date) =>
|
||||
date.fulldate ===
|
||||
`${currentDate.getFullYear()}-${String(
|
||||
currentDate.getMonth() + 1
|
||||
).padStart(2, "0")}-${String(currentDate.getDate()).padStart(2, "0")}`
|
||||
);
|
||||
|
||||
if (todayIndex !== -1) {
|
||||
setCurrentActiveIndex(todayIndex);
|
||||
toggleActive(todayIndex);
|
||||
}
|
||||
}, [dates]);
|
||||
|
||||
const fetchSched = async (date) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Check if cached data exists
|
||||
const cachedData = localStorage.getItem(`schedule-${date}`);
|
||||
if (cachedData) {
|
||||
const parsedData = JSON.parse(cachedData);
|
||||
setscheduleData(Array.isArray(parsedData) ? parsedData : []);
|
||||
} else {
|
||||
const data = await getSchedInfo(date);
|
||||
setscheduleData(Array.isArray(data) ? data : []);
|
||||
localStorage.setItem(`schedule-${date}`, JSON.stringify(data || []));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching schedule info:", err);
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = (index) => {
|
||||
cardRefs.current.forEach((card) => {
|
||||
if (card) {
|
||||
card.classList.remove("active");
|
||||
}
|
||||
});
|
||||
if (cardRefs.current[index]) {
|
||||
cardRefs.current[index].classList.add("active");
|
||||
if (dates[index] && dates[index].fulldate) {
|
||||
fetchSched(dates[index].fulldate);
|
||||
}
|
||||
setCurrentActiveIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShowAll = () => {
|
||||
setShowAll(!showAll);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setShowAll(false);
|
||||
if (currentActiveIndex !== null && swiperRef.current) {
|
||||
swiperRef.current.slideTo(currentActiveIndex);
|
||||
}
|
||||
}, [currentActiveIndex]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full mt-[60px] max-[480px]:mt-[40px]">
|
||||
<div className="flex items-center justify-between max-[570px]:flex-col max-[570px]:items-start max-[570px]:gap-y-2">
|
||||
<div className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px]">
|
||||
Estimated Schedule
|
||||
</div>
|
||||
<p className="leading-[28px] px-[10px] bg-white text-black rounded-full my-[6px] text-[16px] font-bold max-[478px]:text-[12px] max-[275px]:text-[10px]">
|
||||
({GMTOffset}) {currentTime.toLocaleDateString()}{" "}
|
||||
{currentTime.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full overflow-x-scroll space-x-4 scrollbar-hide pt-10 px-6 max-[480px]:px-4 max-[478px]:pt-4">
|
||||
<div className="relative w-full">
|
||||
<Swiper
|
||||
slidesPerView={3}
|
||||
spaceBetween={2}
|
||||
breakpoints={{
|
||||
250: { slidesPerView: 3, spaceBetween: 10 },
|
||||
640: { slidesPerView: 4, spaceBetween: 10 },
|
||||
768: { slidesPerView: 5, spaceBetween: 10 },
|
||||
1024: { slidesPerView: 7, spaceBetween: 10 },
|
||||
1300: { slidesPerView: 7, spaceBetween: 15 },
|
||||
}}
|
||||
modules={[Pagination, Navigation]}
|
||||
navigation={{
|
||||
nextEl: ".next",
|
||||
prevEl: ".prev",
|
||||
}}
|
||||
onSwiper={(swiper) => (swiperRef.current = swiper)}
|
||||
>
|
||||
{dates &&
|
||||
dates.map((date, index) => (
|
||||
<SwiperSlide key={index}>
|
||||
<div
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
onClick={() => toggleActive(index)}
|
||||
className={`h-[70px] flex flex-col justify-center items-center w-full text-center rounded-xl shadow-lg cursor-pointer ${
|
||||
currentActiveIndex === index
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-white bg-opacity-5 text-[#ffffff] hover:bg-[#373646] transition-all duration-300 ease-in-out"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[18px] font-bold max-[400px]:text-[14px] max-[350px]:text-[12px]">
|
||||
{date.dayname}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[14px] max-[400px]:text-[12px] ${
|
||||
currentActiveIndex === index
|
||||
? "text-black"
|
||||
: "text-gray-400"
|
||||
} max-[350px]:text-[10px]`}
|
||||
>
|
||||
{date.monthName} {date.day}
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
<button className="next absolute top-1/2 right-[-15px] transform -translate-y-1/2 flex justify-center items-center cursor-pointer">
|
||||
<FaChevronRight className="text-[12px]" />
|
||||
</button>
|
||||
<button className="prev absolute top-1/2 left-[-15px] transform -translate-y-1/2 flex justify-center items-center cursor-pointer">
|
||||
<FaChevronLeft className="text-[12px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="w-full h-[70px] flex justify-center items-center">
|
||||
<BouncingLoader />
|
||||
</div>
|
||||
) : !scheduleData || scheduleData.length === 0 ? (
|
||||
<div className="w-full h-[70px] flex justify-center items-center mt-5 text-xl">
|
||||
No data to display
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="w-full h-[70px] flex justify-center items-center mt-5 text-xl">
|
||||
Something went wrong
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col mt-5 items-start">
|
||||
{(showAll
|
||||
? scheduleData
|
||||
: Array.isArray(scheduleData)
|
||||
? scheduleData.slice(0, 7)
|
||||
: []
|
||||
).map((item, idx) => (
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
key={idx}
|
||||
className="w-full flex justify-between py-4 border-[#FFFFFF0D] border-b-[1px] group cursor-pointer max-[325px]:py-2"
|
||||
>
|
||||
<div className="flex items-center max-w-[500px] gap-x-7 max-[400px]:gap-x-2">
|
||||
<div className="text-lg font-semibold text-[#ffffff59] group-hover:text-[#ffbade] transition-all duration-300 ease-in-out max-[600px]:text-[14px] max-[275px]:text-[12px]">
|
||||
{item.time || "N/A"}
|
||||
</div>
|
||||
<h3 className="text-[17px] font-semibold line-clamp-1 group-hover:text-[#ffbade] transition-all duration-300 ease-in-out max-[600px]:text-[14px] max-[275px]:text-[12px]">
|
||||
{item.title || "N/A"}
|
||||
</h3>
|
||||
</div>
|
||||
<button className="max-w-[150px] flex items-center py-1 px-4 rounded-lg gap-x-2 group-hover:bg-[#ffbade] transition-all duration-300 ease-in-out">
|
||||
<FontAwesomeIcon
|
||||
icon={faPlay}
|
||||
className="mt-[1px] text-[10px] max-[320px]:text-[8px] group-hover:text-black transition-all duration-300 ease-in-out"
|
||||
/>
|
||||
<p className="text-[14px] text-white group-hover:text-black transition-all duration-300 ease-in-out max-[275px]:text-[12px]">
|
||||
Episode {item.episode_no || "N/A"}
|
||||
</p>
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
{scheduleData.length > 7 && (
|
||||
<button
|
||||
onClick={toggleShowAll}
|
||||
className="text-white py-4 hover:text-[#ffbade] font-semibold transition-all duration-300 ease-in-out max-sm:text-[13px]"
|
||||
>
|
||||
{showAll ? "Show Less" : "Show More"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Schedule;
|
||||
11
src/components/schedule/schedule.css
Normal file
11
src/components/schedule/schedule.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.next,
|
||||
.prev {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 100%;
|
||||
background-color: white;
|
||||
color: black;
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
73
src/components/searchbar/MobileSearch.jsx
Normal file
73
src/components/searchbar/MobileSearch.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Suggestion from '../suggestion/Suggestion';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons';
|
||||
import useSearch from '@/src/hooks/useSearch';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function MobileSearch() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
isSearchVisible,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
isFocused,
|
||||
setIsFocused,
|
||||
debouncedValue,
|
||||
suggestionRefs,
|
||||
addSuggestionRef,
|
||||
} = useSearch();
|
||||
const handleSearchClick = () => {
|
||||
if (searchValue.trim() && window.innerWidth <= 600) {
|
||||
navigate(`/search?keyword=${encodeURIComponent(searchValue)}`);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{isSearchVisible && (
|
||||
<div className="flex w-full mt-2 relative custom-md:hidden ">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-white px-4 py-2 text-black focus:outline-none w-full rounded-l-md"
|
||||
placeholder="Search anime..."
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
const isInsideSuggestionBox = suggestionRefs.current.some(
|
||||
(ref) => ref && ref.contains(document.activeElement),
|
||||
);
|
||||
if (!isInsideSuggestionBox) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearchClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button className="flex items-center justify-center p-2 bg-white rounded-r-md"
|
||||
onClick={handleSearchClick}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className="text-black text-lg"
|
||||
/>
|
||||
</button>
|
||||
{searchValue.trim() && isFocused && (
|
||||
<div
|
||||
ref={addSuggestionRef}
|
||||
className="absolute z-[100000] top-full w-full"
|
||||
>
|
||||
<Suggestion keyword={debouncedValue} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileSearch;
|
||||
77
src/components/searchbar/WebSearch.jsx
Normal file
77
src/components/searchbar/WebSearch.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import Suggestion from "../suggestion/Suggestion";
|
||||
import useSearch from "@/src/hooks/useSearch";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function WebSearch() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
setIsSearchVisible,
|
||||
searchValue,
|
||||
setSearchValue,
|
||||
isFocused,
|
||||
setIsFocused,
|
||||
debouncedValue,
|
||||
suggestionRefs,
|
||||
addSuggestionRef,
|
||||
} = useSearch();
|
||||
|
||||
const handleSearchClick = () => {
|
||||
if (window.innerWidth <= 600) {
|
||||
setIsSearchVisible((prev) => !prev);
|
||||
}
|
||||
if (searchValue.trim() && window.innerWidth > 600) {
|
||||
navigate(`/search?keyword=${encodeURIComponent(searchValue)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center relative w-[380px] max-[600px]:w-fit">
|
||||
<input
|
||||
type="text"
|
||||
className="bg-white px-4 py-2 text-black focus:outline-none w-full max-[600px]:hidden"
|
||||
placeholder="Search anime..."
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
const isInsideSuggestionBox = suggestionRefs.current.some(
|
||||
(ref) => ref && ref.contains(document.activeElement),
|
||||
);
|
||||
if (!isInsideSuggestionBox) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (searchValue.trim()) {
|
||||
navigate(`/search?keyword=${encodeURIComponent(searchValue)}`);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="bg-white p-2 max-[600px]:bg-transparent focus:outline-none max-[600px]:p-0"
|
||||
onClick={handleSearchClick}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className="text-lg text-black hover:text-[#ffbade] max-[600px]:text-white max-[600px]:text-2xl max-[575px]:text-xl max-[600px]:mt-[7px]"
|
||||
/>
|
||||
</button>
|
||||
{searchValue.trim() && isFocused && (
|
||||
<div
|
||||
ref={addSuggestionRef}
|
||||
className="absolute z-[100000] top-full w-full"
|
||||
>
|
||||
<Suggestion keyword={debouncedValue} className="w-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebSearch;
|
||||
9
src/components/servers/Servers.css
Normal file
9
src/components/servers/Servers.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.servers {
|
||||
border-bottom: 1px dashed #35373d;
|
||||
}
|
||||
.servers:only-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.servers:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
187
src/components/servers/Servers.jsx
Normal file
187
src/components/servers/Servers.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import {
|
||||
faClosedCaptioning,
|
||||
faFile,
|
||||
faMicrophone,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import BouncingLoader from "../ui/bouncingloader/Bouncingloader";
|
||||
import "./Servers.css";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function Servers({
|
||||
servers,
|
||||
activeEpisodeNum,
|
||||
activeServerId,
|
||||
setActiveServerId,
|
||||
serverLoading,
|
||||
setActiveServerType,
|
||||
setActiveServerName,
|
||||
}) {
|
||||
const subServers =
|
||||
servers?.filter((server) => server.type === "sub") || [];
|
||||
const dubServers =
|
||||
servers?.filter((server) => server.type === "dub") || [];
|
||||
const rawServers =
|
||||
servers?.filter((server) => server.type === "raw") || [];
|
||||
|
||||
useEffect(() => {
|
||||
const savedServerName = localStorage.getItem("server_name");
|
||||
if (savedServerName) {
|
||||
const matchingServer = servers?.find(
|
||||
(server) => server.serverName === savedServerName,
|
||||
);
|
||||
|
||||
if (matchingServer) {
|
||||
setActiveServerId(matchingServer.data_id);
|
||||
setActiveServerType(matchingServer.type);
|
||||
} else if (servers && servers.length > 0) {
|
||||
setActiveServerId(servers[0].data_id);
|
||||
setActiveServerType(servers[0].type);
|
||||
}
|
||||
} else if (servers && servers.length > 0) {
|
||||
setActiveServerId(servers[0].data_id);
|
||||
setActiveServerType(servers[0].type);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [servers]);
|
||||
|
||||
const handleServerSelect = (server) => {
|
||||
setActiveServerId(server.data_id);
|
||||
setActiveServerType(server.type);
|
||||
setActiveServerName(server.serverName);
|
||||
localStorage.setItem("server_name", server.serverName);
|
||||
localStorage.setItem("server_type", server.type);
|
||||
};
|
||||
return (
|
||||
<div className="relative bg-[#11101A] p-4 w-full min-h-[100px] flex justify-center items-center max-[1200px]:bg-[#14151A]">
|
||||
{serverLoading ? (
|
||||
<div className="w-full h-full rounded-lg flex justify-center items-center max-[600px]:rounded-none">
|
||||
<BouncingLoader />
|
||||
</div>
|
||||
) : servers ? (
|
||||
<div className="w-full h-full rounded-lg grid grid-cols-[minmax(0,30%),minmax(0,70%)] overflow-hidden max-[800px]:grid-cols-[minmax(0,40%),minmax(0,60%)] max-[600px]:flex max-[600px]:flex-col max-[600px]:rounded-none">
|
||||
<div className="h-full bg-[#ffbade] px-6 text-black flex flex-col justify-center items-center gap-y-2 max-[600px]:bg-transparent max-[600px]:h-1/2 max-[600px]:text-white max-[600px]:mb-4">
|
||||
<p className="text-center leading-5 font-medium text-[14px]">
|
||||
You are watching <br />
|
||||
<span className="font-semibold max-[600px]:text-[#ffbade]">
|
||||
Episode {activeEpisodeNum}
|
||||
</span>
|
||||
</p>
|
||||
<p className="leading-5 text-[14px] font-medium text-center">
|
||||
If the current server doesn't work, please try other servers
|
||||
beside.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-[#201F31] flex flex-col max-[600px]:h-full">
|
||||
{rawServers.length > 0 && (
|
||||
<div
|
||||
className={`servers px-2 flex items-center flex-wrap ml-2 max-[600px]:py-2 ${
|
||||
dubServers.length === 0 || subServers.length === 0
|
||||
? "h-1/2"
|
||||
: "h-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faFile}
|
||||
className="text-[#ffbade] text-[13px]"
|
||||
/>
|
||||
<p className="font-bold text-[14px]">RAW:</p>
|
||||
</div>
|
||||
<div className="flex gap-x-[7px] ml-8 flex-wrap">
|
||||
{rawServers.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`px-6 py-[5px] rounded-lg cursor-pointer ${
|
||||
activeServerId === item?.data_id
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-[#373646] text-white"
|
||||
} max-[700px]:px-3`}
|
||||
onClick={() => handleServerSelect(item)}
|
||||
>
|
||||
<p className="text-[13px] font-semibold">
|
||||
{item.serverName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subServers.length > 0 && (
|
||||
<div
|
||||
className={`servers px-2 flex items-center flex-wrap ml-2 max-[600px]:py-2 ${
|
||||
dubServers.length === 0 ? "h-1/2" : "h-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[#ffbade] text-[13px]"
|
||||
/>
|
||||
<p className="font-bold text-[14px]">SUB:</p>
|
||||
</div>
|
||||
<div className="flex gap-x-[7px] ml-8 flex-wrap">
|
||||
{subServers.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`px-6 py-[5px] rounded-lg cursor-pointer ${
|
||||
activeServerId === item?.data_id
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-[#373646] text-white"
|
||||
} max-[700px]:px-3`}
|
||||
onClick={() => handleServerSelect(item)}
|
||||
>
|
||||
<p className="text-[13px] font-semibold">
|
||||
{item.serverName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dubServers.length > 0 && (
|
||||
<div
|
||||
className={`servers px-2 flex items-center flex-wrap ml-2 max-[600px]:py-2 ${
|
||||
subServers.length === 0 ? "h-1/2 " : "h-full"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[#ffbade] text-[13px]"
|
||||
/>
|
||||
<p className="font-bold text-[14px]">DUB:</p>
|
||||
</div>
|
||||
<div className="flex gap-x-[7px] ml-8 flex-wrap">
|
||||
{dubServers.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`px-6 py-[5px] rounded-lg cursor-pointer ${
|
||||
activeServerId === item?.data_id
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-[#373646] text-white"
|
||||
} max-[700px]:px-3`}
|
||||
onClick={() => handleServerSelect(item)}
|
||||
>
|
||||
<p className="text-[13px] font-semibold">
|
||||
{item.serverName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center font-medium text-[15px] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10 pointer-events-none">
|
||||
Could not load servers <br />
|
||||
Either reload or try again after sometime
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Servers;
|
||||
141
src/components/sidebar/Sidebar.jsx
Normal file
141
src/components/sidebar/Sidebar.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { FaChevronLeft } from "react-icons/fa";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faFilm, faRandom } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import { useEffect } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import {
|
||||
cleanupScrollbar,
|
||||
toggleScrollbar,
|
||||
} from "@/src/helper/toggleScrollbar";
|
||||
|
||||
const Sidebar = ({ isOpen, onClose }) => {
|
||||
const { language, toggleLanguage } = useLanguage();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
toggleScrollbar(isOpen);
|
||||
return () => {
|
||||
cleanupScrollbar();
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
onClose();
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`fixed top-0 left-0 bottom-0 right-0 w-screen h-screen transform transition-all duration-400 ease-in-out ${
|
||||
isOpen ? "backdrop-blur-lg" : "backdrop-blur-none"
|
||||
}`}
|
||||
onClick={onClose}
|
||||
style={{ zIndex: 1000000, background: "rgba(32, 31, 49, .8)" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`fixed h-full top-0 left-0 z-50 flex transition-transform duration-300 ease-in-out ${
|
||||
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
style={{ zIndex: 1000200 }}
|
||||
>
|
||||
<div
|
||||
className="bg-white/10 w-[260px] py-8 h-full flex flex-col items-start max-[575px]:w-56 overflow-y-auto sidebar"
|
||||
style={{
|
||||
zIndex: 300,
|
||||
borderRight: "1px solid rgba(0, 0, 0, .1)",
|
||||
}}
|
||||
>
|
||||
<div className="px-4 w-full">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full text-white flex items-baseline h-fit gap-x-1 z-[100] px-3 py-2 bg-[#4f4d6e] rounded-3xl"
|
||||
>
|
||||
<FaChevronLeft className="text-sm font-bold" />
|
||||
<p>Close menu</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-x-7 w-full py-3 justify-center px-auto mt-8 bg-black/10 max-[575px]:gap-x-4 lg:hidden">
|
||||
{[
|
||||
{ icon: faRandom, label: "Random" },
|
||||
{ icon: faFilm, label: "Movie" },
|
||||
].map((item, index) => (
|
||||
<Link
|
||||
to={`/${item.label}`}
|
||||
key={index}
|
||||
className="flex flex-col gap-y-1 items-center"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={item.icon}
|
||||
className="text-[#ffbade] text-xl font-bold max-[575px]:text-[15px]"
|
||||
/>
|
||||
<p className="text-[15px] max-[575px]:text-[13px]">
|
||||
{item.label}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col gap-y-1 items-center w-auto justify-center">
|
||||
<div className="flex">
|
||||
{["EN", "JP"].map((lang, index) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => toggleLanguage(lang)}
|
||||
className={`px-1 py-[1px] text-xs font-bold ${
|
||||
index === 0 ? "rounded-l-[3px]" : "rounded-r-[3px]"
|
||||
} ${
|
||||
language === lang
|
||||
? "bg-[#ffbade] text-black"
|
||||
: "bg-gray-600 text-white"
|
||||
} max-[575px]:text-[9px] max-[575px]:py-0`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="whitespace-nowrap text-[15px] max-[575px]:text-[13px]">
|
||||
Anime name
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="text-white mt-8 w-full">
|
||||
{[
|
||||
{ name: "Home", path: "/home" },
|
||||
{ name: "Subbed Anime", path: "/subbed-anime" },
|
||||
{ name: "Dubbed Anime", path: "/dubbed-anime" },
|
||||
{ name: "Most Popular", path: "/most-popular" },
|
||||
{ name: "Movies", path: "/movie" },
|
||||
{ name: "TV Series", path: "/tv" },
|
||||
{ name: "OVAs", path: "/ova" },
|
||||
{ name: "ONAs", path: "/ona" },
|
||||
{ name: "Specials", path: "/special" },
|
||||
{
|
||||
name: "Join Telegram",
|
||||
path: "https://t.me/zenime_discussion",
|
||||
},
|
||||
].map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="py-4 w-full font-semibold"
|
||||
style={{ borderBottom: "1px solid rgba(255, 255, 255, .08)" }}
|
||||
>
|
||||
<Link
|
||||
to={item.path}
|
||||
className="px-4 hover:text-[#ffbade] hover:cursor-pointer w-fit line-clamp-1"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
142
src/components/sidecard/Sidecard.jsx
Normal file
142
src/components/sidecard/Sidecard.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClosedCaptioning,
|
||||
faMicrophone,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import useToolTipPosition from "@/src/hooks/useToolTipPosition";
|
||||
import Qtip from "../qtip/Qtip";
|
||||
|
||||
function Sidecard({ data, label, className, limit }) {
|
||||
const { language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [hoverTimeout, setHoverTimeout] = useState(null);
|
||||
const handleMouseEnter = (item, index) => {
|
||||
const timeout = setTimeout(() => {
|
||||
setHoveredItem(item.id + index);
|
||||
}, 400);
|
||||
setHoverTimeout(timeout);
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(hoverTimeout);
|
||||
setHoveredItem(null);
|
||||
};
|
||||
const toggleShowAll = () => {
|
||||
setShowAll((prev) => !prev);
|
||||
};
|
||||
|
||||
const displayedData = limit
|
||||
? data.slice(0, limit)
|
||||
: showAll
|
||||
? data
|
||||
: data.slice(0, 6);
|
||||
const [hoveredItem, setHoveredItem] = useState(null);
|
||||
const { tooltipPosition, tooltipHorizontalPosition, cardRefs } =
|
||||
useToolTipPosition(hoveredItem, data);
|
||||
return (
|
||||
<div className={`flex flex-col space-y-6 ${className}`}>
|
||||
<h1 className="font-bold text-2xl text-[#ffbade]">{label}</h1>
|
||||
<div className="flex flex-col space-y-4 bg-[#2B2A3C] p-4 pt-8">
|
||||
{data &&
|
||||
displayedData.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-x-4"
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderBottom:
|
||||
index + 1 < displayedData.length
|
||||
? "1px solid rgba(255, 255, 255, .075)"
|
||||
: "none",
|
||||
}}
|
||||
className="flex pb-4 relative container items-center"
|
||||
>
|
||||
{hoveredItem === item.id + index &&
|
||||
window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition} ${
|
||||
tooltipPosition === "top-1/2"
|
||||
? "translate-y-[50px]"
|
||||
: "translate-y-[-50px]"
|
||||
} z-[100000] transform transition-all duration-300 ease-in-out ${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="flex-shrink-0 w-[60px] h-[75px] rounded-md object-cover cursor-pointer"
|
||||
onClick={() => navigate(`/watch/${item.id}`)}
|
||||
onMouseEnter={() => handleMouseEnter(item, index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
<div className="flex flex-col ml-4 space-y-2">
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="text-[1em] font-[500] hover:cursor-pointer hover:text-[#ffbade] transform transition-all ease-out line-clamp-1 max-[478px]:line-clamp-2 max-[478px]:text-[14px]"
|
||||
onClick={() =>
|
||||
window.scrollTo({ top: 0, behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center w-fit space-x-1 max-[320px]:gap-y-2">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[4px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[4px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.showType && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="dot ml-[4px]"></div>
|
||||
<p className="text-[15px] font-light">
|
||||
{item.tvInfo.showType}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!limit && data.length > 6 && (
|
||||
<button
|
||||
className="w-full bg-[#555462d3] py-3 mt-4 hover:bg-[#555462] rounded-md font-bold transform transition-all ease-out"
|
||||
onClick={toggleShowAll}
|
||||
>
|
||||
{showAll ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Sidecard);
|
||||
227
src/components/splashscreen/SplashScreen.css
Normal file
227
src/components/splashscreen/SplashScreen.css
Normal file
@@ -0,0 +1,227 @@
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Container and background */
|
||||
.splash-container {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: url('/splash.jpg') no-repeat center center fixed;
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 0 30px;
|
||||
}
|
||||
|
||||
.splash-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 140px;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo-container {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 75px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 14px 48px 14px 20px;
|
||||
background: rgba(17, 17, 17, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.search-button {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Enter button */
|
||||
.enter-button {
|
||||
background: white;
|
||||
color: black;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin: 8px 0 60px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.enter-button:hover {
|
||||
background: #ffbade;
|
||||
}
|
||||
|
||||
/* FAQ Section */
|
||||
.faq-section {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.faq-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
background: #141414;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
width: 100%;
|
||||
padding: 18px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 17px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.faq-question:hover {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.faq-toggle {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
opacity: 0.8;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.faq-toggle.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
padding: 0 24px 18px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 12px 40px 12px 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.faq-title {
|
||||
font-size: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.content-wrapper {
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 12px 36px 12px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.enter-button {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
padding: 16px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
padding: 0 16px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
107
src/components/splashscreen/SplashScreen.jsx
Normal file
107
src/components/splashscreen/SplashScreen.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import "./SplashScreen.css";
|
||||
import logoTitle from "@/src/config/logoTitle";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faMagnifyingGlass, faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
question: "Is JustAnime safe?",
|
||||
answer: "Yes, JustAnime is completely safe to use. We ensure all content is properly scanned and secured for our users."
|
||||
},
|
||||
{
|
||||
question: "What makes JustAnime the best site to watch anime free online?",
|
||||
answer: "JustAnime offers high-quality streaming, a vast library of anime, no intrusive ads, and a user-friendly interface - all completely free."
|
||||
},
|
||||
{
|
||||
question: "How do I request an anime?",
|
||||
answer: "You can submit anime requests through our contact form or by reaching out to our support team."
|
||||
}
|
||||
];
|
||||
|
||||
function SplashScreen() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedFaq, setExpandedFaq] = useState(null);
|
||||
|
||||
const handleSearchSubmit = useCallback(() => {
|
||||
const trimmedSearch = search.trim();
|
||||
if (!trimmedSearch) return;
|
||||
const queryParam = encodeURIComponent(trimmedSearch);
|
||||
navigate(`/search?keyword=${queryParam}`);
|
||||
}, [search, navigate]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearchSubmit();
|
||||
}
|
||||
},
|
||||
[handleSearchSubmit]
|
||||
);
|
||||
|
||||
const toggleFaq = (index) => {
|
||||
setExpandedFaq(expandedFaq === index ? null : index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="splash-container">
|
||||
<div className="splash-overlay"></div>
|
||||
<div className="content-wrapper">
|
||||
<div className="logo-container">
|
||||
<img src="/logo.png" alt={logoTitle} className="logo" />
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search anime..."
|
||||
className="search-input"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
className="search-button"
|
||||
onClick={handleSearchSubmit}
|
||||
aria-label="Search"
|
||||
>
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link to="/home" className="enter-button">
|
||||
Enter Homepage →
|
||||
</Link>
|
||||
|
||||
<div className="faq-section">
|
||||
<h2 className="faq-title">Frequently Asked Questions</h2>
|
||||
<div className="faq-list">
|
||||
{FAQ_ITEMS.map((item, index) => (
|
||||
<div key={index} className="faq-item">
|
||||
<button
|
||||
className="faq-question"
|
||||
onClick={() => toggleFaq(index)}
|
||||
>
|
||||
<span>{item.question}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className={`faq-toggle ${expandedFaq === index ? 'rotate' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{expandedFaq === index && (
|
||||
<div className="faq-answer">
|
||||
{item.answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SplashScreen;
|
||||
68
src/components/spotlight/Spotlight.css
Normal file
68
src/components/spotlight/Spotlight.css
Normal file
@@ -0,0 +1,68 @@
|
||||
.swiper {
|
||||
width: 100%;
|
||||
}
|
||||
.swiper-slide {
|
||||
font-size: 18px;
|
||||
display: -webkit-box;
|
||||
display: -ms-flexbox;
|
||||
display: -webkit-flex;
|
||||
-webkit-box-pack: center;
|
||||
-ms-flex-pack: center;
|
||||
-webkit-box-align: center;
|
||||
-ms-flex-align: center;
|
||||
}
|
||||
.button-prev,
|
||||
.button-next {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: white;
|
||||
background-color: #383747;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
.button-prev:hover,
|
||||
.button-next:hover {
|
||||
background-color: #ffbade;
|
||||
color: #383747;
|
||||
}
|
||||
|
||||
.button-prev::after {
|
||||
font-family: "Font Awesome 5 Free";
|
||||
content: "\f053";
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.button-next::after {
|
||||
font-family: "Font Awesome 5 Free";
|
||||
content: "\f054";
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.swiper-horizontal > .swiper-pagination-bullets {
|
||||
display: none;
|
||||
}
|
||||
.swiper-pagination-bullet-active {
|
||||
background-color: rgb(239, 213, 22) !important;
|
||||
}
|
||||
@media only screen and (max-width: 575px) {
|
||||
.swiper-horizontal > .swiper-pagination-bullets {
|
||||
/* bottom: var(--swiper-pagination-bottom, 8px); */
|
||||
bottom: 0;
|
||||
right: 10px !important ;
|
||||
left: auto !important;
|
||||
width: 20px !important;
|
||||
bottom: 5px !important;
|
||||
display: flex !important;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
54
src/components/spotlight/Spotlight.jsx
Normal file
54
src/components/spotlight/Spotlight.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Navigation, Autoplay } from "swiper/modules";
|
||||
import "swiper/css";
|
||||
import "swiper/css/autoplay";
|
||||
import "swiper/css/navigation";
|
||||
import "./Spotlight.css";
|
||||
import Banner from "../banner/Banner";
|
||||
|
||||
const Spotlight = ({ spotlights }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="relative h-[600px] max-[1390px]:h-[530px] max-[1300px]:h-[500px] max-md:h-[420px]">
|
||||
<div className="absolute right-[10px] bottom-0 flex flex-col space-y-2 z-10 max-[575px]:hidden">
|
||||
<div className="button-next"></div>
|
||||
<div className="button-prev"></div>
|
||||
</div>
|
||||
{spotlights && spotlights.length > 0 ? (
|
||||
<>
|
||||
<Swiper
|
||||
spaceBetween={0}
|
||||
slidesPerView={1}
|
||||
loop={true}
|
||||
allowTouchMove={false}
|
||||
navigation={{
|
||||
nextEl: ".button-next",
|
||||
prevEl: ".button-prev",
|
||||
}}
|
||||
autoplay={{
|
||||
delay: 3000,
|
||||
disableOnInteraction: false,
|
||||
}}
|
||||
modules={[Navigation, Autoplay]}
|
||||
className="h-[600px] max-[1390px]:h-full"
|
||||
style={{
|
||||
"--swiper-pagination-bullet-inactive-color": "#ffffff",
|
||||
"--swiper-pagination-bullet-inactive-opacity": "1",
|
||||
}}
|
||||
>
|
||||
{spotlights.map((item, index) => (
|
||||
<SwiperSlide className="text-black relative" key={index}>
|
||||
<Banner item={item} index={index} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</>
|
||||
) : (
|
||||
<p>No spotlights to show.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spotlight;
|
||||
115
src/components/suggestion/Suggestion.jsx
Normal file
115
src/components/suggestion/Suggestion.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import getSearchSuggestion from "@/src/utils/getSearchSuggestion.utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import BouncingLoader from "../ui/bouncingloader/Bouncingloader";
|
||||
import { FaChevronRight } from "react-icons/fa";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function Suggestion({ keyword, className }) {
|
||||
const [suggestion, setSuggestion] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSearchSuggestion = async () => {
|
||||
if (!keyword) return;
|
||||
setLoading(true);
|
||||
setHasFetched(false);
|
||||
try {
|
||||
const data = await getSearchSuggestion(keyword);
|
||||
setSuggestion(data);
|
||||
setHasFetched(true);
|
||||
} catch (err) {
|
||||
console.error("Error fetching search suggestion info:", err);
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSearchSuggestion();
|
||||
}, [keyword]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-[#2d2b44] ${className} flex ${
|
||||
loading ? "justify-center py-7" : "justify-start"
|
||||
} ${!suggestion ? "p-3" : "justify-start"} items-center`}
|
||||
style={{ boxShadow: "0 20px 20px rgba(0, 0, 0, .3)" }}
|
||||
>
|
||||
{loading ? (
|
||||
<BouncingLoader />
|
||||
) : error && !suggestion ? (
|
||||
<div>Error loading suggestions</div>
|
||||
) : suggestion && hasFetched ? (
|
||||
<div className="w-full flex flex-col pt-2 overflow-y-auto">
|
||||
{suggestion.map((item, index) => (
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
key={index}
|
||||
className="group py-2 flex items-start gap-x-3 hover:bg-[#3c3a5e] cursor-pointer px-[10px]"
|
||||
style={{
|
||||
borderBottom:
|
||||
index === suggestion.length - 1
|
||||
? "none"
|
||||
: "1px dashed rgba(255, 255, 255, .075)",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
className="w-[50px] h-[75px] flex-shrink-0 object-cover"
|
||||
alt=""
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-y-[2px]">
|
||||
{item?.title && (
|
||||
<h1 className="line-clamp-1 leading-5 font-bold text-[15px] group-hover:text-[#ffbade]">
|
||||
{item.title || "N/A"}
|
||||
</h1>
|
||||
)}
|
||||
{item?.japanese_title && (
|
||||
<h1 className="line-clamp-1 leading-5 text-[13px] font-light text-[#aaaaaa]">
|
||||
{item.japanese_title || "N/A"}
|
||||
</h1>
|
||||
)}
|
||||
{(item?.releaseDate || item?.showType || item?.duration) && (
|
||||
<div className="flex gap-x-[5px] items-center w-full justify-start mt-[4px]">
|
||||
<p className="leading-5 text-[13px] font-light text-[#aaaaaa]">
|
||||
{item.releaseDate || "N/A"}
|
||||
</p>
|
||||
<span className="dot"></span>
|
||||
<p className="leading-5 text-[13px] font-medium group-hover:text-[#ffbade]">
|
||||
{item.showType || "N/A"}
|
||||
</p>
|
||||
<span className="dot"></span>
|
||||
<p className="leading-5 text-[13px] font-light text-[#aaaaaa]">
|
||||
{item.duration || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{!loading && hasFetched && (
|
||||
<Link
|
||||
className="w-full flex py-4 justify-center items-center bg-[#ffbade]"
|
||||
to={`/search?keyword=${encodeURIComponent(keyword)}`}
|
||||
>
|
||||
<div className="flex w-fit items-center gap-x-2">
|
||||
<p className="text-[17px] font-light text-black">
|
||||
View all results
|
||||
</p>
|
||||
<FaChevronRight className="text-black text-[12px] font-black mt-[2px]" />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : hasFetched ? (
|
||||
<p className="text-[17px]">No results found!</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Suggestion;
|
||||
176
src/components/topten/Topten.jsx
Normal file
176
src/components/topten/Topten.jsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClosedCaptioning,
|
||||
faMicrophone,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import useToolTipPosition from "@/src/hooks/useToolTipPosition";
|
||||
import Qtip from "../qtip/Qtip";
|
||||
|
||||
function Topten({ data, className }) {
|
||||
const { language } = useLanguage();
|
||||
const [activePeriod, setActivePeriod] = useState("today");
|
||||
const [hoveredItem, setHoveredItem] = useState(null);
|
||||
const [hoverTimeout, setHoverTimeout] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handlePeriodChange = (period) => {
|
||||
setActivePeriod(period);
|
||||
};
|
||||
|
||||
const handleNavigate = (id) => {
|
||||
navigate(`/${id}`);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const currentData =
|
||||
activePeriod === "today"
|
||||
? data.today
|
||||
: activePeriod === "week"
|
||||
? data.week
|
||||
: data.month;
|
||||
|
||||
const { tooltipPosition, tooltipHorizontalPosition, cardRefs } =
|
||||
useToolTipPosition(hoveredItem, currentData);
|
||||
|
||||
const handleMouseEnter = (item, index) => {
|
||||
if (hoverTimeout) clearTimeout(hoverTimeout);
|
||||
setHoveredItem(item.id + index);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoverTimeout(
|
||||
setTimeout(() => {
|
||||
setHoveredItem(null);
|
||||
}, 300) // Small delay to prevent flickering
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col space-y-6 ${className}`}>
|
||||
<div className="flex justify-between items-center max-[350px]:flex-col max-[350px]:gap-y-2 max-[350px]:items-start">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade]">Top 10</h1>
|
||||
<ul className="flex justify-between w-fit bg-[#373646] rounded-[4px] text-sm font-bold">
|
||||
{["today", "week", "month"].map((period) => (
|
||||
<li
|
||||
key={period}
|
||||
className={`cursor-pointer p-2 px-3 ${
|
||||
activePeriod === period
|
||||
? "bg-[#ffbade] text-[#555462]"
|
||||
: "text-white hover:text-[#ffbade]"
|
||||
} ${period === "today" ? "rounded-l-[4px]" : ""} ${
|
||||
period === "month" ? "rounded-r-[4px]" : ""
|
||||
}`}
|
||||
onClick={() => handlePeriodChange(period)}
|
||||
>
|
||||
{period.charAt(0).toUpperCase() + period.slice(1)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-4 bg-[#2B2A3C] p-4 pt-8">
|
||||
{currentData &&
|
||||
currentData.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-x-4"
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
>
|
||||
<h1
|
||||
className={`font-bold text-2xl ${
|
||||
index < 3
|
||||
? "pb-1 text-white border-b-[3px] border-[#ffbade]"
|
||||
: "text-[#777682]"
|
||||
} max-[350px]:hidden`}
|
||||
>
|
||||
{`${index + 1 < 10 ? "0" : ""}${index + 1}`}
|
||||
</h1>
|
||||
<div
|
||||
style={{
|
||||
borderBottom:
|
||||
index + 1 < 10
|
||||
? "1px solid rgba(255, 255, 255, .075)"
|
||||
: "none",
|
||||
}}
|
||||
className="flex pb-4 relative container items-center"
|
||||
>
|
||||
{/* Image with tooltip behavior */}
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="w-[60px] h-[75px] rounded-md object-cover flex-shrink-0 cursor-pointer"
|
||||
onClick={() => navigate(`/watch/${item.id}`)}
|
||||
onMouseEnter={() => handleMouseEnter(item, index)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
|
||||
{/* Tooltip positioned near image */}
|
||||
{hoveredItem === item.id + index &&
|
||||
window.innerWidth > 1024 && (
|
||||
<div
|
||||
className={`absolute ${tooltipPosition} ${tooltipHorizontalPosition}
|
||||
${
|
||||
tooltipPosition === "top-1/2"
|
||||
? "translate-y-[50px]"
|
||||
: "translate-y-[-50px]"
|
||||
}
|
||||
z-[100000] transform transition-all duration-300 ease-in-out
|
||||
${
|
||||
hoveredItem === item.id + index
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-2"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (hoverTimeout) clearTimeout(hoverTimeout);
|
||||
}}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Qtip id={item.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col ml-4 space-y-2">
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="text-[1em] font-[500] hover:cursor-pointer hover:text-[#ffbade] transform transition-all ease-out line-clamp-1 max-[478px]:line-clamp-2 max-[478px]:text-[14px]"
|
||||
onClick={() => handleNavigate(item.id)}
|
||||
>
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center w-fit space-x-1 max-[350px]:gap-y-[3px]">
|
||||
{item.tvInfo?.sub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B0E3AF] rounded-[4px] px-[4px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faClosedCaptioning}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.sub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.tvInfo?.dub && (
|
||||
<div className="flex space-x-1 justify-center items-center bg-[#B9E7FF] rounded-[4px] px-[8px] text-black py-[2px]">
|
||||
<FontAwesomeIcon
|
||||
icon={faMicrophone}
|
||||
className="text-[12px]"
|
||||
/>
|
||||
<p className="text-[12px] font-bold">
|
||||
{item.tvInfo.dub}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Topten);
|
||||
77
src/components/trending/Trending.jsx
Normal file
77
src/components/trending/Trending.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Pagination, Navigation } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { FaChevronLeft, FaChevronRight } from "react-icons/fa";
|
||||
import { useLanguage } from "@/src/context/LanguageContext";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
const Trending = ({ trending }) => {
|
||||
const { language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="mt-6 max-[1200px]:px-4 max-md:px-0">
|
||||
<h1 className="text-[#ffbade] text-2xl font-bold max-md:pl-4">
|
||||
Trending
|
||||
</h1>
|
||||
<div className="pr-[60px] relative mx-auto overflow-hidden z-[1] mt-6 max-[759px]:pr-0">
|
||||
<Swiper
|
||||
className="w-full h-full"
|
||||
slidesPerView={3}
|
||||
spaceBetween={2}
|
||||
breakpoints={{
|
||||
479: { spaceBetween: 15 },
|
||||
575: { spaceBetween: 15 },
|
||||
640: { slidesPerView: 3, spaceBetween: 15 },
|
||||
900: { slidesPerView: 4, spaceBetween: 15 },
|
||||
1300: { slidesPerView: 6, spaceBetween: 15 },
|
||||
}}
|
||||
modules={[Pagination, Navigation]}
|
||||
navigation={{
|
||||
nextEl: ".btn-next",
|
||||
prevEl: ".btn-prev",
|
||||
}}
|
||||
>
|
||||
{trending &&
|
||||
trending.map((item, idx) => (
|
||||
<SwiperSlide
|
||||
key={idx}
|
||||
className="text-center flex text-[18px] justify-center items-center"
|
||||
onClick={() => navigate(`/watch/${item.id}`)}
|
||||
>
|
||||
<div className="w-full h-auto pb-[115%] relative inline-block overflow-hidden max-[575px]:pb-[150%]">
|
||||
<div className="absolute left-0 top-0 bottom-0 overflow-hidden w-[40px] text-center font-semibold bg-[#201F31] max-[575px]:top-0 max-[575px]:h-[30px] max-[575px]:z-[9] max-[575px]:bg-white">
|
||||
<span className="absolute left-0 right-0 bottom-0 text-[24px] leading-[1.1em] text-center z-[9] transform -rotate-90 max-[575px]:transform max-[575px]:rotate-0 max-[575px]:text-[#111] max-[575px]:text-[18px] max-[575px]:leading-[30px]">
|
||||
{item.number}
|
||||
</span>
|
||||
<div className="w-[150px] h-fit text-left transform -rotate-90 absolute bottom-[100px] left-[-55px] leading-[40px] text-ellipsis whitespace-nowrap overflow-hidden text-white text-[16px] font-medium">
|
||||
{language === "EN" ? item.title : item.japanese_title}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/${item.id}`}
|
||||
className="inline-block bg-[#2a2c31] absolute w-auto left-[40px] right-0 top-0 bottom-0 max-[575px]:left-0 max-[575px]:top-0 max-[575px]:bottom-0"
|
||||
>
|
||||
<img
|
||||
src={`https://wsrv.nl/?url=${item.poster}`}
|
||||
alt={item.title}
|
||||
className="block w-full h-full object-cover hover:cursor-pointer"
|
||||
title={item.title}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
<div className="absolute top-0 right-0 bottom-0 w-[45px] flex flex-col space-y-2 max-[759px]:hidden">
|
||||
<div className="btn-next bg-[#383747] h-[50%] flex justify-center items-center rounded-[8px] cursor-pointer transition-all duration-300 ease-out hover:bg-[#ffbade] hover:text-[#383747]">
|
||||
<FaChevronRight />
|
||||
</div>
|
||||
<div className="btn-prev bg-[#383747] h-[50%] flex justify-center items-center rounded-[8px] cursor-pointer transition-all duration-300 ease-out hover:bg-[#ffbade] hover:text-[#383747]">
|
||||
<FaChevronLeft />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Trending;
|
||||
23
src/components/ui/Skeleton/Skeleton.css
Normal file
23
src/components/ui/Skeleton/Skeleton.css
Normal file
@@ -0,0 +1,23 @@
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer-effect {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.2) 20%,
|
||||
rgba(255, 255, 255, 0.3) 40%,
|
||||
rgba(255, 255, 255, 0.2) 60%,
|
||||
rgba(255, 255, 255, 0.1) 80%,
|
||||
rgba(0, 0, 0, 0.03) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite linear;
|
||||
}
|
||||
|
||||
16
src/components/ui/Skeleton/Skeleton.jsx
Normal file
16
src/components/ui/Skeleton/Skeleton.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import './Skeleton.css';
|
||||
|
||||
function Skeleton({ className, animation=true, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-gray-400 rounded-3xl",
|
||||
animation ? "shimmer-effect" : "",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
45
src/components/ui/bouncingloader/Bouncingloader.css
Normal file
45
src/components/ui/bouncingloader/Bouncingloader.css
Normal file
@@ -0,0 +1,45 @@
|
||||
.bouncing-loading > div {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: #858490;
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
-webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.bouncing-loading .span1 {
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.bouncing-loading .span2 {
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sk-bouncedelay {
|
||||
0%,
|
||||
100%,
|
||||
80% {
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-bouncedelay {
|
||||
0%,
|
||||
100%,
|
||||
80% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
12
src/components/ui/bouncingloader/Bouncingloader.jsx
Normal file
12
src/components/ui/bouncingloader/Bouncingloader.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import "./Bouncingloader.css"
|
||||
const BouncingLoader = () => {
|
||||
return (
|
||||
<div className="bouncing-loading flex gap-x-[5px]">
|
||||
<div className="span1"></div>
|
||||
<div className="span2"></div>
|
||||
<div className="span3"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BouncingLoader;
|
||||
100
src/components/voiceactor/Voiceactor.jsx
Normal file
100
src/components/voiceactor/Voiceactor.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import { FaChevronRight } from "react-icons/fa";
|
||||
import VoiceactorList from "../voiceactorlist/VoiceactorList";
|
||||
|
||||
function Voiceactor({ animeInfo, className }) {
|
||||
const [showVoiceActors, setShowVoiceActors] = useState(false);
|
||||
return (
|
||||
<div className={`w-full mt-8 flex flex-col gap-y-4 ${className}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="font-bold text-2xl text-[#ffbade] max-[478px]:text-[18px] capitalize">
|
||||
Characters & Voice Actors
|
||||
</h1>
|
||||
<button className="flex w-fit items-baseline h-fit rounded-3xl gap-x-1 group">
|
||||
<p
|
||||
className="text-white text-[12px] font-semibold h-fit leading-0"
|
||||
onClick={() => {
|
||||
setShowVoiceActors(true);
|
||||
}}
|
||||
>
|
||||
View more
|
||||
</p>
|
||||
<FaChevronRight className="text-white text-[10px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full grid grid-cols-3 max-[1024px]:grid-cols-2 max-[758px]:grid-cols-1 gap-4">
|
||||
{animeInfo.charactersVoiceActors.slice(0, 6).map((character, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex justify-between items-center px-3 py-4 rounded-md bg-[#373646]"
|
||||
>
|
||||
{character.character && (
|
||||
<div className="w-[50%] float-left overflow-hidden max-[350px]:w-[45%]">
|
||||
<div className="w-full flex gap-x-3">
|
||||
{character.character.poster && (
|
||||
<img
|
||||
src={character.character.poster}
|
||||
title={character.character.name || "Character"}
|
||||
alt={character.character.name || "Character"}
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
className="w-[45px] h-[45px] flex-shrink-0 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-center flex-col">
|
||||
{character.character.name && (
|
||||
<h4 className="text-[13px] text-left leading-[1.3em] font-[400] mb-0 overflow-hidden -webkit-box -webkit-line-clamp-2 -webkit-box-orient-vertical">
|
||||
{character.character.name}
|
||||
</h4>
|
||||
)}
|
||||
{character.character.cast && (
|
||||
<p className="text-[11px] mt-[3px]">
|
||||
{character.character.cast}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{character.voiceActors.length > 0 && character.voiceActors[0] && (
|
||||
<div className="w-[50%] float-right overflow-hidden max-[350px]:w-[45%]">
|
||||
<div className="w-full flex justify-end gap-x-2">
|
||||
<div className="flex flex-col justify-center ">
|
||||
{character.voiceActors[0].name && (
|
||||
<span className="text-[13px] text-right leading-[1.3em] font-[400] mb-0 overflow-hidden -webkit-box -webkit-line-clamp-2 -webkit-box-orient-vertical w-fit">
|
||||
{character.voiceActors[0].name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{character.voiceActors[0].poster && (
|
||||
<img
|
||||
src={character.voiceActors[0].poster}
|
||||
title={character.voiceActors[0].name || "Voice Actor"}
|
||||
alt={character.voiceActors[0].name || "Voice Actor"}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
className="w-[45px] h-[45px] rounded-full object-cover grayscale hover:grayscale-0 hover:cursor-pointer flex-shrink-0 transition-all duration-300 ease-in-out"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showVoiceActors && (
|
||||
<VoiceactorList
|
||||
id={animeInfo.id}
|
||||
isOpen={showVoiceActors}
|
||||
onClose={() => setShowVoiceActors(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Voiceactor;
|
||||
175
src/components/voiceactorlist/VoiceactorList.jsx
Normal file
175
src/components/voiceactorlist/VoiceactorList.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
faAngleDoubleLeft,
|
||||
faAngleDoubleRight,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import fetchVoiceActorInfo from "@/src/utils/getVoiceActor.utils";
|
||||
import VoiceActorlistLoader from "../Loader/VoiceActorlist.loader";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Error from "../error/Error";
|
||||
import {
|
||||
cleanupScrollbar,
|
||||
toggleScrollbar,
|
||||
} from "@/src/helper/toggleScrollbar";
|
||||
import PageSlider from "../pageslider/PageSlider";
|
||||
|
||||
function VoiceactorList({ id, isOpen, onClose }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [error, setError] = useState(null);
|
||||
const [VoiceactorList, setVoiceactorList] = useState([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
toggleScrollbar(isOpen);
|
||||
return () => {
|
||||
cleanupScrollbar();
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCategoryInfo = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchVoiceActorInfo(id, page);
|
||||
setVoiceactorList(data.data);
|
||||
setTotalPages(data.totalPages);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
console.error("Error fetching category info:", err);
|
||||
}
|
||||
};
|
||||
fetchCategoryInfo();
|
||||
}, [page]);
|
||||
if (error) {
|
||||
navigate("/error-page");
|
||||
return <Error />;
|
||||
}
|
||||
if (!VoiceactorList) {
|
||||
navigate("/404-not-found-page");
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 left-0 w-screen h-screen overflow-y-auto bg-black/80 z-50 flex justify-center py-10 max-[575px]:py-3"
|
||||
style={{
|
||||
zIndex: 1000000,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-[920px] h-fit flex flex-col relative backdrop-blur-[10px] rounded-lg p-6 bg-white/10 ${
|
||||
loading ? "h-fit" : ""
|
||||
} max-[1000px]:w-[80vw] max-md:w-[90vw] max-[480px]:p-3`}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{!loading && (
|
||||
<h2 className="text-2xl font-bold col-span-2 max-[480px]:text-lg">
|
||||
Characters & Voice Actors
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<VoiceActorlistLoader />
|
||||
) : (
|
||||
<div className="w-full grid grid-cols-2 gap-4 mt-5 max-[1000px]:grid-cols-1">
|
||||
{VoiceactorList.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex p-4 items-center justify-between py-2 bg-[#444445] rounded-lg h-[80px] max-[480px]:p-1 max-[480px]:bg-transparent max-[480px]:rounded-none max-[480px]:border-b-[1px] border-dotted max-[480px]:h-[60px] max-[480px]:pb-4"
|
||||
>
|
||||
<div className="flex gap-x-2 items-center w-[50%] overflow-hidden">
|
||||
<img
|
||||
src={item.character.poster}
|
||||
className="w-[45px] h-[45px] rounded-full flex-shrink-0 object-cover hover:cursor-pointer max-[480px]:w-[30px] max-[480px]:h-[30px]"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col text-left gap-y-1 w-full">
|
||||
{item.character.name && (
|
||||
<h1 className="text-[13px] font-semibold max-[480px]:text-[11px]">
|
||||
{item.character.name}
|
||||
</h1>
|
||||
)}
|
||||
{item.character.cast && (
|
||||
<p className="text-[12px] font-light max-[480px]:text-[10px]">
|
||||
{item.character.cast}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.voiceActors &&
|
||||
item.voiceActors.length > 0 &&
|
||||
(item.voiceActors.length > 1 ? (
|
||||
<div className="flex flex-wrap gap-x-[4px] items-center justify-end w-[50%] max-sm:flex-nowrap max-sm:overflow-auto max-[350px]:justify-start max-sm:py-3">
|
||||
{item.voiceActors.map((data, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={data.poster}
|
||||
className="w-[41px] h-[41px] opacity-70 cursor-pointer rounded-full flex-shrink-0 object-cover grayscale hover:grayscale-0 hover:opacity-100 max-[480px]:w-[30px] max-[480px]:h-[30px] transition-all duration-300 ease-in-out"
|
||||
title={data.name}
|
||||
style={{
|
||||
border: "4px solid rgba(105, 108, 117, 0.8)",
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-x-2 w-[50%] overflow-hidden max-[480px]:flex-wrap max-[480px]:flex-col-reverse max-[480px]:items-end max-[480px]:gap-y-1">
|
||||
{item?.voiceActors[0]?.name && (
|
||||
<p className="text-right text-[13px] max-[480px]:text-[11px]">
|
||||
{item.voiceActors[0].name}
|
||||
</p>
|
||||
)}
|
||||
<img
|
||||
src={item.voiceActors[0].poster}
|
||||
alt=""
|
||||
title={item.voiceActors.name}
|
||||
loading="lazy"
|
||||
className="w-[45px] h-[45px] rounded-full opacity-70 flex-shrink-0 object-cover grayscale hover:grayscale-0 hover:opacity-100 max-[480px]:w-[30px] max-[480px]:h-[30px] transition-all duration-300 ease-in-out"
|
||||
onError={(e) => {
|
||||
e.target.src = "https://i.postimg.cc/HnHKvHpz/no-avatar.jpg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="bg-white w-[30px] h-[30px] p-2 rounded-full text-3xl absolute z-[1000] top-[-14px] right-[-14px] hover:text-[#FFBADE] cursor-pointer transform transition-all ease-in-out duration-300 flex items-center justify-center hover:bg-[#ffbade] max-md:top-0 max-md:right-0 max-md:rounded-none max-md:rounded-bl-lg max-md:rounded-tr-lg"
|
||||
onClick={onClose}
|
||||
>
|
||||
<button className="text-black mb-[6px] font-semibold">×</button>
|
||||
</div>
|
||||
|
||||
<PageSlider
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
handlePageChange={setPage}
|
||||
start={true}
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VoiceactorList;
|
||||
97
src/components/watchcontrols/Watchcontrols.jsx
Normal file
97
src/components/watchcontrols/Watchcontrols.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { faBackward, faForward } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const ToggleButton = ({ label, isActive, onClick }) => (
|
||||
<button className="flex gap-x-2" onClick={onClick}>
|
||||
<h1 className="capitalize text-[13px]">{label}</h1>
|
||||
<span
|
||||
className={`capitalize text-[13px] ${
|
||||
isActive ? "text-[#ffbade]" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{isActive ? "on" : "off"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function WatchControls({
|
||||
autoPlay,
|
||||
setAutoPlay,
|
||||
autoSkipIntro,
|
||||
setAutoSkipIntro,
|
||||
autoNext,
|
||||
setAutoNext,
|
||||
episodeId,
|
||||
episodes = [],
|
||||
onButtonClick,
|
||||
}) {
|
||||
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(
|
||||
episodes?.findIndex(
|
||||
(episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (episodes?.length > 0) {
|
||||
const newIndex = episodes.findIndex(
|
||||
(episode) => episode.id.match(/ep=(\d+)/)?.[1] === episodeId
|
||||
);
|
||||
setCurrentEpisodeIndex(newIndex);
|
||||
}
|
||||
}, [episodeId, episodes]);
|
||||
|
||||
return (
|
||||
<div className="bg-[#11101A] w-full flex justify-between flex-wrap px-4 pt-4 max-[1200px]:bg-[#14151A] max-[375px]:flex-col max-[375px]:gap-y-2">
|
||||
<div className="flex gap-x-4 flex-wrap">
|
||||
<ToggleButton
|
||||
label="auto play"
|
||||
isActive={autoPlay}
|
||||
onClick={() => setAutoPlay((prev) => !prev)}
|
||||
/>
|
||||
<ToggleButton
|
||||
label="auto skip intro"
|
||||
isActive={autoSkipIntro}
|
||||
onClick={() => setAutoSkipIntro((prev) => !prev)}
|
||||
/>
|
||||
<ToggleButton
|
||||
label="auto next"
|
||||
isActive={autoNext}
|
||||
onClick={() => setAutoNext((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-x-6 max-[575px]:gap-x-4 max-[375px]:justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentEpisodeIndex > 0) {
|
||||
onButtonClick(
|
||||
episodes[currentEpisodeIndex - 1].id.match(/ep=(\d+)/)?.[1]
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={currentEpisodeIndex <= 0}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faBackward}
|
||||
className="text-[20px] max-[575px]:text-[16px] text-white"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentEpisodeIndex < episodes?.length - 1) {
|
||||
onButtonClick(
|
||||
episodes[currentEpisodeIndex + 1].id.match(/ep=(\d+)/)?.[1]
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={currentEpisodeIndex >= episodes?.length - 1}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faForward}
|
||||
className="text-[20px] max-[575px]:text-[16px] text-white"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user