Compare commits

..

19 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
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
f9104c2580 Merge pull request 'feat/recommendation' (#10) from feat/recommendation into main
All checks were successful
Sync to GitHub / sync (push) Successful in 11s
Reviewed-on: #10
2026-03-15 22:36:54 +07:00
eecaeb13e8 🚨 fix: resolve linting type error
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m44s
2026-03-15 22:32:21 +07:00
74ad82c4f0 feat: add scroll button and card skeleton loading 2026-03-15 22:23:41 +07:00
97ef74e0f7 💄 style: add scroll button UI 2026-03-15 21:25:53 +07:00
5cb3b909be 👔 feat: add title and additional attributes to card 2026-03-15 21:11:20 +07:00
8393e6393c 🚧 wip: add rating to card 2026-03-14 12:00:00 +07:00
c02832674b 🚧 wip: add image support to card 2026-03-13 12:00:00 +07:00
e3211d240a 🚧 wip: add recommendation component 2026-03-12 12:00:00 +07:00
25 changed files with 850 additions and 83 deletions

View File

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

View File

@ -3,7 +3,7 @@ import React from "react";
const layout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
return (
<div className="max-w-400 mx-auto relative">
<div className="max-w-396 mx-auto relative">
<Navbar />
<div className="pt-16">{children}</div>
</div>

View File

@ -124,3 +124,76 @@
@apply bg-background text-foreground;
}
}
/* ===== Scrollbar CSS ===== */
/* Firefox */
* {
scrollbar-width: auto;
scrollbar-color: #4a4a4a #0a0a0a;
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 12px;
}
*::-webkit-scrollbar-track {
background: #0a0a0a;
}
*::-webkit-scrollbar-thumb {
background-color: #4a4a4a;
border-radius: 9px;
border: 3px none #000000;
}
@keyframes aircraft-strobe {
/* Kedipan 1: Agak lambat/lama */
0%,
20% {
opacity: 1;
}
/* Jeda singkat */
35% {
opacity: 0;
}
/* Kedipan 2: Cepat */
40% {
opacity: 1;
}
43% {
opacity: 0;
}
/* Jeda sangat singkat */
48% {
opacity: 0;
}
/* Kedipan 3: Cepat */
53% {
opacity: 1;
}
56% {
opacity: 0;
}
/* Jeda panjang sebelum mengulang loop (gelap) */
100% {
opacity: 1;
}
}
/* Class untuk diterapkan ke elemen */
.blink-strobe {
animation: aircraft-strobe 2s linear infinite;
}
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Edge, Safari */
}

View File

