Compare commits
11 Commits
f9104c2580
...
anime-page
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ae6dd8fb | |||
| a277372f43 | |||
| 5a8395e50b | |||
| 76f5a97538 | |||
| 2c0ece7870 | |||
| 99bf72c1af | |||
| 6f2588250c | |||
| 7a4c92526e | |||
| 73ed6ce3b8 | |||
| 4e5d509e99 | |||
| 72f13c7c2e |
9
app/(session)/(main)/anime/[slug]/page.tsx
Normal file
9
app/(session)/(main)/anime/[slug]/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import AnimeIndex from "@/features/anime";
|
||||
|
||||
export default function page() {
|
||||
return (
|
||||
<div>
|
||||
<AnimeIndex />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
features/anime/actions/getAnimeBySlug.ts
Normal file
96
features/anime/actions/getAnimeBySlug.ts
Normal file
@ -0,0 +1,96 @@
|
||||
export interface GetAnimeBySlugResponse {
|
||||
success: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: {
|
||||
id: string;
|
||||
title: string;
|
||||
titleAlternative: {
|
||||
type: string;
|
||||
title: string;
|
||||
}[];
|
||||
genres: {
|
||||
slug: string;
|
||||
name: string;
|
||||
}[];
|
||||
slug: string;
|
||||
malId: number;
|
||||
pictureMedium: string;
|
||||
pictureLarge: string;
|
||||
country: string;
|
||||
score: string;
|
||||
status: string;
|
||||
startAiring: string;
|
||||
endAiring: string;
|
||||
synopsis: string;
|
||||
ageRating: string;
|
||||
mediaType: string;
|
||||
source: string;
|
||||
onDraft: boolean;
|
||||
uploadedBy: string;
|
||||
deletedAt: null | string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const getAnimeBySlug = async (
|
||||
slug: string,
|
||||
): Promise<GetAnimeBySlugResponse> => {
|
||||
return {
|
||||
success: true,
|
||||
status: 200,
|
||||
message: "Media fetched successfully",
|
||||
data: {
|
||||
id: "019cc6ea-5f59-70ec-8c64-2c716a32e0a9",
|
||||
title: "Sakamoto Days",
|
||||
titleAlternative: [
|
||||
{
|
||||
type: "Default",
|
||||
title: "Sakamoto Days",
|
||||
},
|
||||
{
|
||||
type: "Japanese",
|
||||
title: "SAKAMOTO DAYS",
|
||||
},
|
||||
{
|
||||
type: "English",
|
||||
title: "Sakamoto Days",
|
||||
},
|
||||
],
|
||||
genres: [
|
||||
{
|
||||
slug: "action",
|
||||
name: "Action",
|
||||
},
|
||||
{
|
||||
slug: "slice-of-life",
|
||||
name: "Slice of Life",
|
||||
},
|
||||
{
|
||||
slug: "comedy",
|
||||
name: "Comedy",
|
||||
},
|
||||
],
|
||||
slug: "sakamoto-days",
|
||||
malId: 58939,
|
||||
pictureMedium: "https://myanimelist.net/images/anime/1026/146459.webp",
|
||||
pictureLarge: "https://myanimelist.net/images/anime/1026/146459l.webp",
|
||||
country: "JP",
|
||||
score: "7.59",
|
||||
status: "Finished Airing",
|
||||
startAiring: "2025-01-11T00:00:00.000Z",
|
||||
endAiring: "2025-03-22T00:00:00.000Z",
|
||||
synopsis:
|
||||
"The name Tarou Sakamoto once instilled fear in every villain. No other professional hitman matched his prowess, and fellow assassins revered him. However, Sakamoto fell in love. In five short years, he married, became a father, put on some weight, and traded his weapons for an apron as he became the owner of a humble convenience store.\n\nAlthough Sakamoto is decidedly retired, he finds his old life of crime hard to shake off. His former partner, Shin Asakura, reappears and resolves to stay with Sakamoto's family under their strict no-kill rule. To make matters worse, a large bounty is placed on Sakamoto's head. Numerous assassins now pursue him—but they are in for a surprise. Sakamoto has not lost his edge, and no matter what tricks his enemies pull, he will fight off every last one to protect his dear family.\n\n[Written by MAL Rewrite]",
|
||||
ageRating: "R - 17+ (violence & profanity)",
|
||||
mediaType: "TV",
|
||||
source: "Manga",
|
||||
onDraft: false,
|
||||
uploadedBy: "019c0645-a3b5-747a-83bb-0fca3040f951",
|
||||
deletedAt: null,
|
||||
createdAt: "2026-02-19T14:35:00.053Z",
|
||||
updatedAt: "2026-03-07T06:09:34.575Z",
|
||||
},
|
||||
};
|
||||
};
|
||||
11
features/anime/index.tsx
Normal file
11
features/anime/index.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import AnimeInformation from "./sections/Information/wrapper";
|
||||
|
||||
const AnimeIndex = () => {
|
||||
return (
|
||||
<div>
|
||||
<AnimeInformation />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimeIndex;
|
||||
56
features/anime/sections/Information/main.client.tsx
Normal file
56
features/anime/sections/Information/main.client.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { GetAnimeBySlugResponse } from "../../actions/getAnimeBySlug";
|
||||
import GenreTags from "@/shared/components/GenreTags";
|
||||
|
||||
const AnimeInformationClient = ({ data }: { data: GetAnimeBySlugResponse }) => {
|
||||
return (
|
||||
<section className="flex gap-4">
|
||||
<div>
|
||||
<img
|
||||
src={data.data.pictureMedium}
|
||||
alt={data.data.title}
|
||||
className="h-fit w-78 overflow-hidden rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="tracking-tight">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h1 className="tracking-tighter text-5xl font-bold">
|
||||
{data.data.title}
|
||||
</h1>
|
||||
<h3 className="ml-0.5 tracking-tight text-xl font-medium text-muted-foreground">
|
||||
{data.data.titleAlternative[0].title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<GenreTags genres={data.data.genres} />
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr className="">
|
||||
<td className="font-medium text-sm text-muted-foreground">
|
||||
Score
|
||||
</td>
|
||||
<td className="text-sm">{data.data.score}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="font-medium text-sm text-muted-foreground">
|
||||
Status
|
||||
</td>
|
||||
<td className="text-sm">{data.data.status}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="font-medium text-sm text-muted-foreground">
|
||||
Airing
|
||||
</td>
|
||||
<td className="text-sm">
|
||||
{data.data.startAiring} - {data.data.endAiring}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimeInformationClient;
|
||||
14
features/anime/sections/Information/main.tsx
Normal file
14
features/anime/sections/Information/main.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { getAnimeBySlug } from "../../actions/getAnimeBySlug";
|
||||
import AnimeInformationClient from "./main.client";
|
||||
|
||||
const AnimeInformationMain = async () => {
|
||||
const data = async () => await getAnimeBySlug("sakamoto-days");
|
||||
const result = await data();
|
||||
return (
|
||||
<div>
|
||||
<AnimeInformationClient data={result} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimeInformationMain;
|
||||
3
features/anime/sections/Information/skeleton.tsx
Normal file
3
features/anime/sections/Information/skeleton.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const AnimeInformationSkeleton = () => {
|
||||
return <div>Loading...</div>;
|
||||
};
|
||||
15
features/anime/sections/Information/wrapper.tsx
Normal file
15
features/anime/sections/Information/wrapper.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React, { Suspense } from "react";
|
||||
import AnimeInformationMain from "./main";
|
||||
import { AnimeInformationSkeleton } from "./skeleton";
|
||||
|
||||
const AnimeInformation = () => {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={<AnimeInformationSkeleton />}>
|
||||
<AnimeInformationMain />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimeInformation;
|
||||
18
features/home/actions/Hero/addHeroBannerMediaToSaved.ts
Normal file
18
features/home/actions/Hero/addHeroBannerMediaToSaved.ts
Normal file
@ -0,0 +1,18 @@
|
||||
"use server";
|
||||
|
||||
import { backendFetch } from "@/shared/helpers/backendFetch";
|
||||
|
||||
export const addHeroBannerMediaToSaved = async (mediaId: string) => {
|
||||
try {
|
||||
return await backendFetch("collections/sys", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "Saved",
|
||||
itemId: mediaId,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error adding media to saved list:", error);
|
||||
return { success: false, message: "Failed to add media to saved list." };
|
||||
}
|
||||
};
|
||||
21
features/home/actions/Hero/removeHeroBannerMediaFromSaved.ts
Normal file
21
features/home/actions/Hero/removeHeroBannerMediaFromSaved.ts
Normal file
@ -0,0 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { backendFetch } from "@/shared/helpers/backendFetch";
|
||||
|
||||
export const removeHeroBannerMediaFromSaved = async (mediaId: string) => {
|
||||
try {
|
||||
return await backendFetch("collections/sys", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({
|
||||
name: "Saved",
|
||||
itemId: mediaId,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error removing media from saved list:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to remove media from saved list.",
|
||||
};
|
||||
}
|
||||
};
|
||||
65
features/home/sections/Hero/components/AddToList.tsx
Normal file
65
features/home/sections/Hero/components/AddToList.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import { addHeroBannerMediaToSaved } from "@/features/home/actions/Hero/addHeroBannerMediaToSaved";
|
||||
import { removeHeroBannerMediaFromSaved } from "@/features/home/actions/Hero/removeHeroBannerMediaFromSaved";
|
||||
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||
import { BackendResponse } from "@/shared/helpers/backendFetch";
|
||||
import { Button } from "@/shared/libs/shadcn/ui/button";
|
||||
import { Icon } from "@iconify/react";
|
||||
import React from "react";
|
||||
|
||||
const AddToList = ({
|
||||
mediaId,
|
||||
isInCollection,
|
||||
}: {
|
||||
mediaId: string;
|
||||
isInCollection: boolean;
|
||||
}) => {
|
||||
const { session } = useAuth();
|
||||
const [isSaved, setIsSaved] = React.useState<boolean>(isInCollection);
|
||||
|
||||
const handleAddToList = async () => {
|
||||
setIsSaved(!isSaved);
|
||||
const result = (await addHeroBannerMediaToSaved(mediaId).catch(
|
||||
(_) => void _,
|
||||
)) as BackendResponse<undefined>;
|
||||
if (!result || !result.success) {
|
||||
setIsSaved((prev) => !prev);
|
||||
}
|
||||
};
|
||||
const handleRemoveFromList = async () => {
|
||||
setIsSaved(!isSaved);
|
||||
const result = (await removeHeroBannerMediaFromSaved(mediaId).catch(
|
||||
(_) => void _,
|
||||
)) as BackendResponse<undefined>;
|
||||
if (!result || !result.success) {
|
||||
setIsSaved((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{session?.user &&
|
||||
(isSaved ? (
|
||||
<Button
|
||||
onClick={handleRemoveFromList}
|
||||
variant="secondary"
|
||||
className="h-full flex gap-1 px-4 rounded-xl border border-neutral-400/10 bg-neutral-950/20 hover:bg-neutral-950/40 backdrop-blur-lg text-neutral-200"
|
||||
>
|
||||
<Icon icon="boxicons:bookmark-filled" className="size-5.5" />
|
||||
<span>Remove from List</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleAddToList}
|
||||
variant="secondary"
|
||||
className="h-full flex gap-1 px-4 rounded-xl border border-neutral-400/10 bg-neutral-950/20 hover:bg-neutral-950/40 backdrop-blur-lg text-neutral-200"
|
||||
>
|
||||
<Icon icon="boxicons:bookmark" className="size-5.5" />
|
||||
<span>Add to List</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddToList;
|
||||
@ -2,114 +2,89 @@
|
||||
import "swiper/css";
|
||||
import { Badge } from "@/shared/libs/shadcn/ui/badge";
|
||||
import { Button } from "@/shared/libs/shadcn/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Autoplay, Navigation, Pagination } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Link from "next/link";
|
||||
import AddToList from "./AddToList";
|
||||
import GenreTags from "@/shared/components/GenreTags";
|
||||
|
||||
export interface HeroSwiperProps {
|
||||
data: {
|
||||
id: string;
|
||||
isClickable: boolean;
|
||||
title: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
buttonContent: string;
|
||||
buttonLink: string;
|
||||
slug: string;
|
||||
imageUrl: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
synopsis: string;
|
||||
genres: {
|
||||
slug: string;
|
||||
name: string;
|
||||
}[];
|
||||
isInCollection: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
const HeroSwiper = (props: HeroSwiperProps) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="h-full rounded-lg overflow-hidden">
|
||||
<Swiper
|
||||
spaceBetween={0}
|
||||
slidesPerView={1}
|
||||
onSlideChange={() => console.log("slide change")}
|
||||
onSwiper={(swiper) => console.log(swiper)}
|
||||
className="h-full"
|
||||
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||
modules={[Autoplay, Pagination, Navigation]}
|
||||
>
|
||||
{props.data.map((slide) =>
|
||||
slide.imageUrl ? (
|
||||
// Slide with image background
|
||||
<SwiperSlide key={slide.id} className="relative overflow-hidden">
|
||||
<img
|
||||
src={slide.imageUrl}
|
||||
alt={slide.title}
|
||||
className="absolute top-0 left-0 z-0 object-cover w-full h-full opacity-80"
|
||||
/>
|
||||
{slide.title && slide.description && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 h-full w-full py-16 px-20"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg,rgba(0, 0, 0, 0.64) 0%, rgba(0, 0, 0, 0.42) 46%, rgba(0, 0, 0, 0) 100%)",
|
||||
}}
|
||||
>
|
||||
<h1 className="text-6xl font-semibold tracking-tight">
|
||||
{slide.title}
|
||||
</h1>
|
||||
<div className="mt-4 flex gap-1.5">
|
||||
{slide.tags.map((tag) => (
|
||||
<Badge
|
||||
className="bg-neutral-200 text-neutral-800"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 font-medium text-base max-w-[40vw] line-clamp-6">
|
||||
{slide.description}
|
||||
</p>
|
||||
{slide.isClickable && (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => router.push(slide.buttonLink)}
|
||||
className="mt-6"
|
||||
>
|
||||
{slide.buttonContent}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SwiperSlide>
|
||||
) : (
|
||||
// Fallback for slides without image
|
||||
<SwiperSlide
|
||||
key={slide.id}
|
||||
className="relative overflow-hidden bg-neutral-800 flex flex-col items-center text-center pt-18"
|
||||
{props.data.map((slide, index) => (
|
||||
<SwiperSlide key={index} className="relative overflow-hidden">
|
||||
<img
|
||||
src={slide.imageUrl}
|
||||
alt={slide.title}
|
||||
className="absolute top-0 left-0 z-0 object-cover w-full h-full opacity-80"
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 h-full w-full py-16 px-20"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg,rgba(0, 0, 0, 0.64) 0%, rgba(0, 0, 0, 0.42) 46%, rgba(0, 0, 0, 0) 100%)",
|
||||
}}
|
||||
>
|
||||
<h1 className="text-6xl font-semibold tracking-tight">
|
||||
{slide.title}
|
||||
</h1>
|
||||
<div className="mt-4 flex justify-center gap-1.5">
|
||||
{slide.tags.map((tag) => (
|
||||
<Badge className="bg-neutral-200 text-neutral-800" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
<div className="mt-4">
|
||||
<GenreTags genres={slide.genres} />
|
||||
</div>
|
||||
<p className="mt-4 font-medium text-base max-w-[40vw] mx-auto">
|
||||
{slide.description}
|
||||
<p className="mt-4 font-medium text-base max-w-[40vw] line-clamp-6">
|
||||
{slide.synopsis}
|
||||
</p>
|
||||
{slide.isClickable && (
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => router.push(slide.buttonLink)}
|
||||
className="mt-6"
|
||||
<div className="flex gap-2 mt-6 h-12">
|
||||
<Link
|
||||
href={`/anime/${slide.slug}`}
|
||||
className="w-fit h-full rounded-xl overflow-hidden"
|
||||
>
|
||||
{slide.buttonContent}
|
||||
</Button>
|
||||
)}
|
||||
</SwiperSlide>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-full flex gap-2 px-4 hover:bg-neutral-950 group"
|
||||
>
|
||||
<div className="bg-neutral-950 p-2 rounded-full group-hover:bg-primary">
|
||||
<Icon
|
||||
icon="solar:play-bold"
|
||||
className="text-primary group-hover:text-neutral-950"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-semibold text-neutral-950 group-hover:text-primary">
|
||||
Watch Now
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<AddToList
|
||||
mediaId={slide.id}
|
||||
isInCollection={slide.isInCollection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { RecommendationAnime } from "@/features/home/actions/getRecommenationAnime";
|
||||
import { RecommendationAnime } from "@/features/home/actions/Hero/getRecommenationAnime";
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
const AnimeRecommendationCard = ({ data }: { data: RecommendationAnime }) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { RecommendationAnime } from "../../actions/getRecommenationAnime";
|
||||
import { RecommendationAnime } from "../../actions/Hero/getRecommenationAnime";
|
||||
import AnimeRecommendationCard from "./components/Card";
|
||||
import ScrollingButton from "./components/ScrollingButton";
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getRecommendationAnimeAction } from "../../actions/getRecommenationAnime";
|
||||
import { getRecommendationAnimeAction } from "../../actions/Hero/getRecommenationAnime";
|
||||
import RecommendationClient from "./main.client";
|
||||
|
||||
const RecommendationMain = async () => {
|
||||
|
||||
25
shared/components/GenreTags.tsx
Normal file
25
shared/components/GenreTags.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { Badge } from "../libs/shadcn/ui/badge";
|
||||
|
||||
type GenreTagsProps = {
|
||||
genres: {
|
||||
slug: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const GenreTags = ({ genres }: GenreTagsProps) => {
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{genres.map((genre, index) => (
|
||||
<Link href={`/genres/${genre.slug}`} key={index}>
|
||||
<Badge className="bg-neutral-100/60 backdrop-blur-lg text-neutral-800">
|
||||
{genre.name}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenreTags;
|
||||
Reference in New Issue
Block a user