Compare commits

..

21 Commits

Author SHA1 Message Date
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
4fc87b7134 🛂 security: fix auth token validation flow 2026-02-17 21:32:27 +07:00
33 changed files with 771 additions and 61 deletions

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

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

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

@ -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,90 @@
"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";
import { Icon } from "@iconify/react";
export interface HeroSwiperProps {
data: {
id: string;
title: string;
slug: string;
imageUrl: string;
synopsis: string;
genres: {
slug: string;
name: 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) => (
<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"
/>
<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.genres.map((genre) => (
<Badge
className="bg-neutral-200 text-neutral-800"
key={genre.slug}
>
{genre.name}
</Badge>
))}
</div>
<p className="mt-4 font-medium text-base max-w-[40vw] line-clamp-6">
{slide.synopsis}
</p>
<Button
size="lg"
onClick={() => router.push(`/media/${slide.slug}`)}
className="mt-6 h-12 rounded-xl 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>
</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/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/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/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

@ -5,6 +5,7 @@ import { UAParser } from "ua-parser-js";
export interface BackendResponse<T = unknown> { export interface BackendResponse<T = unknown> {
success: boolean; success: boolean;
status: number;
message: string; message: string;
data?: T; data?: T;
error?: unknown; error?: unknown;
@ -34,16 +35,11 @@ export const backendFetch = async (path: string, options: RequestInit = {}) => {
...options.headers, ...options.headers,
}, },
cache: "default", cache: "default",
}); }).then((response) => response.json());
const resJson = (await res.json()) as BackendResponse; return res as BackendResponse;
} catch (res) {
if (!res.ok) { if (process.env.NODE_ENV === "development") return res;
throw new Error(`Elysia error: ${resJson.error}`);
}
return resJson;
} catch {
redirect("/status?reason=backend-unreachable"); redirect("/status?reason=backend-unreachable");
} }
}; };

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,18 +1,16 @@
"use server"; "use server";
import { backendFetch } 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", {
method: "POST", method: "POST",
}); })) 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

@ -1,7 +1,7 @@
"use server"; "use server";
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch"; import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
import { cookies } from "next/headers"; import { redirect } from "next/navigation";
export interface UserSession { export interface UserSession {
id: string; id: string;
@ -30,18 +30,14 @@ export interface UserSession {
} }
export const validateAndDecodeJWT = async (): Promise<UserSession | null> => { export const validateAndDecodeJWT = async (): Promise<UserSession | null> => {
const cookieHeader = (await cookies()).get("auth_token")?.value; "use server";
if (!cookieHeader) {
return null;
}
const res = (await backendFetch("auth/token/validate", { const res = (await backendFetch("auth/token/validate", {
method: "POST", method: "POST",
body: JSON.stringify({
token: cookieHeader,
}),
})) as BackendResponse<UserSession>; })) as BackendResponse<UserSession>;
return res.data!; if (res.status === 403) {
redirect("/auth/logout");
}
return res.data ?? null;
}; };

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