Compare commits
13 Commits
eee8546260
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9104c2580 | |||
| eecaeb13e8 | |||
| 74ad82c4f0 | |||
| 97ef74e0f7 | |||
| 5cb3b909be | |||
| 8393e6393c | |||
| c02832674b | |||
| e3211d240a | |||
| 01a15210ea | |||
| 29f2d3fa59 | |||
| 2f9fef54ff | |||
| 119e0f447c | |||
| 24ec3588d5 |
@ -3,7 +3,7 @@ import React from "react";
|
|||||||
|
|
||||||
const layout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
const layout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-400 mx-auto relative">
|
<div className="max-w-396 mx-auto relative">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="pt-16">{children}</div>
|
<div className="pt-16">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -124,3 +124,76 @@
|
|||||||
@apply bg-background text-foreground;
|
@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 */
|
||||||
|
}
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@ -17,6 +17,7 @@
|
|||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"shadcn": "^3.6.3",
|
"shadcn": "^3.6.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"swiper": "^12.1.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"ua-parser-js": "^2.0.8",
|
"ua-parser-js": "^2.0.8",
|
||||||
@ -1328,6 +1329,8 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"swiper": ["swiper@12.1.2", "", {}, "sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||||
|
|
||||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||||
|
|||||||
@ -5,6 +5,13 @@ import nextTs from "eslint-config-next/typescript";
|
|||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...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.
|
// Override default ignores of eslint-config-next.
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
// Default ignores of eslint-config-next:
|
// Default ignores of eslint-config-next:
|
||||||
|
|||||||
134
features/home/actions/getRecommenationAnime.ts
Normal file
134
features/home/actions/getRecommenationAnime.ts
Normal 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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@ -1,7 +1,13 @@
|
|||||||
"use client";
|
import Hero from "./sections/Hero/wrapper";
|
||||||
|
import Recommendation from "./sections/Recommendation/wrapper";
|
||||||
|
|
||||||
const HomeIndex = () => {
|
const HomeIndex = () => {
|
||||||
return <div className="text-center w-full">HomePage</div>;
|
return (
|
||||||
|
<div className="w-full pt-4 pb-12">
|
||||||
|
<Hero />
|
||||||
|
<Recommendation />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HomeIndex;
|
export default HomeIndex;
|
||||||
|
|||||||
118
features/home/sections/Hero/components/Swiper.tsx
Normal file
118
features/home/sections/Hero/components/Swiper.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export interface HeroSwiperProps {
|
||||||
|
data: {
|
||||||
|
id: string;
|
||||||
|
isClickable: boolean;
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
description: string;
|
||||||
|
buttonContent: string;
|
||||||
|
buttonLink: string;
|
||||||
|
imageUrl: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSwiper;
|
||||||
17
features/home/sections/Hero/main.tsx
Normal file
17
features/home/sections/Hero/main.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
|
||||||
|
import HeroSwiper, { HeroSwiperProps } from "./components/Swiper";
|
||||||
|
|
||||||
|
const HeroMain = async () => {
|
||||||
|
const testing = async () => {
|
||||||
|
return (await backendFetch("hero-banner")) as BackendResponse<
|
||||||
|
HeroSwiperProps["data"]
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await testing();
|
||||||
|
if (!response.data) return <div></div>;
|
||||||
|
|
||||||
|
return <HeroSwiper data={response.data} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroMain;
|
||||||
8
features/home/sections/Hero/skeleton.tsx
Normal file
8
features/home/sections/Hero/skeleton.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Skeleton } from "@/shared/libs/shadcn/ui/skeleton";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const HeroSkeleton = () => {
|
||||||
|
return <Skeleton className="w-full h-full" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroSkeleton;
|
||||||
15
features/home/sections/Hero/wrapper.tsx
Normal file
15
features/home/sections/Hero/wrapper.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import HeroSkeleton from "./skeleton";
|
||||||
|
import HeroMain from "./main";
|
||||||
|
|
||||||
|
const Hero = () => {
|
||||||
|
return (
|
||||||
|
<div className="h-120 w-full">
|
||||||
|
<Suspense fallback={<HeroSkeleton />}>
|
||||||
|
<HeroMain />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Hero;
|
||||||
40
features/home/sections/Recommendation/components/Card.tsx
Normal file
40
features/home/sections/Recommendation/components/Card.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { RecommendationAnime } from "@/features/home/actions/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;
|
||||||
@ -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;
|
||||||
45
features/home/sections/Recommendation/main.client.tsx
Normal file
45
features/home/sections/Recommendation/main.client.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { RecommendationAnime } from "../../actions/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;
|
||||||
10
features/home/sections/Recommendation/main.tsx
Normal file
10
features/home/sections/Recommendation/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { getRecommendationAnimeAction } from "../../actions/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;
|
||||||
19
features/home/sections/Recommendation/skeleton.tsx
Normal file
19
features/home/sections/Recommendation/skeleton.tsx
Normal 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;
|
||||||
20
features/home/sections/Recommendation/wrapper.tsx
Normal file
20
features/home/sections/Recommendation/wrapper.tsx
Normal 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;
|
||||||
@ -21,6 +21,7 @@
|
|||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"shadcn": "^3.6.3",
|
"shadcn": "^3.6.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"swiper": "^12.1.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"ua-parser-js": "^2.0.8"
|
"ua-parser-js": "^2.0.8"
|
||||||
|
|||||||
49
shared/libs/shadcn/ui/badge.tsx
Normal file
49
shared/libs/shadcn/ui/badge.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
83
shared/libs/shadcn/ui/button-group.tsx
Normal file
83
shared/libs/shadcn/ui/button-group.tsx
Normal 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,
|
||||||
|
}
|
||||||
@ -1,29 +1,36 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
import { cn } from "@/shared/libs/shadcn/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 cursor-pointer [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
|
outline:
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
|
||||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
secondary:
|
||||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
default:
|
||||||
|
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||||
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
icon: "size-9",
|
icon: "size-9",
|
||||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
"icon-xs":
|
||||||
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -31,8 +38,8 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
@ -42,9 +49,9 @@ function Button({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
@ -54,7 +61,7 @@ function Button({
|
|||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|||||||
@ -17,7 +17,7 @@ function Separator({
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
13
shared/libs/shadcn/ui/skeleton.tsx
Normal file
13
shared/libs/shadcn/ui/skeleton.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
2
shared/types/swiper.d.ts
vendored
Normal file
2
shared/types/swiper.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
declare module "swiper/css";
|
||||||
|
declare module "swiper/css/*";
|
||||||
Reference in New Issue
Block a user