Compare commits

...

30 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
01a15210ea Merge pull request 'feat/hero' (#9) from feat/hero into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #9
2026-03-03 14:01:42 +07:00
29f2d3fa59 🔧 chore: replace dummy data with real data
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m4s
2026-03-03 13:58:11 +07:00
2f9fef54ff 💄 style: adjust UI for image-only banner 2026-03-01 21:55:24 +07:00
119e0f447c feat: add base banner elements 2026-03-01 21:40:14 +07:00
24ec3588d5 🚧 wip: add base hero section component 2026-02-28 14:33:39 +07:00
eee8546260 Merge pull request '🚸 ux: handle duplicate email account error flow' (#8) from fix/auth into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #8
2026-02-19 17:20:03 +07:00
f5f0bb8c58 🚸 ux: handle duplicate email account error flow
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m2s
2026-02-19 17:17:23 +07:00
76f17020d4 Merge pull request 'fix/auth' (#7) from fix/auth into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #7
2026-02-18 12:58:27 +07:00
879afd94de 🩹 fix: resolve build error
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m4s
2026-02-18 12:56:29 +07:00
39124f0db4 🚸 ux: improve logout flow completely 2026-02-18 12:53:58 +07:00
0c9ca45b36 🥅 fix: handle logout failure warning 2026-02-18 12:27:24 +07:00
43 changed files with 1092 additions and 48 deletions

View File

@ -1,9 +0,0 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const page = async () => {
(await cookies()).delete("auth_token");
redirect("/");
};
export default page;

View File

@ -0,0 +1,7 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
export const GET = async (request: Request) => {
(await cookies()).delete("auth_token");
return NextResponse.redirect(new URL("/", request.url), 303);
};

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

View File

@ -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 */
}

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono, Inter } from "next/font/google"; import { Geist, Geist_Mono, Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/shared/libs/shadcn/ui/sonner";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
@ -29,7 +30,8 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
{children} <main>{children}</main>
<Toaster />
</body> </body>
</html> </html>
); );

View File

@ -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=="],

View File

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

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

@ -32,7 +32,12 @@ export const submitProviderCallback = async (
}>; }>;
if (!responseProvision.success) if (!responseProvision.success)
throw new Error("Failed to submit provider callback"); return {
success: false,
status: responseProvision.status,
message: responseProvision.message,
error: responseProvision.error,
};
(await cookies()).set({ (await cookies()).set({
name: "auth_token", name: "auth_token",
@ -47,7 +52,9 @@ export const submitProviderCallback = async (
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: "Error submitting provider callback", status: 500,
message:
"Connection to authentication service failed. Please try again later.",
error: error, error: error,
}; };
} }

View File

@ -11,10 +11,13 @@ const AuthCallbackIndex = () => {
"We are processing your authentication.", "We are processing your authentication.",
); );
const finishOAuthFlow = (type: string) => { const finishOAuthFlow = (type: string, message?: string) => {
setTimeout(() => { setTimeout(() => {
if (!window.opener) window.location.href = "/"; if (!window.opener) window.location.href = "/";
window.opener.postMessage({ type: type }, window.location.origin); window.opener.postMessage(
{ type: type, message: message },
window.location.origin,
);
window.close(); window.close();
}, 1000); }, 1000);
}; };
@ -24,11 +27,10 @@ const AuthCallbackIndex = () => {
const response = await submitProviderCallback(name as string, queries); const response = await submitProviderCallback(name as string, queries);
if (response.success) { if (response.success) {
setTextDescription("Authentication successful! Redirecting..."); setTextDescription("Authentication successful! Redirecting...");
finishOAuthFlow("oauth-success"); finishOAuthFlow("oauth-success", response.message);
} else { } else {
console.error("Error in authentication callback:", response);
setTextDescription("Authentication failed. Please try again."); setTextDescription("Authentication failed. Please try again.");
finishOAuthFlow("oauth-failed"); finishOAuthFlow("oauth-failed", response.message);
} }
})(); })();
}, [name, queries]); }, [name, queries]);

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

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

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

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

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

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

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

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

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,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 }

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

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

