Compare commits

...

6 Commits

Author SHA1 Message Date
09ae6dd8fb 🚧 wip: share genre tags UI for reuse 2026-04-06 21:09:27 +07:00
a277372f43 👔 feat: add base structure for anime page 2026-04-05 22:48:47 +07:00
5a8395e50b 🚸 ux: update anime route in swiper 2026-04-03 22:49:18 +07:00
76f5a97538 Merge pull request 'refactor/hero' (#11) from refactor/hero into main
All checks were successful
Sync to GitHub / sync (push) Successful in 7s
Reviewed-on: #11
2026-03-31 21:50:25 +07:00
2c0ece7870 👔 feat: add optimistic update for bookmark button
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 52s
2026-03-31 21:25:20 +07:00
99bf72c1af 👔 feat: add save button endpoint handler 2026-03-29 11:34:08 +07:00
16 changed files with 332 additions and 25 deletions

View File

@ -0,0 +1,9 @@
import AnimeIndex from "@/features/anime";
export default function page() {
return (
<div>
<AnimeIndex />
</div>
);
}

View 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
View File

@ -0,0 +1,11 @@
import AnimeInformation from "./sections/Information/wrapper";
const AnimeIndex = () => {
return (
<div>
<AnimeInformation />
</div>
);
};
export default AnimeIndex;

View 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;

View 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;

View File

@ -0,0 +1,3 @@
export const AnimeInformationSkeleton = () => {
return <div>Loading...</div>;
};

View 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;

View 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." };
}
};

View 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.",
};
}
};

View File

@ -1,21 +1,63 @@
"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 }: { mediaId: string }) => {
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 && (
{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>
);
};

View File

@ -7,6 +7,7 @@ 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: {
@ -19,6 +20,7 @@ export interface HeroSwiperProps {
slug: string;
name: string;
}[];
isInCollection: boolean;
}[];
}
@ -28,8 +30,6 @@ const HeroSwiper = (props: HeroSwiperProps) => {
<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]}
@ -51,21 +51,15 @@ const HeroSwiper = (props: HeroSwiperProps) => {
<h1 className="text-6xl font-semibold tracking-tight">
{slide.title}
</h1>
<div className="mt-4 flex gap-1.5">
{slide.genres.map((genre) => (
<Link href={`/genres/${genre.slug}`} key={genre.slug}>
<Badge className="bg-neutral-100/60 backdrop-blur-lg text-neutral-800">
{genre.name}
</Badge>
</Link>
))}
<div className="mt-4">
<GenreTags genres={slide.genres} />
</div>
<p className="mt-4 font-medium text-base max-w-[40vw] line-clamp-6">
{slide.synopsis}
</p>
<div className="flex gap-2 mt-6 h-12">
<Link
href={`/media/${slide.slug}`}
href={`/anime/${slide.slug}`}
className="w-fit h-full rounded-xl overflow-hidden"
>
<Button
@ -83,7 +77,10 @@ const HeroSwiper = (props: HeroSwiperProps) => {
</span>
</Button>
</Link>
<AddToList mediaId={slide.id} />
<AddToList
mediaId={slide.id}
isInCollection={slide.isInCollection}
/>
</div>
</div>
</SwiperSlide>

View File

@ -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 }) => {

View File

@ -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";

View File

@ -1,4 +1,4 @@
import { getRecommendationAnimeAction } from "../../actions/getRecommenationAnime";
import { getRecommendationAnimeAction } from "../../actions/Hero/getRecommenationAnime";
import RecommendationClient from "./main.client";
const RecommendationMain = async () => {

View 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;