@ -5,6 +5,13 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
// Disable the rule that enforces the use of `next/image` for image optimization.
"@next/next/no-img-element": "off",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:

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,134 @@
"use server";
export type RecommendationAnime = {
title: string;
rating?: number;
type: string;
status: string;
episodes: number;
release_year: string;
thumbnail_url: string;
};
export const getRecommendationAnimeAction = async (): Promise<
RecommendationAnime[]
> => {
await new Promise((resolve) => setTimeout(resolve, 2000));
return [
{
title: "Frieren: Beyond Journey's End",
rating: 9.39,
type: "TV",
status: "finished",
episodes: 28,
release_year: "2023",
thumbnail_url: "https://m.media-amazon.com/images/I/816AbVQc+0L.jpg",
},
{
title: "Steins;Gate",
rating: 9.07,
type: "TV",
status: "finished",
episodes: 24,
release_year: "2011",
thumbnail_url:
"https://m.media-amazon.com/images/M/MV5BZjI1YjZiMDUtZTI3MC00YTA5LWIzMmMtZmQ0NTZiYWM4NTYwXkEyXkFqcGc@._V1_FMjpg_UX1000_.jpg",
},
{
title: "Spirited Away",
rating: 8.78,
type: "Movie",
status: "finished",
episodes: 1,
release_year: "2001",
thumbnail_url:
"https://printedoriginals.com/cdn/shop/products/spirited-away-french-143975.jpg?v=1602427397",
},
{
title: "One Piece",
rating: 8.72,
type: "TV",
status: "airing",
episodes: 1100,
release_year: "1999",
thumbnail_url: "https://myanimelist.net/images/anime/1244/138851.jpg",
},
{
title: "Cyberpunk: Edgerunners",
rating: 8.6,
type: "ONA",
status: "finished",
episodes: 10,
release_year: "2022",
thumbnail_url:
"https://myanimelist.net/images/about_me/ranking_items/14292440-859e4272-536e-4760-845f-78fb48eccafe.jpg?t=1767555420",
},
{
title: "Your Name",
rating: 8.85,
type: "Movie",
status: "finished",
episodes: 1,
release_year: "2016",
thumbnail_url:
"https://m.media-amazon.com/images/M/MV5BMjM4YTE3OGEtYTY1OS00ZWEzLTg1OTctMTkyODA0ZDM3ZmJlXkEyXkFqcGc@._V1_.jpg",
},
{
title: "Hunter x Hunter (2011)",
rating: 9.04,
type: "TV",
status: "finished",
episodes: 148,
release_year: "2011",
thumbnail_url: "https://myanimelist.net/images/anime/1337/99013.jpg",
},
{
title: "Hellsing Ultimate",
rating: 8.36,
type: "OVA",
status: "finished",
episodes: 10,
release_year: "2006",
thumbnail_url: "https://cdn.myanimelist.net/images/anime/6/7333l.jpg",
},
{
title: "Tower of God Season 2",
rating: 7.5,
type: "TV",
status: "airing",
episodes: 12,
release_year: "2024",
thumbnail_url:
"https://www.animationmagazine.net/wordpress/wp-content/uploads/TOG2_ENLOGO_v2.jpg",
},
{
title: "Violet Evergarden: The Movie",
rating: 8.89,
type: "Movie",
status: "finished",
episodes: 1,
release_year: "2020",
thumbnail_url: "https://myanimelist.net/images/anime/1614/106512l.jpg",
},
{
title: "Devilman Crybaby",
rating: 7.75,
type: "ONA",
status: "finished",
episodes: 10,
release_year: "2018",
thumbnail_url: "https://myanimelist.net/images/anime/1046/122722.jpg",
},
{
title:
"Mobile Suit Gundam: The Origin (lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua)",
rating: 8.42,
type: "OVA",
status: "finished",
episodes: 6,
release_year: "2015",
thumbnail_url: "https://myanimelist.net/images/anime/4/72702.jpg",
},
];
};

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,9 +1,11 @@
import Hero from "./sections/Hero/wrapper";
import Recommendation from "./sections/Recommendation/wrapper";
const HomeIndex = () => {
return (
<div className="w-full pt-4">
<div className="w-full pt-4 pb-12">
<Hero />
<Recommendation />
</div>
);
};

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,48 +2,45 @@
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">
{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"
/>
{slide.title && slide.description && (
<div
className="absolute top-0 left-0 z-10 h-full w-full py-16 px-20"
style={{
@ -54,62 +51,40 @@ const HeroSwiper = (props: HeroSwiperProps) => {
<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 className="mt-4">
<GenreTags genres={slide.genres} />
</div>
<p className="mt-4 font-medium text-base max-w-[40vw] line-clamp-6">
{slide.description}
{slide.synopsis}
</p>
{slide.isClickable && (
<div className="flex gap-2 mt-6 h-12">
<Link
href={`/anime/${slide.slug}`}
className="w-fit h-full rounded-xl overflow-hidden"
>
<Button
size="lg"
onClick={() => router.push(slide.buttonLink)}
className="mt-6"
className="h-full flex gap-2 px-4 hover:bg-neutral-950 group"
>
{slide.buttonContent}
</Button>
)}
<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>
) : (
// 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"
>
<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>
<p className="mt-4 font-medium text-base max-w-[40vw] mx-auto">
{slide.description}
</p>
{slide.isClickable && (
<Button
size="lg"
onClick={() => router.push(slide.buttonLink)}
className="mt-6"
>
{slide.buttonContent}
</Button>
)}
</SwiperSlide>
),
)}
</Swiper>
</div>
);

View File

@ -0,0 +1,40 @@
import { RecommendationAnime } from "@/features/home/actions/Hero/getRecommenationAnime";
import { Icon } from "@iconify/react";
const AnimeRecommendationCard = ({ data }: { data: RecommendationAnime }) => {
return (
<div>
<div className="w-64 h-88 rounded-lg overflow-hidden relative">
{data.status === "airing" && (
<div className="absolute top-2 left-2 bg-neutral-800 flex items-center gap-0.5 px-2 py-1 rounded-full">
<Icon
icon="icon-park-outline:dot"
className="h-auto w-4 text-red-500 blink-strobe"
/>
<span className="text-xs font-medium">Airing</span>
</div>
)}
<div className="absolute right-2 top-2 bg-amber-400 text-neutral-950 flex items-center py-1 px-1.5 rounded">
<Icon icon="material-symbols:star-rounded" className="h-auto w-4" />
<span className="text-xs tracking-tight font-medium">
{data.rating ?? "N/A"}
</span>
</div>
<img
className="w-full h-full object-cover"
src={data.thumbnail_url}
alt={data.title}
draggable={false}
/>
</div>
<div className="mt-3 px-1 mb-1">
<h3 className=" font-semibold mt-1 line-clamp-1">{data.title}</h3>
<div className="flex gap-2 text-sm text-neutral-400 mt-0.5">
<span>{data.release_year}</span>
</div>
</div>
</div>
);
};
export default AnimeRecommendationCard;

View File

@ -0,0 +1,26 @@
import { Button } from "@/shared/libs/shadcn/ui/button";
import { ButtonGroup } from "@/shared/libs/shadcn/ui/button-group";
import { ArrowLeft, ArrowRight } from "lucide-react";
const ScrollingButton = ({
scrollLeft,
scrollRight,
}: {
scrollLeft: () => void;
scrollRight: () => void;
}) => {
return (
<div>
<ButtonGroup>
<Button variant="outline" onClick={scrollLeft}>
<ArrowLeft />
</Button>
<Button variant="outline" onClick={scrollRight}>
<ArrowRight />
</Button>
</ButtonGroup>
</div>
);
};
export default ScrollingButton;

View File

@ -0,0 +1,45 @@
"use client";
import { useRef } from "react";
import { RecommendationAnime } from "../../actions/Hero/getRecommenationAnime";
import AnimeRecommendationCard from "./components/Card";
import ScrollingButton from "./components/ScrollingButton";
const RecommendationClient = ({
result,
}: {
result: RecommendationAnime[];
}) => {
const scrollingContainer = useRef<HTMLDivElement | null>(null);
const scrollLeft = () => {
console.log("scroll left");
if (scrollingContainer.current) {
scrollingContainer.current.scrollBy({ left: -788, behavior: "smooth" });
}
};
const scrollRight = () => {
console.log("scroll right");
if (scrollingContainer.current) {
scrollingContainer.current.scrollBy({ left: 788, behavior: "smooth" });
}
};
return (
<div>
<div className="absolute top-0 right-0">
<ScrollingButton scrollLeft={scrollLeft} scrollRight={scrollRight} />
</div>
<div
className="flex gap-2 w-full overflow-x-scroll py-2 mt-2 hide-scrollbar relative"
ref={scrollingContainer}
>
{result.map((item, index) => (
<AnimeRecommendationCard data={item} key={index} />
))}
</div>
</div>
);
};
export default RecommendationClient;

View File

@ -0,0 +1,10 @@
import { getRecommendationAnimeAction } from "../../actions/Hero/getRecommenationAnime";
import RecommendationClient from "./main.client";
const RecommendationMain = async () => {
const data = async () => await getRecommendationAnimeAction();
const result = await data();
return <RecommendationClient result={result} />;
};
export default RecommendationMain;

View File

@ -0,0 +1,19 @@
import { Skeleton } from "@/shared/libs/shadcn/ui/skeleton";
const RecommendationSkeleton = () => {
const skeletonLenght = 6;
return (
<div className="flex gap-2 w-full overflow-hidden mt-4">
{[...Array(skeletonLenght)].map((_, index) => (
<div key={index}>
<Skeleton className="h-88 w-64" />
<Skeleton className="mt-3 h-6 w-64 rounded-full" />
<Skeleton className="mt-1 h-4 w-12 rounded-full" />
</div>
))}
</div>
);
};
export default RecommendationSkeleton;

View File

@ -0,0 +1,20 @@
import { Suspense } from "react";
import RecommendationMain from "./main";
import RecommendationSkeleton from "./skeleton";
const Recommendation = async () => {
return (
<div className="mt-12 relative">
<div className="flex justify-between">
<h1 className="text-[26px] text-neutral-100 font-semibold w-fit tracking-tight">
Maybe You Like
</h1>
</div>
<Suspense fallback={<RecommendationSkeleton />}>
<RecommendationMain />
</Suspense>
</div>
);
};
export default Recommendation;

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;

View File

@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/shared/libs/shadcn/lib/utils"
import { Separator } from "@/shared/libs/shadcn/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md!",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@ -17,7 +17,7 @@ function Separator({
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}