Compare commits

...

8 Commits

Author SHA1 Message Date
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
6f2588250c ♻️ refactor: extract bookmark logic from banner component 2026-03-27 22:28:32 +07:00
7a4c92526e 💄 style: add "add to list" button to banner UI 2026-03-26 16:50:31 +07:00
73ed6ce3b8 🐛 fix: correct watch button and genre on swiper 2026-03-26 15:22:01 +07:00
4e5d509e99 ♻️ refactor: change swiper key from banner id to index 2026-03-26 14:02:46 +07:00
72f13c7c2e ♻️ refactor: restructure hero swiper to be media-specific 2026-03-25 19:44:54 +07:00
8 changed files with 166 additions and 82 deletions

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

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

View File

@ -2,114 +2,94 @@
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";
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 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>
<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={`/media/${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>
);

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 () => {