Compare commits
24 Commits
eee8546260
...
anime-page
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ae6dd8fb | |||
| a277372f43 | |||
| 5a8395e50b | |||
| 76f5a97538 | |||
| 2c0ece7870 | |||
| 99bf72c1af | |||
| 6f2588250c | |||
| 7a4c92526e | |||
| 73ed6ce3b8 | |||
| 4e5d509e99 | |||
| 72f13c7c2e | |||
| f9104c2580 | |||
| eecaeb13e8 | |||
| 74ad82c4f0 | |||
| 97ef74e0f7 | |||
| 5cb3b909be | |||
| 8393e6393c | |||
| c02832674b | |||
| e3211d240a | |||
| 01a15210ea | |||
| 29f2d3fa59 | |||
| 2f9fef54ff | |||
| 119e0f447c | |||
| 24ec3588d5 |
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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 */
|
||||
}
|
||||
|
||||
3
bun.lock
3
bun.lock
@ -17,6 +17,7 @@
|
||||
"react-dom": "19.2.3",
|
||||
"shadcn": "^3.6.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"ua-parser-js": "^2.0.8",
|
||||
@ -1328,6 +1329,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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([
|
||||
...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:
|
||||
|
||||
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." };
|
||||
}
|
||||
};
|
||||
134
features/home/actions/Hero/getRecommenationAnime.ts
Normal file
134
features/home/actions/Hero/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",
|
||||
},
|
||||
];
|
||||
};
|
||||
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.",
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
import Hero from "./sections/Hero/wrapper";
|
||||
import Recommendation from "./sections/Recommendation/wrapper";
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
93
features/home/sections/Hero/components/Swiper.tsx
Normal file
93
features/home/sections/Hero/components/Swiper.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
import "swiper/css";
|
||||
import { Badge } from "@/shared/libs/shadcn/ui/badge";
|
||||
import { Button } from "@/shared/libs/shadcn/ui/button";
|
||||
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;
|
||||
title: string;
|
||||
slug: string;
|
||||
imageUrl: string;
|
||||
synopsis: string;
|
||||
genres: {
|
||||
slug: string;
|
||||
name: string;
|
||||
}[];
|
||||
isInCollection: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
const HeroSwiper = (props: HeroSwiperProps) => {
|
||||
return (
|
||||
<div className="h-full rounded-lg overflow-hidden">
|
||||
<Swiper
|
||||
spaceBetween={0}
|
||||
slidesPerView={1}
|
||||
className="h-full"
|
||||
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||
modules={[Autoplay, Pagination, Navigation]}
|
||||
>
|
||||
{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">
|
||||
<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={`/anime/${slide.slug}`}
|
||||
className="w-fit h-full rounded-xl overflow-hidden"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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/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;
|
||||
@ -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/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;
|
||||
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/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;
|
||||
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",
|
||||
"shadcn": "^3.6.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"ua-parser-js": "^2.0.8"
|
||||
|
||||
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;
|
||||
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 { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
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"
|
||||
import { cn } from "@/shared/libs/shadcn/lib/utils";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
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",
|
||||
secondary: "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",
|
||||
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",
|
||||
secondary:
|
||||
"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",
|
||||
},
|
||||
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",
|
||||
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",
|
||||
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-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||
"icon-xs":
|
||||
"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",
|
||||
},
|
||||
},
|
||||
@ -31,8 +38,8 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@ -42,9 +49,9 @@ function Button({
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@ -54,7 +61,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@ -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}
|
||||
|
||||
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