View File

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

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

View File

@ -1,7 +1,6 @@
"use server"; "use server";
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch"; import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
import { cookies } from "next/headers";
export const logout = async () => { export const logout = async () => {
const res = (await backendFetch("auth/logout", { const res = (await backendFetch("auth/logout", {
@ -9,10 +8,9 @@ export const logout = async () => {
})) as BackendResponse; })) as BackendResponse;
if (res.success) { if (res.success) {
(await cookies()).delete("auth_token");
return { return {
success: true, success: true,
message: "Logged out successfully", message: "Logout successful",
}; };
} else { } else {
return { return {

View File

@ -2,7 +2,6 @@
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch"; import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cookies } from "next/headers";
export interface UserSession { export interface UserSession {
id: string; id: string;

2
shared/types/swiper.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module "swiper/css";
declare module "swiper/css/*";

View File

@ -11,7 +11,9 @@ import {
import { Spinner } from "@/shared/libs/shadcn/ui/spinner"; import { Spinner } from "@/shared/libs/shadcn/ui/spinner";
import { logout } from "@/shared/models/auth/logout"; import { logout } from "@/shared/models/auth/logout";
import { Button } from "@base-ui/react"; import { Button } from "@base-ui/react";
import { useRouter } from "next/navigation";
import React from "react"; import React from "react";
import { toast } from "sonner";
const LogoutAlert = ({ const LogoutAlert = ({
openState, openState,
@ -20,12 +22,30 @@ const LogoutAlert = ({
openState: boolean; openState: boolean;
setOpenState: React.Dispatch<React.SetStateAction<boolean>>; setOpenState: React.Dispatch<React.SetStateAction<boolean>>;
}) => { }) => {
const router = useRouter();
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const continueLogout = async () => { const continueLogout = async () => {
setIsLoading(true); setIsLoading(true);
await logout().then((res) => const res = await logout();
res.success ? window.location.reload() : setIsLoading(false), if (!res.success) {
); setIsLoading(false);
toast.error(res.message || "Logout failed", {
position: "bottom-right",
description:
"An error occurred while logging out. Please try again later.",
richColors: true,
});
} else {
toast.success(res.message || "Logout successful", {
position: "bottom-right",
description: "You have been logged out successfully.",
richColors: true,
});
router.push("/auth/logout");
setTimeout(() => {
window.location.reload();
}, 2000);
}
}; };
return ( return (

View File

@ -18,6 +18,7 @@ import {
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Spinner } from "@/shared/libs/shadcn/ui/spinner"; import { Spinner } from "@/shared/libs/shadcn/ui/spinner";
import { getOauthEndpoint } from "../actions/getOauthEndpoint"; import { getOauthEndpoint } from "../actions/getOauthEndpoint";
import { toast } from "sonner";
const SignInCard = () => { const SignInCard = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -49,8 +50,21 @@ const SignInCard = () => {
// Handle the feedback from popup window for OAuth // Handle the feedback from popup window for OAuth
const handleMessage = useCallback((event: MessageEvent) => { const handleMessage = useCallback((event: MessageEvent) => {
if (event.origin !== window.location.origin) return; if (event.origin !== window.location.origin) return;
if (event.data.type === "oauth-success") window.location.reload(); if (event.data.type === "oauth-success") {
if (event.data.type === "oauth-failed") setIsLoading(false); toast.success("Authentication successful! Redirecting...", {
description: event.data.message,
richColors: true,
});
window.location.reload();
}
if (event.data.type === "oauth-failed") {
toast.error("Authentication failed.", {
description: event.data.message || "Please try again.",
duration: 5000,
richColors: true,
});
setIsLoading(false);
}
}, []); }, []);
useEffect(() => { useEffect(() => {