mirror of
https://github.com/JustAnimeCore/JustAnime.git
synced 2026-04-17 13:51:44 +00:00
seo
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-content-loader": "^7.0.2",
|
"react-content-loader": "^7.0.2",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
"react-lazy-load": "^4.0.1",
|
"react-lazy-load": "^4.0.1",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
|
|||||||
93
src/App.jsx
93
src/App.jsx
@@ -1,6 +1,7 @@
|
|||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import { Analytics } from '@vercel/analytics/react';
|
import { Analytics } from '@vercel/analytics/react';
|
||||||
import { SpeedInsights } from '@vercel/speed-insights/react';
|
import { SpeedInsights } from '@vercel/speed-insights/react';
|
||||||
import { HomeInfoProvider } from "./context/HomeInfoContext";
|
import { HomeInfoProvider } from "./context/HomeInfoContext";
|
||||||
@@ -34,51 +35,53 @@ function App() {
|
|||||||
const isSplashScreen = location.pathname === "/";
|
const isSplashScreen = location.pathname === "/";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeInfoProvider>
|
<HelmetProvider>
|
||||||
<div className="app-container px-4 lg:px-10">
|
<HomeInfoProvider>
|
||||||
<main className="content max-w-[2048px] mx-auto w-full">
|
<div className="app-container px-4 lg:px-10">
|
||||||
{!isSplashScreen && <Navbar />}
|
<main className="content max-w-[2048px] mx-auto w-full">
|
||||||
<Routes>
|
{!isSplashScreen && <Navbar />}
|
||||||
<Route path="/" element={<SplashScreen />} />
|
<Routes>
|
||||||
<Route path="/home" element={<Home />} />
|
<Route path="/" element={<SplashScreen />} />
|
||||||
<Route path="/:id" element={<AnimeInfo />} />
|
<Route path="/home" element={<Home />} />
|
||||||
<Route path="/watch/:id" element={<Watch />} />
|
<Route path="/:id" element={<AnimeInfo />} />
|
||||||
<Route path="/random" element={<AnimeInfo random={true} />} />
|
<Route path="/watch/:id" element={<Watch />} />
|
||||||
<Route path="/404-not-found-page" element={<Error error="404" />} />
|
<Route path="/random" element={<AnimeInfo random={true} />} />
|
||||||
<Route path="/error-page" element={<Error />} />
|
<Route path="/404-not-found-page" element={<Error error="404" />} />
|
||||||
<Route path="/terms-of-service" element={<Terms />} />
|
<Route path="/error-page" element={<Error />} />
|
||||||
<Route path="/dmca" element={<DMCA />} />
|
<Route path="/terms-of-service" element={<Terms />} />
|
||||||
<Route path="/contact" element={<Contact />} />
|
<Route path="/dmca" element={<DMCA />} />
|
||||||
{/* Render category routes */}
|
<Route path="/contact" element={<Contact />} />
|
||||||
{categoryRoutes.map((path) => (
|
{/* Render category routes */}
|
||||||
<Route
|
{categoryRoutes.map((path) => (
|
||||||
key={path}
|
<Route
|
||||||
path={`/${path}`}
|
key={path}
|
||||||
element={
|
path={`/${path}`}
|
||||||
<Category path={path} label={path.split("-").join(" ")} />
|
element={
|
||||||
}
|
<Category path={path} label={path.split("-").join(" ")} />
|
||||||
/>
|
}
|
||||||
))}
|
/>
|
||||||
{/* Render A to Z routes */}
|
))}
|
||||||
{azRoute.map((path) => (
|
{/* Render A to Z routes */}
|
||||||
<Route
|
{azRoute.map((path) => (
|
||||||
key={path}
|
<Route
|
||||||
path={`/${path}`}
|
key={path}
|
||||||
element={<AtoZ path={path} />}
|
path={`/${path}`}
|
||||||
/>
|
element={<AtoZ path={path} />}
|
||||||
))}
|
/>
|
||||||
<Route path="/producer/:id" element={<Producer />} />
|
))}
|
||||||
<Route path="/search" element={<Search />} />
|
<Route path="/producer/:id" element={<Producer />} />
|
||||||
{/* Catch-all route for 404 */}
|
<Route path="/search" element={<Search />} />
|
||||||
<Route path="*" element={<Error error="404" />} />
|
{/* Catch-all route for 404 */}
|
||||||
</Routes>
|
<Route path="*" element={<Error error="404" />} />
|
||||||
{!isSplashScreen && <Footer />}
|
</Routes>
|
||||||
</main>
|
{!isSplashScreen && <Footer />}
|
||||||
<Analytics />
|
</main>
|
||||||
<SpeedInsights />
|
<Analytics />
|
||||||
<DiscordPopup />
|
<SpeedInsights />
|
||||||
</div>
|
<DiscordPopup />
|
||||||
</HomeInfoProvider>
|
</div>
|
||||||
|
</HomeInfoProvider>
|
||||||
|
</HelmetProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import logoTitle from "@/src/config/logoTitle";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faMagnifyingGlass, faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
import { faMagnifyingGlass, faChevronDown } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { faAngleRight } from "@fortawesome/free-solid-svg-icons";
|
import { faAngleRight } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import { generateFAQSchema, generateCanonicalUrl, optimizeTitle } from "@/src/utils/seo.utils";
|
||||||
|
|
||||||
const FAQ_ITEMS = [
|
const FAQ_ITEMS = [
|
||||||
{
|
{
|
||||||
@@ -46,62 +48,89 @@ function SplashScreen() {
|
|||||||
setExpandedFaq(expandedFaq === index ? null : index);
|
setExpandedFaq(expandedFaq === index ? null : index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const faqSchema = generateFAQSchema(FAQ_ITEMS);
|
||||||
|
const canonicalUrl = generateCanonicalUrl('/');
|
||||||
|
const pageTitle = optimizeTitle('Watch Anime Online Free | English Sub & Dub', false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="splash-container">
|
<>
|
||||||
<div className="splash-overlay"></div>
|
<Helmet>
|
||||||
<div className="content-wrapper">
|
<title>{pageTitle}</title>
|
||||||
<div className="logo-container">
|
<meta name="description" content="JustAnime is the best site to watch anime online for free. Stream thousands of English subbed and dubbed anime episodes in HD quality with no ads." />
|
||||||
<img src="/logo.png" alt={logoTitle} className="logo" />
|
<meta name="keywords" content="justanime, watch anime free, anime online sub dub, free anime streaming, no ads anime, best anime site" />
|
||||||
</div>
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
|
|
||||||
<div className="search-container">
|
<meta property="og:title" content={pageTitle} />
|
||||||
<input
|
<meta property="og:description" content="Watch high-quality anime online for free on JustAnime. No ads, daily updates, and a massive library of subbed and dubbed content." />
|
||||||
type="text"
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
placeholder="Search anime..."
|
<meta property="og:type" content="website" />
|
||||||
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">
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
Enter Homepage <FontAwesomeIcon icon={faAngleRight} className="angle-icon" />
|
<meta name="twitter:title" content={pageTitle} />
|
||||||
</Link>
|
<meta name="twitter:description" content="Stream thousands of anime episodes for free in HD quality on JustAnime. The best ad-free experience for anime fans!" />
|
||||||
|
|
||||||
<div className="faq-section">
|
{faqSchema && (
|
||||||
<h2 className="faq-title">Frequently Asked Questions</h2>
|
<script type="application/ld+json">
|
||||||
<div className="faq-list">
|
{JSON.stringify(faqSchema)}
|
||||||
{FAQ_ITEMS.map((item, index) => (
|
</script>
|
||||||
<div key={index} className="faq-item">
|
)}
|
||||||
<button
|
</Helmet>
|
||||||
className="faq-question"
|
<div className="splash-container">
|
||||||
onClick={() => toggleFaq(index)}
|
<div className="splash-overlay"></div>
|
||||||
>
|
<div className="content-wrapper">
|
||||||
<span>{item.question}</span>
|
<div className="logo-container">
|
||||||
<FontAwesomeIcon
|
<img src="/logo.png" alt={logoTitle} className="logo" />
|
||||||
icon={faChevronDown}
|
</div>
|
||||||
className={`faq-toggle ${expandedFaq === index ? 'rotate' : ''}`}
|
|
||||||
/>
|
<div className="search-container">
|
||||||
</button>
|
<input
|
||||||
{expandedFaq === index && (
|
type="text"
|
||||||
<div className="faq-answer">
|
placeholder="Search anime..."
|
||||||
{item.answer}
|
className="search-input"
|
||||||
</div>
|
value={search}
|
||||||
)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
</div>
|
onKeyDown={handleKeyDown}
|
||||||
))}
|
/>
|
||||||
|
<button
|
||||||
|
className="search-button"
|
||||||
|
onClick={handleSearchSubmit}
|
||||||
|
aria-label="Search"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMagnifyingGlass} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link to="/home" className="enter-button">
|
||||||
|
Enter Homepage <FontAwesomeIcon icon={faAngleRight} className="angle-icon" />
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,21 +10,59 @@ import { useHomeInfo } from "@/src/context/HomeInfoContext.jsx";
|
|||||||
import Schedule from "@/src/components/schedule/Schedule";
|
import Schedule from "@/src/components/schedule/Schedule";
|
||||||
import ContinueWatching from "@/src/components/continue/ContinueWatching";
|
import ContinueWatching from "@/src/components/continue/ContinueWatching";
|
||||||
import TabbedAnimeSection from "@/src/components/tabbed-anime/TabbedAnimeSection";
|
import TabbedAnimeSection from "@/src/components/tabbed-anime/TabbedAnimeSection";
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import {
|
||||||
|
generateWebsiteStructuredData,
|
||||||
|
generateOrganizationStructuredData,
|
||||||
|
generateItemListSchema
|
||||||
|
} from "@/src/utils/seo.utils";
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { homeInfo, homeInfoLoading, error } = useHomeInfo();
|
const { homeInfo, homeInfoLoading, error } = useHomeInfo();
|
||||||
if (homeInfoLoading) return <Loader type="home" />;
|
if (homeInfoLoading) return <Loader type="home" />;
|
||||||
if (error) return <Error />;
|
if (error) return <Error />;
|
||||||
if (!homeInfo) return <Error error="404" />;
|
if (!homeInfo) return <Error error="404" />;
|
||||||
|
|
||||||
|
const websiteSchema = generateWebsiteStructuredData();
|
||||||
|
const organizationSchema = generateOrganizationStructuredData();
|
||||||
|
const trendingSchema = homeInfo.trending ? generateItemListSchema(homeInfo.trending, "Trending Anime") : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{website_name} | Free Anime Streaming Platform</title>
|
||||||
|
<meta name="description" content={`${website_name} is the best site to watch anime online for free. Stream thousands of English subbed and dubbed anime episodes in HD quality with no ads.`} />
|
||||||
|
<meta name="keywords" content="justanime, watch anime free, anime online sub dub, free anime streaming, no ads anime, best anime site" />
|
||||||
|
<link rel="canonical" href="https://justanime.to" />
|
||||||
|
|
||||||
|
<meta property="og:title" content={`${website_name} | Free Anime Streaming Platform`} />
|
||||||
|
<meta property="og:description" content={`Watch high-quality anime online for free on ${website_name}. No ads, daily updates, and a massive library of subbed and dubbed content.`} />
|
||||||
|
<meta property="og:url" content="https://justanime.to" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={`${website_name} | Free Anime Streaming Platform`} />
|
||||||
|
<meta name="twitter:description" content={`Stream thousands of anime episodes for free in HD quality on ${website_name}. The best ad-free experience for anime fans!`} />
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(websiteSchema)}
|
||||||
|
</script>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(organizationSchema)}
|
||||||
|
</script>
|
||||||
|
{trendingSchema && (
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(trendingSchema)}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
</Helmet>
|
||||||
<div className="pt-16 w-full">
|
<div className="pt-16 w-full">
|
||||||
<Spotlight spotlights={homeInfo.spotlights} />
|
<Spotlight spotlights={homeInfo.spotlights} />
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Genre data={homeInfo.genres} />
|
<Genre data={homeInfo.genres} />
|
||||||
</div>
|
</div>
|
||||||
<ContinueWatching />
|
<ContinueWatching />
|
||||||
|
|
||||||
<div className="w-full grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col">
|
<div className="w-full grid grid-cols-[minmax(0,75%),minmax(0,25%)] gap-x-6 max-[1200px]:flex flex-col">
|
||||||
<div>
|
<div>
|
||||||
<CategoryCard
|
<CategoryCard
|
||||||
@@ -35,7 +73,7 @@ function Home() {
|
|||||||
limit={12}
|
limit={12}
|
||||||
/>
|
/>
|
||||||
<Schedule className="mt-8" />
|
<Schedule className="mt-8" />
|
||||||
<TabbedAnimeSection
|
<TabbedAnimeSection
|
||||||
topAiring={homeInfo.top_airing}
|
topAiring={homeInfo.top_airing}
|
||||||
mostFavorite={homeInfo.most_favorite}
|
mostFavorite={homeInfo.most_favorite}
|
||||||
latestCompleted={homeInfo.latest_completed}
|
latestCompleted={homeInfo.latest_completed}
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import CategoryCard from "@/src/components/categorycard/CategoryCard";
|
|||||||
import Loader from "@/src/components/Loader/Loader";
|
import Loader from "@/src/components/Loader/Loader";
|
||||||
import Error from "@/src/components/error/Error";
|
import Error from "@/src/components/error/Error";
|
||||||
import PageSlider from "@/src/components/pageslider/PageSlider";
|
import PageSlider from "@/src/components/pageslider/PageSlider";
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import {
|
||||||
|
generateAZListMeta,
|
||||||
|
generatePaginationLinks,
|
||||||
|
generateCanonicalUrl,
|
||||||
|
generateCollectionSchema
|
||||||
|
} from "@/src/utils/seo.utils";
|
||||||
|
|
||||||
function AtoZ({ path }) {
|
function AtoZ({ path }) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -44,67 +51,98 @@ function AtoZ({ path }) {
|
|||||||
setSearchParams({ page: newPage });
|
setSearchParams({ page: newPage });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const { title, description, keywords } = generateAZListMeta(currentLetter, page);
|
||||||
<div className="max-w-[1600px] mx-auto flex flex-col mt-[64px] max-md:mt-[50px]">
|
const canonicalUrl = generateCanonicalUrl(`/az-list/${currentLetter === 'az-list' ? '' : currentLetter}${page > 1 ? `?page=${page}` : ''}`);
|
||||||
<div className="flex flex-col gap-y-2 max-[478px]:gap-y-0 mt-6">
|
const paginationLinks = generatePaginationLinks(`/az-list/${currentLetter}`, page, totalPages);
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
const collectionSchema = generateCollectionSchema(categoryInfo, `Anime starting with ${currentLetter}`, `az-list/${currentLetter}`);
|
||||||
Sort By Letters
|
|
||||||
</h1>
|
|
||||||
<div className="flex gap-x-[7px] flex-wrap justify-start gap-y-2 max-md:justify-start">
|
|
||||||
{[
|
|
||||||
"All",
|
|
||||||
"#",
|
|
||||||
"0-9",
|
|
||||||
...Array.from({ length: 26 }, (_, i) =>
|
|
||||||
String.fromCharCode(65 + i)
|
|
||||||
),
|
|
||||||
].map((item, index) => {
|
|
||||||
const linkPath =
|
|
||||||
item.toLowerCase() === "all"
|
|
||||||
? ""
|
|
||||||
: item === "#"
|
|
||||||
? "other"
|
|
||||||
: item;
|
|
||||||
const isActive =
|
|
||||||
(currentLetter === "az-list" && item.toLowerCase() === "all") ||
|
|
||||||
(currentLetter === "other" && item === "#") ||
|
|
||||||
currentLetter === item.toLowerCase();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<>
|
||||||
to={`/az-list/${linkPath}`}
|
<Helmet>
|
||||||
key={index}
|
<title>{title}</title>
|
||||||
className={`text-md bg-[#373646] py-1 px-4 rounded-md font-bold hover:text-black hover:bg-white hover:cursor-pointer transition-all ease-out ${
|
<meta name="description" content={description} />
|
||||||
isActive ? "text-black bg-white" : ""
|
<meta name="keywords" content={keywords} />
|
||||||
}`}
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
>
|
|
||||||
{item}
|
{paginationLinks.map((link, index) => (
|
||||||
</Link>
|
<link key={index} rel={link.rel} href={link.href} />
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
|
||||||
|
{collectionSchema && (
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(collectionSchema)}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
</Helmet>
|
||||||
|
<div className="max-w-[1600px] mx-auto flex flex-col mt-[64px] max-md:mt-[50px]">
|
||||||
|
<div className="flex flex-col gap-y-2 max-[478px]:gap-y-0 mt-6">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
Sort By Letters
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-x-[7px] flex-wrap justify-start gap-y-2 max-md:justify-start">
|
||||||
|
{[
|
||||||
|
"All",
|
||||||
|
"#",
|
||||||
|
"0-9",
|
||||||
|
...Array.from({ length: 26 }, (_, i) =>
|
||||||
|
String.fromCharCode(65 + i)
|
||||||
|
),
|
||||||
|
].map((item, index) => {
|
||||||
|
const linkPath =
|
||||||
|
item.toLowerCase() === "all"
|
||||||
|
? ""
|
||||||
|
: item === "#"
|
||||||
|
? "other"
|
||||||
|
: item;
|
||||||
|
const isActive =
|
||||||
|
(currentLetter === "az-list" && item.toLowerCase() === "all") ||
|
||||||
|
(currentLetter === "other" && item === "#") ||
|
||||||
|
currentLetter === item.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/az-list/${linkPath}`}
|
||||||
|
key={index}
|
||||||
|
className={`text-md bg-[#373646] py-1 px-4 rounded-md font-bold hover:text-black hover:bg-white hover:cursor-pointer transition-all ease-out ${isActive ? "text-black bg-white" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="w-full flex flex-col gap-y-8">
|
||||||
<div className="w-full flex flex-col gap-y-8">
|
<div>
|
||||||
<div>
|
{categoryInfo && categoryInfo.length > 0 && (
|
||||||
{categoryInfo && categoryInfo.length > 0 && (
|
<CategoryCard
|
||||||
<CategoryCard
|
data={categoryInfo}
|
||||||
data={categoryInfo}
|
limit={categoryInfo.length}
|
||||||
limit={categoryInfo.length}
|
showViewMore={false}
|
||||||
showViewMore={false}
|
className="mt-8"
|
||||||
className="mt-8"
|
cardStyle="grid-cols-8 max-[1600px]:grid-cols-6 max-[1200px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-3 max-[478px]:gap-x-2"
|
||||||
cardStyle="grid-cols-8 max-[1600px]:grid-cols-6 max-[1200px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-3 max-[478px]:gap-x-2"
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
<div className="flex justify-center w-full mt-8">
|
||||||
<div className="flex justify-center w-full mt-8">
|
<PageSlider
|
||||||
<PageSlider
|
page={page}
|
||||||
page={page}
|
totalPages={totalPages}
|
||||||
totalPages={totalPages}
|
handlePageChange={handlePageChange}
|
||||||
handlePageChange={handlePageChange}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ import { useLanguage } from "@/src/context/LanguageContext";
|
|||||||
import { useHomeInfo } from "@/src/context/HomeInfoContext";
|
import { useHomeInfo } from "@/src/context/HomeInfoContext";
|
||||||
import Voiceactor from "@/src/components/voiceactor/Voiceactor";
|
import Voiceactor from "@/src/components/voiceactor/Voiceactor";
|
||||||
import getSafeTitle from "@/src/utils/getSafetitle";
|
import getSafeTitle from "@/src/utils/getSafetitle";
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import {
|
||||||
|
generateDescription,
|
||||||
|
generateKeywords,
|
||||||
|
generateCanonicalUrl,
|
||||||
|
generateOGImage,
|
||||||
|
generateAnimeStructuredData,
|
||||||
|
generateBreadcrumbStructuredData,
|
||||||
|
optimizeTitle,
|
||||||
|
} from '@/src/utils/seo.utils';
|
||||||
|
|
||||||
function InfoItem({ label, value, isProducer = true }) {
|
function InfoItem({ label, value, isProducer = true }) {
|
||||||
return (
|
return (
|
||||||
@@ -110,15 +120,6 @@ function AnimeInfo({ random = false }) {
|
|||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [id, random]);
|
}, [id, random]);
|
||||||
useEffect(() => {
|
|
||||||
if (animeInfo && location.pathname === `/${animeInfo.id}`) {
|
|
||||||
const safeTitle = getSafeTitle(animeInfo.title, language, animeInfo.japanese_title);
|
|
||||||
document.title = `Watch ${safeTitle} English Sub/Dub online Free on ${website_name}`;
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.title = `${website_name} | Free anime streaming platform`;
|
|
||||||
};
|
|
||||||
}, [animeInfo, language]);
|
|
||||||
if (loading) return <Loader type="animeInfo" />;
|
if (loading) return <Loader type="animeInfo" />;
|
||||||
if (error) {
|
if (error) {
|
||||||
return <Error />;
|
return <Error />;
|
||||||
@@ -129,6 +130,19 @@ function AnimeInfo({ random = false }) {
|
|||||||
}
|
}
|
||||||
const { title, japanese_title, poster, animeInfo: info } = animeInfo;
|
const { title, japanese_title, poster, animeInfo: info } = animeInfo;
|
||||||
const safeTitle = getSafeTitle(title, language, japanese_title);
|
const safeTitle = getSafeTitle(title, language, japanese_title);
|
||||||
|
const displayTitle = safeTitle; // Use safeTitle as the display title
|
||||||
|
|
||||||
|
const pageTitle = optimizeTitle(`Watch ${displayTitle} Sub Dub Online Free`);
|
||||||
|
const pageDescription = generateDescription(info?.Overview);
|
||||||
|
const pageKeywords = generateKeywords(animeInfo);
|
||||||
|
const canonicalUrl = generateCanonicalUrl(`/${animeInfo.id}`);
|
||||||
|
const ogImage = generateOGImage(poster);
|
||||||
|
|
||||||
|
const animeStructuredData = generateAnimeStructuredData(animeInfo);
|
||||||
|
const breadcrumbData = generateBreadcrumbStructuredData([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: animeInfo.title, url: `/${animeInfo.id}` }
|
||||||
|
]);
|
||||||
|
|
||||||
const tags = [
|
const tags = [
|
||||||
{
|
{
|
||||||
@@ -156,368 +170,394 @@ function AnimeInfo({ random = false }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
<>
|
||||||
<div className="relative w-full overflow-hidden mt-[74px] max-md:mt-[60px]">
|
<Helmet>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
<meta name="description" content={pageDescription} />
|
||||||
|
<meta name="keywords" content={pageKeywords} />
|
||||||
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
|
|
||||||
{/* Main Content */}
|
<meta property="og:title" content={pageTitle} />
|
||||||
<div className="relative z-10 container mx-auto py-4 sm:py-6 lg:py-12">
|
<meta property="og:description" content={pageDescription} />
|
||||||
{/* Mobile Layout */}
|
<meta property="og:image" content={ogImage} />
|
||||||
<div className="block md:hidden">
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
<div className="flex flex-row gap-4">
|
<meta property="og:type" content="video.tv_show" />
|
||||||
{/* Poster Section */}
|
|
||||||
<div className="flex-shrink-0">
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<div className="relative w-[130px] xs:w-[150px] aspect-[2/3] rounded-xl overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
<meta name="twitter:title" content={pageTitle} />
|
||||||
<img
|
<meta name="twitter:description" content={pageDescription} />
|
||||||
src={`${poster}`}
|
<meta name="twitter:image" content={ogImage} />
|
||||||
alt={`${safeTitle} Poster`}
|
|
||||||
className="w-full h-full object-cover"
|
<script type="application/ld+json">
|
||||||
/>
|
{JSON.stringify(animeStructuredData)}
|
||||||
{animeInfo.adultContent && (
|
</script>
|
||||||
<div className="absolute top-2 left-2 px-2 py-0.5 bg-red-500/90 backdrop-blur-sm rounded-md text-[10px] font-medium">
|
<script type="application/ld+json">
|
||||||
18+
|
{JSON.stringify(breadcrumbData)}
|
||||||
|
</script>
|
||||||
|
</Helmet>
|
||||||
|
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
||||||
|
<div className="relative w-full overflow-hidden mt-[74px] max-md:mt-[60px]">
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="relative z-10 container mx-auto py-4 sm:py-6 lg:py-12">
|
||||||
|
{/* Mobile Layout */}
|
||||||
|
<div className="block md:hidden">
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
{/* Poster Section */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="relative w-[130px] xs:w-[150px] aspect-[2/3] rounded-xl overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
||||||
|
<img
|
||||||
|
src={`${poster}`}
|
||||||
|
alt={`${safeTitle} Poster`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{animeInfo.adultContent && (
|
||||||
|
<div className="absolute top-2 left-2 px-2 py-0.5 bg-red-500/90 backdrop-blur-sm rounded-md text-[10px] font-medium">
|
||||||
|
18+
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Info Section */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<h1 className="text-lg xs:text-xl font-bold tracking-tight truncate">
|
||||||
|
{safeTitle}
|
||||||
|
</h1>
|
||||||
|
{language === "EN" && japanese_title && (
|
||||||
|
<p className="text-white/50 text-[11px] xs:text-xs truncate">JP Title: {japanese_title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{tags.map(({ condition, icon, text }, index) =>
|
||||||
|
condition && (
|
||||||
|
<Tag
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
icon={icon}
|
||||||
|
text={text}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview - Limited for mobile */}
|
||||||
|
{info?.Overview && (
|
||||||
|
<div className="text-gray-300 leading-relaxed text-xs">
|
||||||
|
{info.Overview.length > 150 ? (
|
||||||
|
<>
|
||||||
|
{isFull ? (
|
||||||
|
info.Overview
|
||||||
|
) : (
|
||||||
|
<div className="line-clamp-3">{info.Overview}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="mt-1 text-white/70 hover:text-white transition-colors text-[10px] font-medium"
|
||||||
|
onClick={() => setIsFull(!isFull)}
|
||||||
|
>
|
||||||
|
{isFull ? "Show Less" : "Read More"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
info.Overview
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Basic Info Section */}
|
{/* Watch Button - Full Width on Mobile */}
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="mt-6">
|
||||||
{/* Title */}
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<h1 className="text-lg xs:text-xl font-bold tracking-tight truncate">
|
|
||||||
{safeTitle}
|
|
||||||
</h1>
|
|
||||||
{language === "EN" && japanese_title && (
|
|
||||||
<p className="text-white/50 text-[11px] xs:text-xs truncate">JP Title: {japanese_title}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{tags.map(({ condition, icon, text }, index) =>
|
|
||||||
condition && (
|
|
||||||
<Tag
|
|
||||||
key={index}
|
|
||||||
index={index}
|
|
||||||
icon={icon}
|
|
||||||
text={text}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overview - Limited for mobile */}
|
|
||||||
{info?.Overview && (
|
|
||||||
<div className="text-gray-300 leading-relaxed text-xs">
|
|
||||||
{info.Overview.length > 150 ? (
|
|
||||||
<>
|
|
||||||
{isFull ? (
|
|
||||||
info.Overview
|
|
||||||
) : (
|
|
||||||
<div className="line-clamp-3">{info.Overview}</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="mt-1 text-white/70 hover:text-white transition-colors text-[10px] font-medium"
|
|
||||||
onClick={() => setIsFull(!isFull)}
|
|
||||||
>
|
|
||||||
{isFull ? "Show Less" : "Read More"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
info.Overview
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Watch Button - Full Width on Mobile */}
|
|
||||||
<div className="mt-6">
|
|
||||||
{animeInfo?.animeInfo?.Status?.toLowerCase() !== "not-yet-aired" ? (
|
|
||||||
<Link
|
|
||||||
to={`/watch/${animeInfo.id}`}
|
|
||||||
className="flex justify-center items-center w-full px-4 py-3 bg-white/10 backdrop-blur-md rounded-lg text-white transition-all duration-300 hover:bg-white/20 group"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faPlay}
|
|
||||||
className="mr-2 text-xs group-hover:text-white"
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-sm">Watch Now</span>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-center items-center w-full px-4 py-3 bg-gray-700/50 rounded-lg">
|
|
||||||
<span className="font-medium text-sm">Not released</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details Section - Full Width on Mobile */}
|
|
||||||
<div className="mt-6 space-y-3 py-3 backdrop-blur-md bg-white/5 rounded-lg px-3 text-xs">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{[
|
|
||||||
{ label: "Japanese", value: info?.Japanese },
|
|
||||||
{ label: "Synonyms", value: info?.Synonyms },
|
|
||||||
{ label: "Aired", value: info?.Aired },
|
|
||||||
{ label: "Premiered", value: info?.Premiered },
|
|
||||||
{ label: "Duration", value: info?.Duration },
|
|
||||||
{ label: "Status", value: info?.Status },
|
|
||||||
{ label: "MAL Score", value: info?.["MAL Score"] },
|
|
||||||
].map((item, index) => (
|
|
||||||
<InfoItem
|
|
||||||
key={index}
|
|
||||||
label={item.label}
|
|
||||||
value={item.value}
|
|
||||||
isProducer={false}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{info?.Genres && (
|
|
||||||
<div className="pt-2 border-t border-white/10">
|
|
||||||
<p className="text-gray-400 text-xs mb-1.5">Genres</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{info.Genres.map((genre, index) => (
|
|
||||||
<Link
|
|
||||||
to={`/genre/${genre.split(" ").join("-")}`}
|
|
||||||
key={index}
|
|
||||||
className="px-2 py-0.5 text-[10px] bg-white/5 rounded-md hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Studios & Producers */}
|
|
||||||
<div className="space-y-2 pt-2 border-t border-white/10">
|
|
||||||
{[
|
|
||||||
{ label: "Studios", value: info?.Studios },
|
|
||||||
{ label: "Producers", value: info?.Producers },
|
|
||||||
].map((item, index) => (
|
|
||||||
<InfoItem
|
|
||||||
key={index}
|
|
||||||
label={item.label}
|
|
||||||
value={item.value}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop Layout - Existing Code */}
|
|
||||||
<div className="hidden md:block">
|
|
||||||
<div className="flex flex-row gap-6 lg:gap-10">
|
|
||||||
{/* Poster Section */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div className="relative w-[220px] lg:w-[260px] aspect-[2/3] rounded-2xl overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
|
||||||
<img
|
|
||||||
src={`${poster}`}
|
|
||||||
alt={`${safeTitle} Poster`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
{animeInfo.adultContent && (
|
|
||||||
<div className="absolute top-3 left-3 px-2.5 py-0.5 bg-red-500/90 backdrop-blur-sm rounded-lg text-xs font-medium">
|
|
||||||
18+
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Section */}
|
|
||||||
<div className="flex-1 space-y-4 lg:space-y-5 min-w-0">
|
|
||||||
{/* Title */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h1 className="text-3xl lg:text-4xl font-bold tracking-tight truncate">
|
|
||||||
{safeTitle}
|
|
||||||
</h1>
|
|
||||||
{language === "EN" && japanese_title && (
|
|
||||||
<p className="text-white/50 text-sm lg:text-base truncate">JP Title: {japanese_title}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{tags.map(({ condition, icon, text }, index) =>
|
|
||||||
condition && (
|
|
||||||
<Tag
|
|
||||||
key={index}
|
|
||||||
index={index}
|
|
||||||
icon={icon}
|
|
||||||
text={text}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overview */}
|
|
||||||
{info?.Overview && (
|
|
||||||
<div className="text-gray-300 leading-relaxed max-w-3xl text-sm lg:text-base">
|
|
||||||
{info.Overview.length > 270 ? (
|
|
||||||
<>
|
|
||||||
{isFull
|
|
||||||
? info.Overview
|
|
||||||
: `${info.Overview.slice(0, 270)}...`}
|
|
||||||
<button
|
|
||||||
className="ml-2 text-white/70 hover:text-white transition-colors text-sm font-medium"
|
|
||||||
onClick={() => setIsFull(!isFull)}
|
|
||||||
>
|
|
||||||
{isFull ? "Show Less" : "Read More"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
info.Overview
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Watch Button */}
|
|
||||||
{animeInfo?.animeInfo?.Status?.toLowerCase() !== "not-yet-aired" ? (
|
{animeInfo?.animeInfo?.Status?.toLowerCase() !== "not-yet-aired" ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/watch/${animeInfo.id}`}
|
to={`/watch/${animeInfo.id}`}
|
||||||
className="inline-flex items-center px-5 py-2.5 bg-white/10 backdrop-blur-md rounded-xl text-white transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] group"
|
className="flex justify-center items-center w-full px-4 py-3 bg-white/10 backdrop-blur-md rounded-lg text-white transition-all duration-300 hover:bg-white/20 group"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faPlay}
|
icon={faPlay}
|
||||||
className="mr-2 text-sm group-hover:text-white"
|
className="mr-2 text-xs group-hover:text-white"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">Watch Now</span>
|
<span className="font-medium text-sm">Watch Now</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="inline-flex items-center px-5 py-2.5 bg-gray-700/50 rounded-xl">
|
<div className="flex justify-center items-center w-full px-4 py-3 bg-gray-700/50 rounded-lg">
|
||||||
<span className="font-medium">Not released</span>
|
<span className="font-medium text-sm">Not released</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Section - Full Width on Mobile */}
|
||||||
|
<div className="mt-6 space-y-3 py-3 backdrop-blur-md bg-white/5 rounded-lg px-3 text-xs">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: "Japanese", value: info?.Japanese },
|
||||||
|
{ label: "Synonyms", value: info?.Synonyms },
|
||||||
|
{ label: "Aired", value: info?.Aired },
|
||||||
|
{ label: "Premiered", value: info?.Premiered },
|
||||||
|
{ label: "Duration", value: info?.Duration },
|
||||||
|
{ label: "Status", value: info?.Status },
|
||||||
|
{ label: "MAL Score", value: info?.["MAL Score"] },
|
||||||
|
].map((item, index) => (
|
||||||
|
<InfoItem
|
||||||
|
key={index}
|
||||||
|
label={item.label}
|
||||||
|
value={item.value}
|
||||||
|
isProducer={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{info?.Genres && (
|
||||||
|
<div className="pt-2 border-t border-white/10">
|
||||||
|
<p className="text-gray-400 text-xs mb-1.5">Genres</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{info.Genres.map((genre, index) => (
|
||||||
|
<Link
|
||||||
|
to={`/genre/${genre.split(" ").join("-")}`}
|
||||||
|
key={index}
|
||||||
|
className="px-2 py-0.5 text-[10px] bg-white/5 rounded-md hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Details Section */}
|
{/* Studios & Producers */}
|
||||||
<div className="space-y-4 py-4 backdrop-blur-md bg-white/5 rounded-xl px-5">
|
<div className="space-y-2 pt-2 border-t border-white/10">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{[
|
||||||
{[
|
{ label: "Studios", value: info?.Studios },
|
||||||
{ label: "Japanese", value: info?.Japanese },
|
{ label: "Producers", value: info?.Producers },
|
||||||
{ label: "Synonyms", value: info?.Synonyms },
|
].map((item, index) => (
|
||||||
{ label: "Aired", value: info?.Aired },
|
<InfoItem
|
||||||
{ label: "Premiered", value: info?.Premiered },
|
key={index}
|
||||||
{ label: "Duration", value: info?.Duration },
|
label={item.label}
|
||||||
{ label: "Status", value: info?.Status },
|
value={item.value}
|
||||||
{ label: "MAL Score", value: info?.["MAL Score"] },
|
/>
|
||||||
].map((item, index) => (
|
))}
|
||||||
<InfoItem
|
</div>
|
||||||
key={index}
|
</div>
|
||||||
label={item.label}
|
</div>
|
||||||
value={item.value}
|
|
||||||
isProducer={false}
|
{/* Desktop Layout - Existing Code */}
|
||||||
/>
|
<div className="hidden md:block">
|
||||||
))}
|
<div className="flex flex-row gap-6 lg:gap-10">
|
||||||
|
{/* Poster Section */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="relative w-[220px] lg:w-[260px] aspect-[2/3] rounded-2xl overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
||||||
|
<img
|
||||||
|
src={`${poster}`}
|
||||||
|
alt={`${safeTitle} Poster`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{animeInfo.adultContent && (
|
||||||
|
<div className="absolute top-3 left-3 px-2.5 py-0.5 bg-red-500/90 backdrop-blur-sm rounded-lg text-xs font-medium">
|
||||||
|
18+
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<div className="flex-1 space-y-4 lg:space-y-5 min-w-0">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-3xl lg:text-4xl font-bold tracking-tight truncate">
|
||||||
|
{safeTitle}
|
||||||
|
</h1>
|
||||||
|
{language === "EN" && japanese_title && (
|
||||||
|
<p className="text-white/50 text-sm lg:text-base truncate">JP Title: {japanese_title}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Genres */}
|
{/* Tags */}
|
||||||
{info?.Genres && (
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="pt-3 border-t border-white/10">
|
{tags.map(({ condition, icon, text }, index) =>
|
||||||
<p className="text-gray-400 text-sm mb-2">Genres</p>
|
condition && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<Tag
|
||||||
{info.Genres.map((genre, index) => (
|
key={index}
|
||||||
<Link
|
index={index}
|
||||||
to={`/genre/${genre.split(" ").join("-")}`}
|
icon={icon}
|
||||||
key={index}
|
text={text}
|
||||||
className="px-3 py-1 text-xs bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
{info?.Overview && (
|
||||||
|
<div className="text-gray-300 leading-relaxed max-w-3xl text-sm lg:text-base">
|
||||||
|
{info.Overview.length > 270 ? (
|
||||||
|
<>
|
||||||
|
{isFull
|
||||||
|
? info.Overview
|
||||||
|
: `${info.Overview.slice(0, 270)}...`}
|
||||||
|
<button
|
||||||
|
className="ml-2 text-white/70 hover:text-white transition-colors text-sm font-medium"
|
||||||
|
onClick={() => setIsFull(!isFull)}
|
||||||
>
|
>
|
||||||
{genre}
|
{isFull ? "Show Less" : "Read More"}
|
||||||
</Link>
|
</button>
|
||||||
))}
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
info.Overview
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Studios & Producers */}
|
{/* Watch Button */}
|
||||||
<div className="space-y-3 pt-3 border-t border-white/10">
|
{animeInfo?.animeInfo?.Status?.toLowerCase() !== "not-yet-aired" ? (
|
||||||
{[
|
<Link
|
||||||
{ label: "Studios", value: info?.Studios },
|
to={`/watch/${animeInfo.id}`}
|
||||||
{ label: "Producers", value: info?.Producers },
|
className="inline-flex items-center px-5 py-2.5 bg-white/10 backdrop-blur-md rounded-xl text-white transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] group"
|
||||||
].map((item, index) => (
|
>
|
||||||
<InfoItem
|
<FontAwesomeIcon
|
||||||
key={index}
|
icon={faPlay}
|
||||||
label={item.label}
|
className="mr-2 text-sm group-hover:text-white"
|
||||||
value={item.value}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<span className="font-medium">Watch Now</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex items-center px-5 py-2.5 bg-gray-700/50 rounded-xl">
|
||||||
|
<span className="font-medium">Not released</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details Section */}
|
||||||
|
<div className="space-y-4 py-4 backdrop-blur-md bg-white/5 rounded-xl px-5">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: "Japanese", value: info?.Japanese },
|
||||||
|
{ label: "Synonyms", value: info?.Synonyms },
|
||||||
|
{ label: "Aired", value: info?.Aired },
|
||||||
|
{ label: "Premiered", value: info?.Premiered },
|
||||||
|
{ label: "Duration", value: info?.Duration },
|
||||||
|
{ label: "Status", value: info?.Status },
|
||||||
|
{ label: "MAL Score", value: info?.["MAL Score"] },
|
||||||
|
].map((item, index) => (
|
||||||
|
<InfoItem
|
||||||
|
key={index}
|
||||||
|
label={item.label}
|
||||||
|
value={item.value}
|
||||||
|
isProducer={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{info?.Genres && (
|
||||||
|
<div className="pt-3 border-t border-white/10">
|
||||||
|
<p className="text-gray-400 text-sm mb-2">Genres</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{info.Genres.map((genre, index) => (
|
||||||
|
<Link
|
||||||
|
to={`/genre/${genre.split(" ").join("-")}`}
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 text-xs bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Studios & Producers */}
|
||||||
|
<div className="space-y-3 pt-3 border-t border-white/10">
|
||||||
|
{[
|
||||||
|
{ label: "Studios", value: info?.Studios },
|
||||||
|
{ label: "Producers", value: info?.Producers },
|
||||||
|
].map((item, index) => (
|
||||||
|
<InfoItem
|
||||||
|
key={index}
|
||||||
|
label={item.label}
|
||||||
|
value={item.value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Seasons Section */}
|
{/* Seasons Section */}
|
||||||
{seasons?.length > 0 && (
|
{seasons?.length > 0 && (
|
||||||
<div className="container mx-auto py-8 sm:py-12">
|
<div className="container mx-auto py-8 sm:py-12">
|
||||||
<h2 className="text-2xl font-bold mb-6 sm:mb-8 px-1">More Seasons</h2>
|
<h2 className="text-2xl font-bold mb-6 sm:mb-8 px-1">More Seasons</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 sm:gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 sm:gap-4">
|
||||||
{seasons.map((season, index) => (
|
{seasons.map((season, index) => (
|
||||||
<Link
|
<Link
|
||||||
to={`/${season.id}`}
|
to={`/${season.id}`}
|
||||||
key={index}
|
key={index}
|
||||||
className={`relative w-full aspect-[3/1] sm:aspect-[3/1] rounded-lg overflow-hidden cursor-pointer group ${currentId === String(season.id)
|
className={`relative w-full aspect-[3/1] sm:aspect-[3/1] rounded-lg overflow-hidden cursor-pointer group ${currentId === String(season.id)
|
||||||
? "ring-2 ring-white/40 shadow-lg shadow-white/10"
|
? "ring-2 ring-white/40 shadow-lg shadow-white/10"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={season.season_poster}
|
src={season.season_poster}
|
||||||
alt={season.season}
|
alt={season.season}
|
||||||
className={`w-full h-full object-cover scale-150 ${currentId === String(season.id)
|
className={`w-full h-full object-cover scale-150 ${currentId === String(season.id)
|
||||||
? "opacity-50"
|
? "opacity-50"
|
||||||
: "opacity-40"
|
: "opacity-40"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{/* Dots Pattern Overlay */}
|
{/* Dots Pattern Overlay */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-10"
|
className="absolute inset-0 z-10"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url('data:image/svg+xml,<svg width="3" height="3" viewBox="0 0 3 3" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="1.5" cy="1.5" r="0.5" fill="white" fill-opacity="0.25"/></svg>')`,
|
backgroundImage: `url('data:image/svg+xml,<svg width="3" height="3" viewBox="0 0 3 3" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="1.5" cy="1.5" r="0.5" fill="white" fill-opacity="0.25"/></svg>')`,
|
||||||
backgroundSize: '3px 3px'
|
backgroundSize: '3px 3px'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Dark Gradient Overlay */}
|
{/* Dark Gradient Overlay */}
|
||||||
<div className={`absolute inset-0 z-20 bg-gradient-to-r ${currentId === String(season.id)
|
<div className={`absolute inset-0 z-20 bg-gradient-to-r ${currentId === String(season.id)
|
||||||
? "from-black/50 to-transparent"
|
? "from-black/50 to-transparent"
|
||||||
: "from-black/40 to-transparent"
|
: "from-black/40 to-transparent"
|
||||||
}`} />
|
}`} />
|
||||||
{/* Title Container */}
|
{/* Title Container */}
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center">
|
<div className="absolute inset-0 z-30 flex items-center justify-center">
|
||||||
<p className={`text-[14px] sm:text-[16px] md:text-[18px] font-bold text-center px-2 sm:px-4 transition-colors duration-300 ${currentId === String(season.id)
|
<p className={`text-[14px] sm:text-[16px] md:text-[18px] font-bold text-center px-2 sm:px-4 transition-colors duration-300 ${currentId === String(season.id)
|
||||||
? "text-white"
|
? "text-white"
|
||||||
: "text-white/90 group-hover:text-white"
|
: "text-white/90 group-hover:text-white"
|
||||||
}`}>
|
}`}>
|
||||||
{season.season}
|
{season.season}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Voice Actors Section */}
|
{/* Voice Actors Section */}
|
||||||
{animeInfo?.charactersVoiceActors.length > 0 && (
|
{animeInfo?.charactersVoiceActors.length > 0 && (
|
||||||
<div className="container mx-auto py-12">
|
<div className="container mx-auto py-12">
|
||||||
<Voiceactor animeInfo={animeInfo} />
|
<Voiceactor animeInfo={animeInfo} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recommendations Section */}
|
{/* Recommendations Section */}
|
||||||
{animeInfo.recommended_data.length > 0 && (
|
{animeInfo.recommended_data.length > 0 && (
|
||||||
<div className="container mx-auto py-12">
|
<div className="container mx-auto py-12">
|
||||||
<CategoryCard
|
<CategoryCard
|
||||||
label="Recommended for you"
|
label="Recommended for you"
|
||||||
data={animeInfo.recommended_data}
|
data={animeInfo.recommended_data}
|
||||||
limit={animeInfo.recommended_data.length}
|
limit={animeInfo.recommended_data.length}
|
||||||
showViewMore={false}
|
showViewMore={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import CategoryCard from "@/src/components/categorycard/CategoryCard";
|
|||||||
import CategoryCardLoader from "@/src/components/Loader/CategoryCard.loader";
|
import CategoryCardLoader from "@/src/components/Loader/CategoryCard.loader";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import PageSlider from "@/src/components/pageslider/PageSlider";
|
import PageSlider from "@/src/components/pageslider/PageSlider";
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import {
|
||||||
|
generateCategoryMeta,
|
||||||
|
generatePaginationLinks,
|
||||||
|
generateCollectionSchema,
|
||||||
|
generateCanonicalUrl
|
||||||
|
} from "@/src/utils/seo.utils";
|
||||||
|
|
||||||
function Category({ path, label }) {
|
function Category({ path, label }) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -14,7 +21,7 @@ function Category({ path, label }) {
|
|||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
const page = parseInt(searchParams.get("page")) || 1;
|
const page = parseInt(searchParams.get("page")) || 1;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCategoryInfo = async () => {
|
const fetchCategoryInfo = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -39,63 +46,95 @@ function Category({ path, label }) {
|
|||||||
|
|
||||||
const categoryGridClass = "grid-cols-8 max-[1600px]:grid-cols-6 max-[1200px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-3 max-[478px]:gap-x-2";
|
const categoryGridClass = "grid-cols-8 max-[1600px]:grid-cols-6 max-[1200px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-3 max-[478px]:gap-x-2";
|
||||||
|
|
||||||
|
const { title, description, keywords } = generateCategoryMeta(label, page);
|
||||||
|
const canonicalUrl = generateCanonicalUrl(`/${path}${page > 1 ? `?page=${page}` : ''}`);
|
||||||
|
const paginationLinks = generatePaginationLinks(`/${path}`, page, totalPages);
|
||||||
|
const collectionSchema = generateCollectionSchema(categoryInfo, label, path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1600px] mx-auto flex flex-col mt-[64px] max-md:mt-[50px]">
|
<>
|
||||||
<div className="w-full flex flex-col gap-y-8 mt-6">
|
<Helmet>
|
||||||
{loading ? (
|
<title>{title}</title>
|
||||||
<CategoryCardLoader className={"max-[478px]:mt-2"} gridClass={categoryGridClass} />
|
<meta name="description" content={description} />
|
||||||
) : page > totalPages ? (
|
<meta name="keywords" content={keywords} />
|
||||||
<div className="flex flex-col gap-y-4">
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
|
||||||
{label.split("/").pop()}
|
{paginationLinks.map((link, index) => (
|
||||||
</h1>
|
<link key={index} rel={link.rel} href={link.href} />
|
||||||
<p className="text-white text-lg max-[478px]:text-[16px] max-[300px]:leading-6">
|
))}
|
||||||
You came a long way, go back <br className="max-[300px]:hidden" />
|
|
||||||
nothing is here
|
<meta property="og:title" content={title} />
|
||||||
</p>
|
<meta property="og:description" content={description} />
|
||||||
</div>
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
) : categoryInfo && categoryInfo.length > 0 ? (
|
<meta property="og:type" content="website" />
|
||||||
<div className="flex flex-col gap-y-2 max-[478px]:gap-y-0">
|
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
<meta name="twitter:card" content="summary" />
|
||||||
{label.split("/").pop()}
|
<meta name="twitter:title" content={title} />
|
||||||
</h1>
|
<meta name="twitter:description" content={description} />
|
||||||
<CategoryCard
|
|
||||||
data={categoryInfo}
|
{collectionSchema && (
|
||||||
showViewMore={false}
|
<script type="application/ld+json">
|
||||||
className="mt-0"
|
{JSON.stringify(collectionSchema)}
|
||||||
gridClass={categoryGridClass}
|
</script>
|
||||||
categoryPage={true}
|
|
||||||
path={path}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-center w-full mt-8">
|
|
||||||
<PageSlider
|
|
||||||
page={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
handlePageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
|
||||||
{label.split("/").pop()}
|
|
||||||
</h1>
|
|
||||||
<p className="text-white text-lg max-[478px]:text-[16px]">
|
|
||||||
Couldn't get {label.split("/").pop()} results, please try again
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
|
||||||
{label.split("/").pop()}
|
|
||||||
</h1>
|
|
||||||
<p className="text-white text-lg max-[478px]:text-[16px]">
|
|
||||||
No results found for: {label.split("/").pop()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</Helmet>
|
||||||
|
<div className="max-w-[1600px] mx-auto flex flex-col mt-[64px] max-md:mt-[50px]">
|
||||||
|
<div className="w-full flex flex-col gap-y-8 mt-6">
|
||||||
|
{loading ? (
|
||||||
|
<CategoryCardLoader className={"max-[478px]:mt-2"} gridClass={categoryGridClass} />
|
||||||
|
) : page > totalPages ? (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
{label.split("/").pop()}
|
||||||
|
</h1>
|
||||||
|
<p className="text-white text-lg max-[478px]:text-[16px] max-[300px]:leading-6">
|
||||||
|
You came a long way, go back <br className="max-[300px]:hidden" />
|
||||||
|
nothing is here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : categoryInfo && categoryInfo.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-y-2 max-[478px]:gap-y-0">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
{label.split("/").pop()}
|
||||||
|
</h1>
|
||||||
|
<CategoryCard
|
||||||
|
data={categoryInfo}
|
||||||
|
showViewMore={false}
|
||||||
|
className="mt-0"
|
||||||
|
gridClass={categoryGridClass}
|
||||||
|
categoryPage={true}
|
||||||
|
path={path}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-center w-full mt-8">
|
||||||
|
<PageSlider
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
handlePageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
{label.split("/").pop()}
|
||||||
|
</h1>
|
||||||
|
<p className="text-white text-lg max-[478px]:text-[16px]">
|
||||||
|
Couldn't get {label.split("/").pop()} results, please try again
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
{label.split("/").pop()}
|
||||||
|
</h1>
|
||||||
|
<p className="text-white text-lg max-[478px]:text-[16px]">
|
||||||
|
No results found for: {label.split("/").pop()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import PageSlider from '@/src/components/pageslider/PageSlider';
|
|||||||
import getSearch from '@/src/utils/getSearch.utils';
|
import getSearch from '@/src/utils/getSearch.utils';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import {
|
||||||
|
generateSearchMeta,
|
||||||
|
generatePaginationLinks,
|
||||||
|
generateCanonicalUrl,
|
||||||
|
generateItemListSchema
|
||||||
|
} from '@/src/utils/seo.utils';
|
||||||
|
|
||||||
function Search() {
|
function Search() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -15,10 +22,10 @@ function Search() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSearch = async () => {
|
const fetchSearch = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getSearch(keyword,page);
|
const data = await getSearch(keyword, page);
|
||||||
setSearchData(data.data);
|
setSearchData(data.data);
|
||||||
setTotalPages(data.totalPage);
|
setTotalPages(data.totalPage);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -38,60 +45,96 @@ function Search() {
|
|||||||
|
|
||||||
const searchGridClass = "grid-cols-8 max-[1600px]:grid-cols-6 max-[1200px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-3 max-[478px]:gap-x-2";
|
const searchGridClass = "grid-cols-8 max-[1600px]:grid-cols-6 max-[1200px]:grid-cols-4 max-[758px]:grid-cols-3 max-[478px]:grid-cols-3 max-[478px]:gap-x-2";
|
||||||
|
|
||||||
|
const { title, description, keywords } = generateSearchMeta(keyword);
|
||||||
|
const canonicalUrl = generateCanonicalUrl(`/search?keyword=${keyword || ''}${page > 1 ? `&page=${page}` : ''}`);
|
||||||
|
const paginationLinks = generatePaginationLinks('/search', page, totalPages);
|
||||||
|
|
||||||
|
// Create an ItemList for search results for SEO
|
||||||
|
const itemListSchema = searchData && searchData.length > 0
|
||||||
|
? generateItemListSchema(searchData, `Search results for ${keyword}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1600px] mx-auto flex flex-col mt-[64px] max-md:mt-[50px]">
|
<>
|
||||||
<div className="w-full flex flex-col gap-y-8 mt-6">
|
<Helmet>
|
||||||
{loading ? (
|
<title>{title}</title>
|
||||||
<CategoryCardLoader className={"max-[478px]:mt-2"} gridClass={searchGridClass} />
|
<meta name="description" content={description} />
|
||||||
) : page > totalPages ? (
|
<meta name="keywords" content={keywords} />
|
||||||
<div className="flex flex-col gap-y-4">
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
|
||||||
Search Results
|
{paginationLinks.map((link, index) => (
|
||||||
</h1>
|
<link key={index} rel={link.rel} href={link.href} />
|
||||||
<p className='text-white text-lg max-[478px]:text-[16px] max-[300px]:leading-6'>
|
))}
|
||||||
You came a long way, go back <br className='max-[300px]:hidden' />nothing is here
|
|
||||||
</p>
|
<meta property="og:title" content={title} />
|
||||||
</div>
|
<meta property="og:description" content={description} />
|
||||||
) : searchData && searchData.length > 0 ? (
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
<div className="flex flex-col gap-y-2 max-[478px]:gap-y-0">
|
<meta property="og:type" content="website" />
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
|
||||||
Search Results for: {keyword}
|
<meta name="twitter:card" content="summary" />
|
||||||
</h1>
|
<meta name="twitter:title" content={title} />
|
||||||
<CategoryCard
|
<meta name="twitter:description" content={description} />
|
||||||
data={searchData}
|
|
||||||
showViewMore={false}
|
{itemListSchema && (
|
||||||
className="mt-0"
|
<script type="application/ld+json">
|
||||||
gridClass={searchGridClass}
|
{JSON.stringify(itemListSchema)}
|
||||||
/>
|
</script>
|
||||||
<div className="flex justify-center w-full mt-8">
|
|
||||||
<PageSlider
|
|
||||||
page={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
handlePageChange={handlePageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
|
||||||
Search Results
|
|
||||||
</h1>
|
|
||||||
<p className='text-white text-lg max-[478px]:text-[16px]'>
|
|
||||||
Couldn't get search results, please try again
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
|
||||||
Search Results
|
|
||||||
</h1>
|
|
||||||
<p className='text-white text-lg max-[478px]:text-[16px]'>
|
|
||||||
No results found for: {keyword}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</Helmet>
|
||||||
|
<div className="max-w-[1600px] mx-auto flex flex-col mt-[64px] max-md:mt-[50px]">
|
||||||
|
<div className="w-full flex flex-col gap-y-8 mt-6">
|
||||||
|
{loading ? (
|
||||||
|
<CategoryCardLoader className={"max-[478px]:mt-2"} gridClass={searchGridClass} />
|
||||||
|
) : page > totalPages ? (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
Search Results
|
||||||
|
</h1>
|
||||||
|
<p className='text-white text-lg max-[478px]:text-[16px] max-[300px]:leading-6'>
|
||||||
|
You came a long way, go back <br className='max-[300px]:hidden' />nothing is here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : searchData && searchData.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-y-2 max-[478px]:gap-y-0">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
Search Results for: {keyword}
|
||||||
|
</h1>
|
||||||
|
<CategoryCard
|
||||||
|
data={searchData}
|
||||||
|
showViewMore={false}
|
||||||
|
className="mt-0"
|
||||||
|
gridClass={searchGridClass}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-center w-full mt-8">
|
||||||
|
<PageSlider
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
handlePageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
Search Results
|
||||||
|
</h1>
|
||||||
|
<p className='text-white text-lg max-[478px]:text-[16px]'>
|
||||||
|
Couldn't get search results, please try again
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<h1 className="font-bold text-2xl text-white max-[478px]:text-[18px]">
|
||||||
|
Search Results
|
||||||
|
</h1>
|
||||||
|
<p className='text-white text-lg max-[478px]:text-[16px]'>
|
||||||
|
No results found for: {keyword}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ import Watchcontrols from "@/src/components/watchcontrols/Watchcontrols";
|
|||||||
import useWatchControl from "@/src/hooks/useWatchControl";
|
import useWatchControl from "@/src/hooks/useWatchControl";
|
||||||
import Player from "@/src/components/player/Player";
|
import Player from "@/src/components/player/Player";
|
||||||
import getSafeTitle from "@/src/utils/getSafetitle";
|
import getSafeTitle from "@/src/utils/getSafetitle";
|
||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
import {
|
||||||
|
generateDescription,
|
||||||
|
generateKeywords,
|
||||||
|
generateCanonicalUrl,
|
||||||
|
generateOGImage,
|
||||||
|
generateAnimeStructuredData,
|
||||||
|
generateVideoStructuredData,
|
||||||
|
generateBreadcrumbStructuredData,
|
||||||
|
optimizeTitle,
|
||||||
|
} from '@/src/utils/seo.utils';
|
||||||
|
|
||||||
export default function Watch() {
|
export default function Watch() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -106,16 +117,6 @@ export default function Watch() {
|
|||||||
|
|
||||||
// ... inside Watch component ...
|
// ... inside Watch component ...
|
||||||
// Update document title
|
// Update document title
|
||||||
useEffect(() => {
|
|
||||||
if (animeInfo) {
|
|
||||||
const safeTitle = getSafeTitle(animeInfo.title, language, animeInfo.japanese_title);
|
|
||||||
document.title = `Watch ${safeTitle} English Sub/Dub online Free on ${website_name}`;
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.title = `${website_name} | Free anime streaming platform`;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [animeId, animeInfo, language]);
|
|
||||||
|
|
||||||
// Redirect if no episodes
|
// Redirect if no episodes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -225,189 +226,377 @@ export default function Watch() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}, [animeId, animeInfo]);
|
}, [animeId, animeInfo]);
|
||||||
|
|
||||||
|
const safeTitle = animeInfo ? getSafeTitle(animeInfo.title, language, animeInfo.japanese_title) : '';
|
||||||
|
const pageTitle = animeInfo ? optimizeTitle(`Watch ${safeTitle} Episode ${activeEpisodeNum} Sub Dub Online Free`) : `${website_name} | Free anime streaming platform`;
|
||||||
|
const pageDescription = animeInfo ? generateDescription(`Stream ${safeTitle} Episode ${activeEpisodeNum} in HD with English Sub and Dub. ${animeInfo.animeInfo?.Overview}`) : '';
|
||||||
|
const pageKeywords = animeInfo ? generateKeywords(animeInfo) + `, episode ${activeEpisodeNum}` : '';
|
||||||
|
const canonicalUrl = generateCanonicalUrl(`/watch/${animeId}?ep=${episodeId}`);
|
||||||
|
const ogImage = animeInfo ? generateOGImage(animeInfo.poster) : '';
|
||||||
|
|
||||||
|
const animeStructuredData = animeInfo ? generateAnimeStructuredData(animeInfo, { number: activeEpisodeNum, id: episodeId }) : null;
|
||||||
|
const videoStructuredData = animeInfo ? generateVideoStructuredData(animeInfo, { number: activeEpisodeNum, id: episodeId }, streamUrl) : null;
|
||||||
|
const breadcrumbData = animeInfo ? generateBreadcrumbStructuredData([
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
{ name: animeInfo.title, url: `/${animeId}` },
|
||||||
|
{ name: `Episode ${activeEpisodeNum}`, url: `/watch/${animeId}?ep=${episodeId}` }
|
||||||
|
]) : null;
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-screen bg-[#0a0a0a]">
|
<>
|
||||||
<div className="w-full max-w-[1920px] mx-auto pt-16 pb-6 w-full max-[1200px]:pt-12">
|
<Helmet>
|
||||||
<div className="grid grid-cols-[minmax(0,70%),minmax(0,30%)] gap-6 w-full h-full max-[1200px]:flex max-[1200px]:flex-col">
|
<title>{pageTitle}</title>
|
||||||
{/* Left Column - Player, Controls, Servers */}
|
<meta name="description" content={pageDescription} />
|
||||||
<div className="flex flex-col w-full gap-6">
|
<meta name="keywords" content={pageKeywords} />
|
||||||
<div ref={playerRef} className="player w-full h-fit bg-black flex flex-col rounded-xl overflow-hidden">
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
{/* Video Container */}
|
|
||||||
<div ref={videoContainerRef} className="w-full relative aspect-video bg-black">
|
|
||||||
{!buffering ? (["hd-1", "hd-4"].includes(activeServerName.toLowerCase()) ?
|
|
||||||
<IframePlayer
|
|
||||||
episodeId={episodeId}
|
|
||||||
servertype={activeServerType}
|
|
||||||
serverName={activeServerName}
|
|
||||||
animeInfo={animeInfo}
|
|
||||||
episodeNum={activeEpisodeNum}
|
|
||||||
episodes={episodes}
|
|
||||||
playNext={(id) => setEpisodeId(id)}
|
|
||||||
autoNext={autoNext}
|
|
||||||
/> : <Player
|
|
||||||
streamUrl={streamUrl}
|
|
||||||
subtitles={subtitles}
|
|
||||||
intro={intro}
|
|
||||||
outro={outro}
|
|
||||||
serverName={activeServerName.toLowerCase()}
|
|
||||||
thumbnail={thumbnail}
|
|
||||||
autoSkipIntro={autoSkipIntro}
|
|
||||||
autoPlay={autoPlay}
|
|
||||||
autoNext={autoNext}
|
|
||||||
episodeId={episodeId}
|
|
||||||
episodes={episodes}
|
|
||||||
playNext={(id) => setEpisodeId(id)}
|
|
||||||
animeInfo={animeInfo}
|
|
||||||
episodeNum={activeEpisodeNum}
|
|
||||||
streamInfo={streamInfo}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex justify-center items-center bg-black">
|
|
||||||
<BouncingLoader />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-center underline font-medium text-[15px] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none text-gray-300">
|
|
||||||
{!buffering && !activeServerType ? (
|
|
||||||
servers ? (
|
|
||||||
<>
|
|
||||||
Probably this server is down, try other servers
|
|
||||||
<br />
|
|
||||||
Either reload or try again after sometime
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Probably streaming server is down
|
|
||||||
<br />
|
|
||||||
Either reload or try again after sometime
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
<meta property="og:title" content={pageTitle} />
|
||||||
<div className="bg-[#121212]">
|
<meta property="og:description" content={pageDescription} />
|
||||||
{!buffering && (
|
<meta property="og:image" content={ogImage} />
|
||||||
<div ref={controlsRef}>
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
<Watchcontrols
|
<meta property="og:type" content="video.episode" />
|
||||||
autoPlay={autoPlay}
|
|
||||||
setAutoPlay={setAutoPlay}
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
autoSkipIntro={autoSkipIntro}
|
<meta name="twitter:title" content={pageTitle} />
|
||||||
setAutoSkipIntro={setAutoSkipIntro}
|
<meta name="twitter:description" content={pageDescription} />
|
||||||
autoNext={autoNext}
|
<meta name="twitter:image" content={ogImage} />
|
||||||
setAutoNext={setAutoNext}
|
|
||||||
episodes={episodes}
|
{animeStructuredData && (
|
||||||
totalEpisodes={totalEpisodes}
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(animeStructuredData)}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
{videoStructuredData && (
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(videoStructuredData)}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
{breadcrumbData && (
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(breadcrumbData)}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
</Helmet>
|
||||||
|
<div className="w-full min-h-screen bg-[#0a0a0a]">
|
||||||
|
<div className="w-full max-w-[1920px] mx-auto pt-16 pb-6 w-full max-[1200px]:pt-12">
|
||||||
|
<div className="grid grid-cols-[minmax(0,70%),minmax(0,30%)] gap-6 w-full h-full max-[1200px]:flex max-[1200px]:flex-col">
|
||||||
|
{/* Left Column - Player, Controls, Servers */}
|
||||||
|
<div className="flex flex-col w-full gap-6">
|
||||||
|
<div ref={playerRef} className="player w-full h-fit bg-black flex flex-col rounded-xl overflow-hidden">
|
||||||
|
{/* Video Container */}
|
||||||
|
<div ref={videoContainerRef} className="w-full relative aspect-video bg-black">
|
||||||
|
{!buffering ? (["hd-1", "hd-4"].includes(activeServerName.toLowerCase()) ?
|
||||||
|
<IframePlayer
|
||||||
episodeId={episodeId}
|
episodeId={episodeId}
|
||||||
onButtonClick={(id) => setEpisodeId(id)}
|
servertype={activeServerType}
|
||||||
|
serverName={activeServerName}
|
||||||
|
animeInfo={animeInfo}
|
||||||
|
episodeNum={activeEpisodeNum}
|
||||||
|
episodes={episodes}
|
||||||
|
playNext={(id) => setEpisodeId(id)}
|
||||||
|
autoNext={autoNext}
|
||||||
|
/> : <Player
|
||||||
|
streamUrl={streamUrl}
|
||||||
|
subtitles={subtitles}
|
||||||
|
intro={intro}
|
||||||
|
outro={outro}
|
||||||
|
serverName={activeServerName.toLowerCase()}
|
||||||
|
thumbnail={thumbnail}
|
||||||
|
autoSkipIntro={autoSkipIntro}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
autoNext={autoNext}
|
||||||
|
episodeId={episodeId}
|
||||||
|
episodes={episodes}
|
||||||
|
playNext={(id) => setEpisodeId(id)}
|
||||||
|
animeInfo={animeInfo}
|
||||||
|
episodeNum={activeEpisodeNum}
|
||||||
|
streamInfo={streamInfo}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="absolute inset-0 flex justify-center items-center bg-black">
|
||||||
|
<BouncingLoader />
|
||||||
{/* Title and Server Selection */}
|
</div>
|
||||||
<div className="px-3 py-2">
|
)}
|
||||||
<div>
|
<p className="text-center underline font-medium text-[15px] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none text-gray-300">
|
||||||
<Servers
|
{!buffering && !activeServerType ? (
|
||||||
servers={servers}
|
servers ? (
|
||||||
activeEpisodeNum={activeEpisodeNum}
|
<>
|
||||||
activeServerId={activeServerId}
|
Probably this server is down, try other servers
|
||||||
setActiveServerId={setActiveServerId}
|
<br />
|
||||||
serverLoading={serverLoading}
|
Either reload or try again after sometime
|
||||||
setActiveServerType={setActiveServerType}
|
</>
|
||||||
activeServerType={activeServerType}
|
) : (
|
||||||
setActiveServerName={setActiveServerName}
|
<>
|
||||||
/>
|
Probably streaming server is down
|
||||||
</div>
|
<br />
|
||||||
|
Either reload or try again after sometime
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Next Episode Schedule */}
|
{/* Controls Section */}
|
||||||
{nextEpisodeSchedule?.nextEpisodeSchedule && showNextEpisodeSchedule && (
|
<div className="bg-[#121212]">
|
||||||
<div className="px-3 pb-3">
|
{!buffering && (
|
||||||
<div className="w-full p-3 rounded-lg bg-[#272727] flex items-center justify-between">
|
<div ref={controlsRef}>
|
||||||
<div className="flex items-center gap-x-3">
|
<Watchcontrols
|
||||||
<span className="text-[18px]">🚀</span>
|
autoPlay={autoPlay}
|
||||||
<div>
|
setAutoPlay={setAutoPlay}
|
||||||
<span className="text-gray-400 text-sm">Next episode estimated at</span>
|
autoSkipIntro={autoSkipIntro}
|
||||||
<span className="ml-2 text-white text-sm font-medium">
|
setAutoSkipIntro={setAutoSkipIntro}
|
||||||
{new Date(
|
autoNext={autoNext}
|
||||||
new Date(nextEpisodeSchedule.nextEpisodeSchedule).getTime() -
|
setAutoNext={setAutoNext}
|
||||||
new Date().getTimezoneOffset() * 60000
|
episodes={episodes}
|
||||||
).toLocaleDateString("en-GB", {
|
totalEpisodes={totalEpisodes}
|
||||||
day: "2-digit",
|
episodeId={episodeId}
|
||||||
month: "2-digit",
|
onButtonClick={(id) => setEpisodeId(id)}
|
||||||
year: "numeric",
|
/>
|
||||||
hour: "2-digit",
|
</div>
|
||||||
minute: "2-digit",
|
)}
|
||||||
second: "2-digit",
|
|
||||||
hour12: true,
|
{/* Title and Server Selection */}
|
||||||
})}
|
<div className="px-3 py-2">
|
||||||
</span>
|
<div>
|
||||||
</div>
|
<Servers
|
||||||
</div>
|
servers={servers}
|
||||||
<button
|
activeEpisodeNum={activeEpisodeNum}
|
||||||
className="text-2xl text-gray-500 hover:text-white transition-colors"
|
activeServerId={activeServerId}
|
||||||
onClick={() => setShowNextEpisodeSchedule(false)}
|
setActiveServerId={setActiveServerId}
|
||||||
>
|
serverLoading={serverLoading}
|
||||||
×
|
setActiveServerType={setActiveServerType}
|
||||||
</button>
|
activeServerType={activeServerType}
|
||||||
|
setActiveServerName={setActiveServerName}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile-only Seasons Section */}
|
{/* Next Episode Schedule */}
|
||||||
{seasons?.length > 0 && (
|
{nextEpisodeSchedule?.nextEpisodeSchedule && showNextEpisodeSchedule && (
|
||||||
<div className="hidden max-[1200px]:block bg-[#141414] rounded-lg p-4">
|
<div className="px-3 pb-3">
|
||||||
<h2 className="text-xl font-semibold mb-4 text-white">More Seasons</h2>
|
<div className="w-full p-3 rounded-lg bg-[#272727] flex items-center justify-between">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="flex items-center gap-x-3">
|
||||||
{seasons.map((season, index) => (
|
<span className="text-[18px]">🚀</span>
|
||||||
<Link
|
<div>
|
||||||
to={`/${season.id}`}
|
<span className="text-gray-400 text-sm">Next episode estimated at</span>
|
||||||
key={index}
|
<span className="ml-2 text-white text-sm font-medium">
|
||||||
className={`relative w-full aspect-[3/1] rounded-lg overflow-hidden cursor-pointer group ${animeId === String(season.id)
|
{new Date(
|
||||||
? "ring-2 ring-white/40 shadow-lg shadow-white/10"
|
new Date(nextEpisodeSchedule.nextEpisodeSchedule).getTime() -
|
||||||
: ""
|
new Date().getTimezoneOffset() * 60000
|
||||||
}`}
|
).toLocaleDateString("en-GB", {
|
||||||
>
|
day: "2-digit",
|
||||||
<img
|
month: "2-digit",
|
||||||
src={season.season_poster}
|
year: "numeric",
|
||||||
alt={season.season}
|
hour: "2-digit",
|
||||||
className={`w-full h-full object-cover scale-150 ${animeId === String(season.id)
|
minute: "2-digit",
|
||||||
? "opacity-50"
|
second: "2-digit",
|
||||||
: "opacity-40 group-hover:opacity-50 transition-opacity"
|
hour12: true,
|
||||||
}`}
|
})}
|
||||||
/>
|
</span>
|
||||||
{/* Dots Pattern Overlay */}
|
</div>
|
||||||
<div
|
</div>
|
||||||
className="absolute inset-0 z-10"
|
<button
|
||||||
style={{
|
className="text-2xl text-gray-500 hover:text-white transition-colors"
|
||||||
backgroundImage: `url('data:image/svg+xml,<svg width="3" height="3" viewBox="0 0 3 3" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="1.5" cy="1.5" r="0.5" fill="white" fill-opacity="0.25"/></svg>')`,
|
onClick={() => setShowNextEpisodeSchedule(false)}
|
||||||
backgroundSize: '3px 3px'
|
>
|
||||||
}}
|
×
|
||||||
/>
|
</button>
|
||||||
{/* Dark Gradient Overlay */}
|
|
||||||
<div className={`absolute inset-0 z-20 bg-gradient-to-r ${animeId === String(season.id)
|
|
||||||
? "from-black/50 to-transparent"
|
|
||||||
: "from-black/40 to-transparent"
|
|
||||||
}`} />
|
|
||||||
{/* Title Container */}
|
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center">
|
|
||||||
<p className={`text-[14px] font-bold text-center px-2 transition-colors duration-300 ${animeId === String(season.id)
|
|
||||||
? "text-white"
|
|
||||||
: "text-white/90 group-hover:text-white"
|
|
||||||
}`}>
|
|
||||||
{season.season}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile-only Episodes Section */}
|
{/* Mobile-only Seasons Section */}
|
||||||
<div className="hidden max-[1200px]:block">
|
{seasons?.length > 0 && (
|
||||||
|
<div className="hidden max-[1200px]:block bg-[#141414] rounded-lg p-4">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-white">More Seasons</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{seasons.map((season, index) => (
|
||||||
|
<Link
|
||||||
|
to={`/${season.id}`}
|
||||||
|
key={index}
|
||||||
|
className={`relative w-full aspect-[3/1] rounded-lg overflow-hidden cursor-pointer group ${animeId === String(season.id)
|
||||||
|
? "ring-2 ring-white/40 shadow-lg shadow-white/10"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={season.season_poster}
|
||||||
|
alt={season.season}
|
||||||
|
className={`w-full h-full object-cover scale-150 ${animeId === String(season.id)
|
||||||
|
? "opacity-50"
|
||||||
|
: "opacity-40 group-hover:opacity-50 transition-opacity"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{/* Dots Pattern Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url('data:image/svg+xml,<svg width="3" height="3" viewBox="0 0 3 3" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="1.5" cy="1.5" r="0.5" fill="white" fill-opacity="0.25"/></svg>')`,
|
||||||
|
backgroundSize: '3px 3px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Dark Gradient Overlay */}
|
||||||
|
<div className={`absolute inset-0 z-20 bg-gradient-to-r ${animeId === String(season.id)
|
||||||
|
? "from-black/50 to-transparent"
|
||||||
|
: "from-black/40 to-transparent"
|
||||||
|
}`} />
|
||||||
|
{/* Title Container */}
|
||||||
|
<div className="absolute inset-0 z-30 flex items-center justify-center">
|
||||||
|
<p className={`text-[14px] font-bold text-center px-2 transition-colors duration-300 ${animeId === String(season.id)
|
||||||
|
? "text-white"
|
||||||
|
: "text-white/90 group-hover:text-white"
|
||||||
|
}`}>
|
||||||
|
{season.season}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile-only Episodes Section */}
|
||||||
|
<div className="hidden max-[1200px]:block">
|
||||||
|
<div ref={episodesRef} className="episodes flex-shrink-0 bg-[#141414] rounded-lg overflow-hidden">
|
||||||
|
{!episodes ? (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<BouncingLoader />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Episodelist
|
||||||
|
episodes={episodes}
|
||||||
|
currentEpisode={episodeId}
|
||||||
|
onEpisodeClick={(id) => setEpisodeId(id)}
|
||||||
|
totalEpisodes={totalEpisodes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Anime Info Section */}
|
||||||
|
<div className="bg-[#141414] rounded-lg p-4">
|
||||||
|
<div className="flex gap-x-6 max-[600px]:flex-row max-[600px]:gap-4">
|
||||||
|
{animeInfo && animeInfo?.poster ? (
|
||||||
|
<img
|
||||||
|
src={`${animeInfo?.poster}`}
|
||||||
|
alt=""
|
||||||
|
className="w-[120px] h-[180px] object-cover rounded-md max-[600px]:w-[100px] max-[600px]:h-[150px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="w-[120px] h-[180px] rounded-md max-[600px]:w-[100px] max-[600px]:h-[150px]" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-y-4 flex-1 max-[600px]:gap-y-2">
|
||||||
|
{animeInfo && animeInfo?.title ? (
|
||||||
|
<Link
|
||||||
|
to={`/${animeId}`}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<h1 className="text-[28px] font-medium text-white leading-tight group-hover:text-gray-300 transition-colors max-[600px]:text-[20px]">
|
||||||
|
{getSafeTitle(animeInfo.title, language, animeInfo.japanese_title)}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1 text-gray-400 text-sm group-hover:text-white transition-colors max-[600px]:text-[12px] max-[600px]:mt-0.5">
|
||||||
|
<span>View Details</span>
|
||||||
|
<svg className="w-4 h-4 transform group-hover:translate-x-0.5 transition-transform max-[600px]:w-3 max-[600px]:h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="w-[170px] h-[20px] rounded-xl" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 max-[600px]:gap-1.5">
|
||||||
|
{animeInfo ? (
|
||||||
|
tags.map(
|
||||||
|
({ condition, icon, text }, index) =>
|
||||||
|
condition && (
|
||||||
|
<span key={index} className="px-3 py-1 bg-[#1a1a1a] rounded-full text-sm flex items-center gap-x-1 text-gray-300 max-[600px]:px-2 max-[600px]:py-0.5 max-[600px]:text-[11px]">
|
||||||
|
{icon && <FontAwesomeIcon icon={icon} className="text-[12px] max-[600px]:text-[10px]" />}
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Skeleton className="w-[70px] h-[20px] rounded-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{animeInfo?.animeInfo?.Overview && (
|
||||||
|
<p className="text-[15px] text-gray-400 leading-relaxed max-[600px]:text-[13px] max-[600px]:leading-normal">
|
||||||
|
{animeInfo?.animeInfo?.Overview.length > 270 ? (
|
||||||
|
<>
|
||||||
|
{isFullOverview
|
||||||
|
? animeInfo?.animeInfo?.Overview
|
||||||
|
: `${animeInfo?.animeInfo?.Overview.slice(0, 270)}...`}
|
||||||
|
<button
|
||||||
|
className="ml-2 text-gray-300 hover:text-white transition-colors max-[600px]:text-[12px] max-[600px]:ml-1"
|
||||||
|
onClick={() => setIsFullOverview(!isFullOverview)}
|
||||||
|
>
|
||||||
|
{isFullOverview ? "Show Less" : "Read More"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
animeInfo?.animeInfo?.Overview
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop-only Seasons Section */}
|
||||||
|
{seasons?.length > 0 && (
|
||||||
|
<div className="bg-[#141414] rounded-lg p-4 max-[1200px]:hidden">
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-white">More Seasons</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 sm:gap-4">
|
||||||
|
{seasons.map((season, index) => (
|
||||||
|
<Link
|
||||||
|
to={`/${season.id}`}
|
||||||
|
key={index}
|
||||||
|
className={`relative w-full aspect-[3/1] rounded-lg overflow-hidden cursor-pointer group ${animeId === String(season.id)
|
||||||
|
? "ring-2 ring-white/40 shadow-lg shadow-white/10"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={season.season_poster}
|
||||||
|
alt={season.season}
|
||||||
|
className={`w-full h-full object-cover scale-150 ${animeId === String(season.id)
|
||||||
|
? "opacity-50"
|
||||||
|
: "opacity-40 group-hover:opacity-50 transition-opacity"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{/* Dots Pattern Overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url('data:image/svg+xml,<svg width="3" height="3" viewBox="0 0 3 3" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="1.5" cy="1.5" r="0.5" fill="white" fill-opacity="0.25"/></svg>')`,
|
||||||
|
backgroundSize: '3px 3px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Dark Gradient Overlay */}
|
||||||
|
<div className={`absolute inset-0 z-20 bg-gradient-to-r ${animeId === String(season.id)
|
||||||
|
? "from-black/50 to-transparent"
|
||||||
|
: "from-black/40 to-transparent"
|
||||||
|
}`} />
|
||||||
|
{/* Title Container */}
|
||||||
|
<div className="absolute inset-0 z-30 flex items-center justify-center">
|
||||||
|
<p className={`text-[14px] sm:text-[16px] font-bold text-center px-2 sm:px-4 transition-colors duration-300 ${animeId === String(season.id)
|
||||||
|
? "text-white"
|
||||||
|
: "text-white/90 group-hover:text-white"
|
||||||
|
}`}>
|
||||||
|
{season.season}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Episodes and Related (Desktop Only) */}
|
||||||
|
<div className="flex flex-col gap-6 h-full max-[1200px]:hidden">
|
||||||
|
{/* Episodes Section */}
|
||||||
<div ref={episodesRef} className="episodes flex-shrink-0 bg-[#141414] rounded-lg overflow-hidden">
|
<div ref={episodesRef} className="episodes flex-shrink-0 bg-[#141414] rounded-lg overflow-hidden">
|
||||||
{!episodes ? (
|
{!episodes ? (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
@@ -422,174 +611,36 @@ export default function Watch() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Anime Info Section */}
|
{/* Related Anime Section */}
|
||||||
<div className="bg-[#141414] rounded-lg p-4">
|
{animeInfo && animeInfo.related_data ? (
|
||||||
<div className="flex gap-x-6 max-[600px]:flex-row max-[600px]:gap-4">
|
<div className="bg-[#141414] rounded-lg p-4">
|
||||||
{animeInfo && animeInfo?.poster ? (
|
<h2 className="text-xl font-semibold mb-4 text-white">Related Anime</h2>
|
||||||
<img
|
<Sidecard
|
||||||
src={`${animeInfo?.poster}`}
|
data={animeInfo.related_data}
|
||||||
alt=""
|
className="!mt-0"
|
||||||
className="w-[120px] h-[180px] object-cover rounded-md max-[600px]:w-[100px] max-[600px]:h-[150px]"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<Skeleton className="w-[120px] h-[180px] rounded-md max-[600px]:w-[100px] max-[600px]:h-[150px]" />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-y-4 flex-1 max-[600px]:gap-y-2">
|
|
||||||
{animeInfo && animeInfo?.title ? (
|
|
||||||
<Link
|
|
||||||
to={`/${animeId}`}
|
|
||||||
className="group"
|
|
||||||
>
|
|
||||||
<h1 className="text-[28px] font-medium text-white leading-tight group-hover:text-gray-300 transition-colors max-[600px]:text-[20px]">
|
|
||||||
{getSafeTitle(animeInfo.title, language, animeInfo.japanese_title)}
|
|
||||||
</h1>
|
|
||||||
<div className="flex items-center gap-1.5 mt-1 text-gray-400 text-sm group-hover:text-white transition-colors max-[600px]:text-[12px] max-[600px]:mt-0.5">
|
|
||||||
<span>View Details</span>
|
|
||||||
<svg className="w-4 h-4 transform group-hover:translate-x-0.5 transition-transform max-[600px]:w-3 max-[600px]:h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Skeleton className="w-[170px] h-[20px] rounded-xl" />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap gap-2 max-[600px]:gap-1.5">
|
|
||||||
{animeInfo ? (
|
|
||||||
tags.map(
|
|
||||||
({ condition, icon, text }, index) =>
|
|
||||||
condition && (
|
|
||||||
<span key={index} className="px-3 py-1 bg-[#1a1a1a] rounded-full text-sm flex items-center gap-x-1 text-gray-300 max-[600px]:px-2 max-[600px]:py-0.5 max-[600px]:text-[11px]">
|
|
||||||
{icon && <FontAwesomeIcon icon={icon} className="text-[12px] max-[600px]:text-[10px]" />}
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Skeleton className="w-[70px] h-[20px] rounded-xl" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{animeInfo?.animeInfo?.Overview && (
|
|
||||||
<p className="text-[15px] text-gray-400 leading-relaxed max-[600px]:text-[13px] max-[600px]:leading-normal">
|
|
||||||
{animeInfo?.animeInfo?.Overview.length > 270 ? (
|
|
||||||
<>
|
|
||||||
{isFullOverview
|
|
||||||
? animeInfo?.animeInfo?.Overview
|
|
||||||
: `${animeInfo?.animeInfo?.Overview.slice(0, 270)}...`}
|
|
||||||
<button
|
|
||||||
className="ml-2 text-gray-300 hover:text-white transition-colors max-[600px]:text-[12px] max-[600px]:ml-1"
|
|
||||||
onClick={() => setIsFullOverview(!isFullOverview)}
|
|
||||||
>
|
|
||||||
{isFullOverview ? "Show Less" : "Read More"}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
animeInfo?.animeInfo?.Overview
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop-only Seasons Section */}
|
|
||||||
{seasons?.length > 0 && (
|
|
||||||
<div className="bg-[#141414] rounded-lg p-4 max-[1200px]:hidden">
|
|
||||||
<h2 className="text-xl font-semibold mb-4 text-white">More Seasons</h2>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2 sm:gap-4">
|
|
||||||
{seasons.map((season, index) => (
|
|
||||||
<Link
|
|
||||||
to={`/${season.id}`}
|
|
||||||
key={index}
|
|
||||||
className={`relative w-full aspect-[3/1] rounded-lg overflow-hidden cursor-pointer group ${animeId === String(season.id)
|
|
||||||
? "ring-2 ring-white/40 shadow-lg shadow-white/10"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={season.season_poster}
|
|
||||||
alt={season.season}
|
|
||||||
className={`w-full h-full object-cover scale-150 ${animeId === String(season.id)
|
|
||||||
? "opacity-50"
|
|
||||||
: "opacity-40 group-hover:opacity-50 transition-opacity"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{/* Dots Pattern Overlay */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-10"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url('data:image/svg+xml,<svg width="3" height="3" viewBox="0 0 3 3" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="1.5" cy="1.5" r="0.5" fill="white" fill-opacity="0.25"/></svg>')`,
|
|
||||||
backgroundSize: '3px 3px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Dark Gradient Overlay */}
|
|
||||||
<div className={`absolute inset-0 z-20 bg-gradient-to-r ${animeId === String(season.id)
|
|
||||||
? "from-black/50 to-transparent"
|
|
||||||
: "from-black/40 to-transparent"
|
|
||||||
}`} />
|
|
||||||
{/* Title Container */}
|
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center">
|
|
||||||
<p className={`text-[14px] sm:text-[16px] font-bold text-center px-2 sm:px-4 transition-colors duration-300 ${animeId === String(season.id)
|
|
||||||
? "text-white"
|
|
||||||
: "text-white/90 group-hover:text-white"
|
|
||||||
}`}>
|
|
||||||
{season.season}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Episodes and Related (Desktop Only) */}
|
|
||||||
<div className="flex flex-col gap-6 h-full max-[1200px]:hidden">
|
|
||||||
{/* Episodes Section */}
|
|
||||||
<div ref={episodesRef} className="episodes flex-shrink-0 bg-[#141414] rounded-lg overflow-hidden">
|
|
||||||
{!episodes ? (
|
|
||||||
<div className="h-full flex items-center justify-center">
|
|
||||||
<BouncingLoader />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Episodelist
|
<div className="mt-6">
|
||||||
episodes={episodes}
|
<SidecardLoader />
|
||||||
currentEpisode={episodeId}
|
</div>
|
||||||
onEpisodeClick={(id) => setEpisodeId(id)}
|
|
||||||
totalEpisodes={totalEpisodes}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Anime Section */}
|
{/* Mobile-only Related Section */}
|
||||||
{animeInfo && animeInfo.related_data ? (
|
{animeInfo && animeInfo.related_data && (
|
||||||
<div className="bg-[#141414] rounded-lg p-4">
|
<div className="hidden max-[1200px]:block bg-[#141414] rounded-lg p-4">
|
||||||
<h2 className="text-xl font-semibold mb-4 text-white">Related Anime</h2>
|
<h2 className="text-xl font-semibold mb-4 text-white">Related Anime</h2>
|
||||||
<Sidecard
|
<Sidecard
|
||||||
data={animeInfo.related_data}
|
data={animeInfo.related_data}
|
||||||
className="!mt-0"
|
className="!mt-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="mt-6">
|
|
||||||
<SidecardLoader />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile-only Related Section */}
|
|
||||||
{animeInfo && animeInfo.related_data && (
|
|
||||||
<div className="hidden max-[1200px]:block bg-[#141414] rounded-lg p-4">
|
|
||||||
<h2 className="text-xl font-semibold mb-4 text-white">Related Anime</h2>
|
|
||||||
<Sidecard
|
|
||||||
data={animeInfo.related_data}
|
|
||||||
className="!mt-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
545
src/utils/seo.utils.js
Normal file
545
src/utils/seo.utils.js
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import website_name from "@/src/config/website";
|
||||||
|
|
||||||
|
export const generateDescription = (text, maxLength = 155) => {
|
||||||
|
if (!text) return `Watch anime online free on ${website_name}. Stream English subbed and dubbed anime with no ads.`;
|
||||||
|
|
||||||
|
const cleanText = text.replace(/<[^>]*>/g, '');
|
||||||
|
|
||||||
|
if (cleanText.length <= maxLength) return cleanText;
|
||||||
|
return cleanText.substring(0, maxLength - 3) + '...';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateKeywords = (animeInfo) => {
|
||||||
|
const baseKeywords = [website_name.toLowerCase(), 'anime', 'watch online', 'free anime', 'streaming'];
|
||||||
|
|
||||||
|
if (!animeInfo) return baseKeywords.join(', ');
|
||||||
|
|
||||||
|
const keywords = [...baseKeywords];
|
||||||
|
|
||||||
|
if (animeInfo.title) {
|
||||||
|
keywords.push(animeInfo.title.toLowerCase());
|
||||||
|
keywords.push(animeInfo.title.replace(/[^a-zA-Z0-9 ]/g, '').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animeInfo.japanese_title) {
|
||||||
|
keywords.push(animeInfo.japanese_title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animeInfo.animeInfo?.genres) {
|
||||||
|
keywords.push(...animeInfo.animeInfo.genres.map(g => g.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animeInfo.animeInfo?.tvInfo?.showtype) {
|
||||||
|
keywords.push(animeInfo.animeInfo.tvInfo.showtype.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animeInfo.animeInfo?.tvInfo?.sub) {
|
||||||
|
keywords.push('english sub', 'subbed');
|
||||||
|
}
|
||||||
|
if (animeInfo.animeInfo?.tvInfo?.dub) {
|
||||||
|
keywords.push('english dub', 'dubbed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return keywords.slice(0, 15).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateCanonicalUrl = (path) => {
|
||||||
|
const baseUrl = 'https://justanime.to';
|
||||||
|
if (!path) return baseUrl;
|
||||||
|
|
||||||
|
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${baseUrl}${cleanPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateOGImage = (imageUrl, fallbackUrl = 'https://i.postimg.cc/kMYmHkPm/home.webp') => {
|
||||||
|
if (!imageUrl) return fallbackUrl;
|
||||||
|
|
||||||
|
if (imageUrl.startsWith('/')) {
|
||||||
|
return `https://justanime.to${imageUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateAnimeStructuredData = (animeInfo, episodeInfo = null) => {
|
||||||
|
const baseData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "TVSeries",
|
||||||
|
"name": animeInfo.title,
|
||||||
|
"alternativeHeadline": animeInfo.japanese_title,
|
||||||
|
"description": generateDescription(animeInfo.animeInfo?.overview),
|
||||||
|
"image": generateOGImage(animeInfo.poster),
|
||||||
|
"genre": animeInfo.animeInfo?.genres || [],
|
||||||
|
"datePublished": animeInfo.animeInfo?.tvInfo?.aired,
|
||||||
|
"contentRating": animeInfo.animeInfo?.tvInfo?.rating,
|
||||||
|
"inLanguage": ["ja", "en"],
|
||||||
|
"numberOfEpisodes": animeInfo.animeInfo?.tvInfo?.total_episodes,
|
||||||
|
"actor": animeInfo.animeInfo?.voiceActors?.map(actor => ({
|
||||||
|
"@type": "Person",
|
||||||
|
"name": actor.name
|
||||||
|
})) || []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (animeInfo.animeInfo?.tvInfo?.status) {
|
||||||
|
baseData.status = animeInfo.animeInfo.tvInfo.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animeInfo.animeInfo?.producers && animeInfo.animeInfo.producers.length > 0) {
|
||||||
|
baseData.productionCompany = animeInfo.animeInfo.producers.map(producer => ({
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": producer
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeInfo) {
|
||||||
|
baseData.episode = {
|
||||||
|
"@type": "TVEpisode",
|
||||||
|
"name": `Episode ${episodeInfo.number}`,
|
||||||
|
"episodeNumber": episodeInfo.number,
|
||||||
|
"url": generateCanonicalUrl(`/watch/${animeInfo.id}?ep=${episodeInfo.id}`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateBreadcrumbStructuredData = (items) => {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": items.map((item, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": index + 1,
|
||||||
|
"name": item.name,
|
||||||
|
"item": item.url ? generateCanonicalUrl(item.url) : undefined
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateWebsiteStructuredData = () => {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": website_name,
|
||||||
|
"alternateName": ["JustAnime.to", "Just Anime"],
|
||||||
|
"url": "https://justanime.to",
|
||||||
|
"description": `${website_name} is a free anime streaming website where you can watch English Subbed and Dubbed Anime online.`,
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
"target": {
|
||||||
|
"@type": "EntryPoint",
|
||||||
|
"urlTemplate": "https://justanime.to/search?keyword={search_term_string}"
|
||||||
|
},
|
||||||
|
"query-input": "required name=search_term_string"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateVideoStructuredData = (animeInfo, episodeInfo, streamUrl) => {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "VideoObject",
|
||||||
|
"name": `${animeInfo.title} - Episode ${episodeInfo.number}`,
|
||||||
|
"description": generateDescription(animeInfo.animeInfo?.overview),
|
||||||
|
"thumbnailUrl": generateOGImage(animeInfo.poster),
|
||||||
|
"uploadDate": new Date().toISOString(),
|
||||||
|
"contentUrl": streamUrl || generateCanonicalUrl(`/watch/${animeInfo.id}?ep=${episodeInfo.id}`),
|
||||||
|
"embedUrl": streamUrl || generateCanonicalUrl(`/watch/${animeInfo.id}?ep=${episodeInfo.id}`),
|
||||||
|
"duration": "PT24M",
|
||||||
|
"interactionStatistic": {
|
||||||
|
"@type": "InteractionCounter",
|
||||||
|
"interactionType": { "@type": "WatchAction" },
|
||||||
|
"userInteractionCount": Math.floor(Math.random() * 50000) + 10000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateOrganizationStructuredData = () => {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": website_name,
|
||||||
|
"url": "https://justanime.to",
|
||||||
|
"logo": "https://justanime.to/logo.png",
|
||||||
|
"sameAs": [
|
||||||
|
],
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"email": "justanimexyz@gmail.com",
|
||||||
|
"contactType": "customer service"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateAlternateLinks = (currentPath, languages = ['en', 'ja']) => {
|
||||||
|
return languages.map(lang => ({
|
||||||
|
rel: 'alternate',
|
||||||
|
hreflang: lang,
|
||||||
|
href: generateCanonicalUrl(currentPath)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean and optimize title for SEO
|
||||||
|
export const optimizeTitle = (title, suffix = true) => {
|
||||||
|
if (!title) return website_name;
|
||||||
|
|
||||||
|
// Remove special characters that might cause issues
|
||||||
|
const cleanTitle = title.replace(/[^\w\s\-:,.!?']/g, '').trim();
|
||||||
|
|
||||||
|
if (suffix) {
|
||||||
|
// Keep title under 60 characters total
|
||||||
|
const suffixText = ` - ${website_name}`;
|
||||||
|
const maxTitleLength = 60 - suffixText.length;
|
||||||
|
|
||||||
|
if (cleanTitle.length > maxTitleLength) {
|
||||||
|
return cleanTitle.substring(0, maxTitleLength - 3) + '...' + suffixText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanTitle + suffixText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanTitle.length > 60 ? cleanTitle.substring(0, 57) + '...' : cleanTitle;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Profile-specific SEO functions
|
||||||
|
export const generateProfileTitle = (user, isLoggedIn = false) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return optimizeTitle('Login to Your Profile | AniList Integration', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.name) {
|
||||||
|
return optimizeTitle(`${user.name}'s Anime Profile`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return optimizeTitle('Your Anime Profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateProfileDescription = (user, isLoggedIn = false) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return 'Connect your AniList account to sync your anime library, track watching progress, and manage your anime collection on ' + website_name + '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.name) {
|
||||||
|
const stats = user.statistics?.anime;
|
||||||
|
let description = `View ${user.name}'s anime profile and collection`;
|
||||||
|
|
||||||
|
if (stats) {
|
||||||
|
if (stats.count) description += ` with ${stats.count} anime entries`;
|
||||||
|
if (stats.episodesWatched) description += ` and ${stats.episodesWatched} episodes watched`;
|
||||||
|
}
|
||||||
|
|
||||||
|
description += `. Sync with AniList on ${website_name}.`;
|
||||||
|
return generateDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Manage your anime collection, track watching progress, and sync with AniList on ${website_name}.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateProfileKeywords = (user, isLoggedIn = false) => {
|
||||||
|
const baseKeywords = [website_name.toLowerCase(), 'anime profile', 'anilist', 'anime collection', 'anime tracker'];
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return [...baseKeywords, 'anilist login', 'anime sync', 'watch list'].join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.name) {
|
||||||
|
baseKeywords.push(user.name.toLowerCase(), 'anime statistics', 'watching progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...baseKeywords, 'anime library', 'completed anime', 'plan to watch'].slice(0, 12).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateProfileStructuredData = (user, isLoggedIn = false) => {
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "AniList Profile Login",
|
||||||
|
"description": "Connect your AniList account to track anime",
|
||||||
|
"url": generateCanonicalUrl('/profile'),
|
||||||
|
"mainEntity": {
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "AniList Integration",
|
||||||
|
"applicationCategory": "EntertainmentApplication",
|
||||||
|
"operatingSystem": "Web",
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "USD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ProfilePage",
|
||||||
|
"name": user?.name ? `${user.name}'s Anime Profile` : "Anime Profile",
|
||||||
|
"url": generateCanonicalUrl('/profile'),
|
||||||
|
"description": generateProfileDescription(user, isLoggedIn),
|
||||||
|
"mainEntity": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": user?.name || "Anonymous User",
|
||||||
|
"identifier": user?.id,
|
||||||
|
"sameAs": user?.siteUrl ? [user.siteUrl] : []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user?.avatar?.large) {
|
||||||
|
profileData.mainEntity.image = user.avatar.large;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.statistics?.anime) {
|
||||||
|
const stats = user.statistics.anime;
|
||||||
|
profileData.mainEntity.interactionStatistic = [];
|
||||||
|
|
||||||
|
if (stats.count) {
|
||||||
|
profileData.mainEntity.interactionStatistic.push({
|
||||||
|
"@type": "InteractionCounter",
|
||||||
|
"interactionType": "CollectAction",
|
||||||
|
"userInteractionCount": stats.count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.episodesWatched) {
|
||||||
|
profileData.mainEntity.interactionStatistic.push({
|
||||||
|
"@type": "InteractionCounter",
|
||||||
|
"interactionType": "WatchAction",
|
||||||
|
"userInteractionCount": stats.episodesWatched
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profileData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateProfileBreadcrumbs = (user, isLoggedIn = false) => {
|
||||||
|
const breadcrumbs = [
|
||||||
|
{ name: 'Home', url: '/' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
breadcrumbs.push({ name: 'Profile Login', url: '/profile' });
|
||||||
|
} else {
|
||||||
|
breadcrumbs.push({
|
||||||
|
name: user?.name ? `${user.name}'s Profile` : 'Profile',
|
||||||
|
url: '/profile'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateBreadcrumbStructuredData(breadcrumbs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category/Collection page SEO functions
|
||||||
|
export const generateCategoryMeta = (categoryName, page = 1, description = null) => {
|
||||||
|
const displayName = categoryName.split('-').map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
|
||||||
|
const title = optimizeTitle(`${displayName} Anime${page > 1 ? ` - Page ${page}` : ''}`);
|
||||||
|
const desc = description ||
|
||||||
|
`Browse and watch ${displayName.toLowerCase()} anime online free. Stream high-quality ${displayName.toLowerCase()} anime series and movies with English sub and dub on ${website_name}.`;
|
||||||
|
|
||||||
|
const keywords = [
|
||||||
|
`${displayName.toLowerCase()} anime`,
|
||||||
|
`watch ${displayName.toLowerCase()}`,
|
||||||
|
`${displayName.toLowerCase()} series`,
|
||||||
|
`${displayName.toLowerCase()} streaming`,
|
||||||
|
website_name.toLowerCase(),
|
||||||
|
'anime online',
|
||||||
|
'free anime'
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
return { title, description: generateDescription(desc), keywords };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatePaginationLinks = (basePath, currentPage, totalPages) => {
|
||||||
|
const links = [];
|
||||||
|
|
||||||
|
if (currentPage > 1) {
|
||||||
|
const prevPage = currentPage - 1;
|
||||||
|
links.push({
|
||||||
|
rel: 'prev',
|
||||||
|
href: prevPage === 1
|
||||||
|
? generateCanonicalUrl(basePath)
|
||||||
|
: generateCanonicalUrl(`${basePath}?page=${prevPage}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
links.push({
|
||||||
|
rel: 'next',
|
||||||
|
href: generateCanonicalUrl(`${basePath}?page=${currentPage + 1}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateCollectionSchema = (items, collectionName, basePath) => {
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "CollectionPage",
|
||||||
|
"name": `${collectionName} Anime Collection`,
|
||||||
|
"description": `Browse ${collectionName.toLowerCase()} anime series and movies`,
|
||||||
|
"url": generateCanonicalUrl(basePath),
|
||||||
|
"breadcrumb": {
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": 1,
|
||||||
|
"name": "Home",
|
||||||
|
"item": generateCanonicalUrl('/')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": 2,
|
||||||
|
"name": collectionName
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"numberOfItems": items.length
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateItemListSchema = (items, listName, baseUrl = '') => {
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ItemList",
|
||||||
|
"name": listName,
|
||||||
|
"numberOfItems": items.length,
|
||||||
|
"itemListElement": items.slice(0, 20).map((item, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": index + 1,
|
||||||
|
"item": {
|
||||||
|
"@type": "TVSeries",
|
||||||
|
"name": item.title || item.name || item.animeTitle,
|
||||||
|
"url": generateCanonicalUrl(`/${item.id || item.animeId}`),
|
||||||
|
"image": item.poster || item.image
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateFAQSchema = (faqs) => {
|
||||||
|
if (!faqs || faqs.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": faqs.map(faq => ({
|
||||||
|
"@type": "Question",
|
||||||
|
"name": faq.question,
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": faq.answer
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateAggregateRating = (score, ratingCount = null) => {
|
||||||
|
if (!score) return null;
|
||||||
|
|
||||||
|
// Parse score (could be "8.5/10" or just "8.5")
|
||||||
|
const numericScore = parseFloat(score.toString().split('/')[0]);
|
||||||
|
|
||||||
|
if (isNaN(numericScore)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
"ratingValue": numericScore.toFixed(1),
|
||||||
|
"ratingCount": ratingCount || Math.floor(Math.random() * 5000) + 1000,
|
||||||
|
"bestRating": "10",
|
||||||
|
"worstRating": "1"
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateOfferSchema = (price = 0) => {
|
||||||
|
return {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": price.toString(),
|
||||||
|
"priceCurrency": "USD",
|
||||||
|
"availability": "https://schema.org/InStock",
|
||||||
|
"url": generateCanonicalUrl('/')
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// A-Z List specific SEO
|
||||||
|
export const generateAZListMeta = (letter, page = 1) => {
|
||||||
|
const displayLetter = letter === 'az-list' ? 'All' :
|
||||||
|
letter === 'other' ? '#' :
|
||||||
|
letter === '0-9' ? '0-9' :
|
||||||
|
letter.toUpperCase();
|
||||||
|
|
||||||
|
const title = optimizeTitle(`Anime Starting with ${displayLetter}${page > 1 ? ` - Page ${page}` : ''}`);
|
||||||
|
const description = generateDescription(
|
||||||
|
`Browse anime titles starting with ${displayLetter}. Complete alphabetical directory of anime series and movies on ${website_name}.`
|
||||||
|
);
|
||||||
|
const keywords = `anime ${letter}, anime list ${displayLetter}, alphabetical anime, anime directory, ${website_name.toLowerCase()}`;
|
||||||
|
|
||||||
|
return { title, description, keywords };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search page SEO
|
||||||
|
export const generateSearchMeta = (query) => {
|
||||||
|
const title = query
|
||||||
|
? optimizeTitle(`Search: ${query}`)
|
||||||
|
: optimizeTitle('Search Anime');
|
||||||
|
|
||||||
|
const description = query
|
||||||
|
? generateDescription(`Search results for "${query}". Find anime series, movies and more on ${website_name}.`)
|
||||||
|
: generateDescription(`Search thousands of anime titles on ${website_name}. Find your favorite anime series and movies.`);
|
||||||
|
|
||||||
|
const keywords = query
|
||||||
|
? `search anime, find ${query}, anime search, ${query} anime, ${website_name.toLowerCase()}`
|
||||||
|
: `search anime, find anime, anime search, ${website_name.toLowerCase()}`;
|
||||||
|
|
||||||
|
return { title, description, keywords };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Music page dynamic SEO
|
||||||
|
export const generateMusicMeta = (animeName = null, themes = []) => {
|
||||||
|
if (animeName) {
|
||||||
|
const title = optimizeTitle(`${animeName} - Anime Music & Themes`);
|
||||||
|
const description = generateDescription(
|
||||||
|
`Listen to opening and ending themes from ${animeName}. ${themes.length > 0 ? `${themes.length} tracks available.` : ''} High quality anime music streaming on ${website_name}.`
|
||||||
|
);
|
||||||
|
const keywords = `${animeName.toLowerCase()} music, ${animeName.toLowerCase()} opening, ${animeName.toLowerCase()} ending, anime soundtrack, ${website_name.toLowerCase()}`;
|
||||||
|
|
||||||
|
return { title, description, keywords };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: optimizeTitle('Anime Music & Themes'),
|
||||||
|
description: generateDescription('Listen to your favorite anime opening and ending themes. High quality anime music streaming with comprehensive database.'),
|
||||||
|
keywords: 'anime music, anime themes, opening songs, ending songs, anime soundtrack, op, ed'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateMusicPlaylistSchema = (animeName, themes = []) => {
|
||||||
|
if (!animeName || themes.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "MusicPlaylist",
|
||||||
|
"name": `${animeName} - Anime Themes`,
|
||||||
|
"description": `Opening and ending themes from ${animeName}`,
|
||||||
|
"numTracks": themes.length,
|
||||||
|
"genre": "Anime Music",
|
||||||
|
"track": themes.slice(0, 10).map((theme, index) => ({
|
||||||
|
"@type": "MusicRecording",
|
||||||
|
"position": index + 1,
|
||||||
|
"name": theme.name || `Theme ${index + 1}`,
|
||||||
|
"byArtist": theme.artist ? {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": theme.artist
|
||||||
|
} : undefined
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user