Compare commits
48 Commits
0a9f011f08
...
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 | |||
| eee8546260 | |||
| f5f0bb8c58 | |||
| 76f17020d4 | |||
| 879afd94de | |||
| 39124f0db4 | |||
| 0c9ca45b36 | |||
| 4fc87b7134 | |||
| 5eb7f753a5 | |||
| 36ad865c33 | |||
| 9f0f5e9c55 | |||
| 686d24084f | |||
| ef5f7ef2e0 | |||
| 34eb8d3a8b | |||
| e187f93aef | |||
| 0664282572 | |||
| 5baf988984 | |||
| 48b3dbdab3 | |||
| 9d5412bacb | |||
| 66766c0a36 | |||
| 9ccb91e2fc | |||
| d4cacf13ae | |||
| 5f3a40df8a | |||
| ba125f1381 | |||
| 7e8d26dc53 |
33
.gitea/workflows/ci.yml
Normal file
33
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
name: Integration Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
integration-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node (required by Next.js)
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24.13.0"
|
||||||
|
|
||||||
|
- name: Setup runtime environment (Bun)
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Linting test
|
||||||
|
run: bun run lint
|
||||||
|
|
||||||
|
- name: Build test
|
||||||
|
run: bun run build
|
||||||
20
.gitea/workflows/sync-github.yml
Normal file
20
.gitea/workflows/sync-github.yml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
name: Sync to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout from Gitea
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push to GitHub mirror-main
|
||||||
|
run: |
|
||||||
|
git remote add github https://vivy-agent:${{ secrets.GH_TOKEN }}@github.com/rafiarrafif/SyzneTV-frontend.git
|
||||||
|
git push github HEAD:mirror-main --force
|
||||||
23
.github/workflows/auto-pr-from-mirror.yaml
vendored
Normal file
23
.github/workflows/auto-pr-from-mirror.yaml
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
name: Auto PR from mirror-main
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- mirror-main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Create PR via GitHub CLI
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.BOT_PAT }}
|
||||||
|
run: |
|
||||||
|
gh pr create \
|
||||||
|
--base main \
|
||||||
|
--head mirror-main \
|
||||||
|
--title "Sync from Gitea main" \
|
||||||
|
--body "Automated PR created from Gitea mirror branch."
|
||||||
7
app/(safe-mode-page)/auth/logout/route.tsx
Normal file
7
app/(safe-mode-page)/auth/logout/route.tsx
Normal 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);
|
||||||
|
};
|
||||||
23
app/(safe-mode-page)/status/page.tsx
Normal file
23
app/(safe-mode-page)/status/page.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import StatusIndex from "@/features/status";
|
||||||
|
import { backendFetch } from "@/shared/helpers/backendFetch";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
const page = async () => {
|
||||||
|
// Check service status with API call
|
||||||
|
let isDown = false;
|
||||||
|
try {
|
||||||
|
const data = await backendFetch("status");
|
||||||
|
console.log(data);
|
||||||
|
} catch {
|
||||||
|
isDown = true;
|
||||||
|
}
|
||||||
|
if (!isDown) redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<StatusIndex />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
7
app/(session)/(clean)/down/page.tsx
Normal file
7
app/(session)/(clean)/down/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
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 }>) => {
|
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>
|
||||||
12
app/(session)/layout.tsx
Normal file
12
app/(session)/layout.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import AuthSessionProviderWrapper from "@/shared/providers/AuthSession";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const layout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AuthSessionProviderWrapper>{children}</AuthSessionProviderWrapper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default layout;
|
||||||
@ -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 */
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +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 AuthSessionProviderWrapper from "@/shared/providers/AuthSession";
|
import { Toaster } from "@/shared/libs/shadcn/ui/sonner";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
@ -30,7 +30,8 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<AuthSessionProviderWrapper>{children}</AuthSessionProviderWrapper>
|
<main>{children}</main>
|
||||||
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
3
bun.lock
3
bun.lock
@ -17,6 +17,7 @@
|
|||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"shadcn": "^3.6.3",
|
"shadcn": "^3.6.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"swiper": "^12.1.2",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"ua-parser-js": "^2.0.8",
|
"ua-parser-js": "^2.0.8",
|
||||||
@ -1328,6 +1329,8 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"swiper": ["swiper@12.1.2", "", {}, "sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
|
||||||
|
|
||||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "radix-nova",
|
"style": "radix-vega",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
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;
|
||||||
@ -24,7 +24,7 @@ export const submitProviderCallback = async (
|
|||||||
throw new Error("Failed to get auth client callback URL");
|
throw new Error("Failed to get auth client callback URL");
|
||||||
|
|
||||||
const responseProvision = (await backendFetch(
|
const responseProvision = (await backendFetch(
|
||||||
`${authClientCallbackUrl.data?.callback_url!}?callbackURI=${
|
`${authClientCallbackUrl.data?.callback_url}?callbackURI=${
|
||||||
process.env.APP_URL
|
process.env.APP_URL
|
||||||
}${process.env[envKey]}&${queries}`,
|
}${process.env[envKey]}&${queries}`,
|
||||||
)) as BackendResponse<{
|
)) as BackendResponse<{
|
||||||
@ -32,11 +32,16 @@ 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",
|
||||||
value: responseProvision.data?.authToken!,
|
value: responseProvision.data?.authToken || "",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,14 +27,13 @@ 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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col items-center gap-2 pt-8">
|
<div className="w-full flex flex-col items-center gap-2 pt-8">
|
||||||
|
|||||||
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 = () => {
|
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;
|
||||||
|
|||||||
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;
|
||||||
32
features/status/index.tsx
Normal file
32
features/status/index.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import UnderContruction from "@/shared/assets/under-construction.svg";
|
||||||
|
|
||||||
|
const StatusIndex = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-center text-center md:text-left px-4 pt-12 md:pt-22">
|
||||||
|
<Image
|
||||||
|
src={UnderContruction}
|
||||||
|
alt="Under Construction"
|
||||||
|
draggable={false}
|
||||||
|
width={240}
|
||||||
|
/>
|
||||||
|
<div className="mt-6 md:mt-0 md:ml-6 lg:ml-12 max-w-md">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
Service is temporarily unavailable
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-300 mt-2">
|
||||||
|
We're currently experiencing an issue with this service and our
|
||||||
|
team is working to restore it as quickly as possible. You can still
|
||||||
|
browse other features while we fix the problem. Please check back in
|
||||||
|
a few moments. We appreciate your patience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusIndex;
|
||||||
@ -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"
|
||||||
|
|||||||
1
shared/assets/under-construction.svg
Normal file
1
shared/assets/under-construction.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 152 KiB |
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;
|
||||||
@ -1,9 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { UAParser } from "ua-parser-js";
|
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;
|
||||||
@ -22,22 +24,22 @@ export const backendFetch = async (path: string, options: RequestInit = {}) => {
|
|||||||
ip: userIp,
|
ip: userIp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await fetch(`${process.env.BACKEND_ENDPOINT}/${path}`, {
|
const res = await fetch(`${process.env.BACKEND_ENDPOINT}/${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"x-client-info": JSON.stringify(clientInfo),
|
"x-client-info": JSON.stringify(clientInfo),
|
||||||
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
|
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
|
||||||
|
cookie: (await headers()).get("cookie") || "",
|
||||||
...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 || !resJson.success) {
|
if (process.env.NODE_ENV === "development") return res;
|
||||||
throw new Error(`Elysia error: ${resJson.error}`);
|
redirect("/status?reason=backend-unreachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
return resJson;
|
|
||||||
};
|
};
|
||||||
|
|||||||
184
shared/libs/shadcn/ui/alert-dialog.tsx
Normal file
184
shared/libs/shadcn/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
||||||
|
import { Button } from "@/shared/libs/shadcn/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-background ring-foreground/10 gap-6 rounded-xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn("bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
@ -5,13 +5,13 @@ 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(
|
||||||
"cursor-pointer 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-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_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 [a]:hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
outline:
|
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",
|
"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:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
ghost:
|
ghost:
|
||||||
@ -22,26 +22,23 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
"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),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg 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-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.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-9 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-8",
|
icon: "size-9",
|
||||||
"icon-xs":
|
"icon-xs":
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm":
|
"icon-sm":
|
||||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||||
"icon-lg": "size-9",
|
"icon-lg": "size-10",
|
||||||
},
|
|
||||||
isDisabled: {
|
|
||||||
true: "bg-neutral-800 text-neutral-400 cursor-not-allowed text-xs",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
@ -49,12 +46,10 @@ function Button({
|
|||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
isDisabled = false,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
isDisabled?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "button";
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
@ -63,7 +58,7 @@ function Button({
|
|||||||
data-slot="button"
|
data-slot="button"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(buttonVariants({ variant, size, isDisabled, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -58,7 +58,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
|
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -66,7 +66,7 @@ function DialogContent({
|
|||||||
{children}
|
{children}
|
||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||||
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
|
<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm">
|
||||||
<XIcon
|
<XIcon
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
@ -100,7 +100,7 @@ function DialogFooter({
|
|||||||
<div
|
<div
|
||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -122,7 +122,7 @@ function DialogTitle({
|
|||||||
return (
|
return (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn("text-sm leading-none font-medium", className)}
|
className={cn("leading-none font-medium", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -43,7 +43,7 @@ function DropdownMenuContent({
|
|||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
align={align}
|
align={align}
|
||||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className )}
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-md p-1 shadow-md ring-1 duration-100 z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden", className )}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuPrimitive.Portal>
|
</DropdownMenuPrimitive.Portal>
|
||||||
@ -73,7 +73,7 @@ function DropdownMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -85,20 +85,24 @@ function DropdownMenuCheckboxItem({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
checked,
|
checked,
|
||||||
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
|
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
>
|
>
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
@ -125,19 +129,23 @@ function DropdownMenuRadioGroup({
|
|||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute right-2 flex items-center justify-center pointer-events-none"
|
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||||
data-slot="dropdown-menu-radio-item-indicator"
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
>
|
>
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
@ -161,7 +169,7 @@ function DropdownMenuLabel({
|
|||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-[inset]:pl-8", className)}
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -212,7 +220,7 @@ function DropdownMenuSubTrigger({
|
|||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"dark:bg-input/30 border-input 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 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
"dark:bg-input/30 border-input 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 h-9 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] file:h-7 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority"
|
||||||
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
|
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/shared/libs/shadcn/lib/utils";
|
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
||||||
import { ChevronDownIcon } from "lucide-react";
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
function NavigationMenu({
|
function NavigationMenu({
|
||||||
className,
|
className,
|
||||||
@ -11,14 +11,14 @@ function NavigationMenu({
|
|||||||
viewport = true,
|
viewport = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
viewport?: boolean;
|
viewport?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<NavigationMenuPrimitive.Root
|
<NavigationMenuPrimitive.Root
|
||||||
data-slot="navigation-menu"
|
data-slot="navigation-menu"
|
||||||
data-viewport={viewport}
|
data-viewport={viewport}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-max group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -26,7 +26,7 @@ function NavigationMenu({
|
|||||||
{children}
|
{children}
|
||||||
{viewport && <NavigationMenuViewport />}
|
{viewport && <NavigationMenuViewport />}
|
||||||
</NavigationMenuPrimitive.Root>
|
</NavigationMenuPrimitive.Root>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigationMenuList({
|
function NavigationMenuList({
|
||||||
@ -42,7 +42,7 @@ function NavigationMenuList({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigationMenuItem({
|
function NavigationMenuItem({
|
||||||
@ -55,12 +55,12 @@ function NavigationMenuItem({
|
|||||||
className={cn("relative", className)}
|
className={cn("relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationMenuTriggerStyle = cva(
|
const navigationMenuTriggerStyle = cva(
|
||||||
"bg-background hover:bg-muted focus:bg-muted data-open:hover:bg-muted data-open:focus:bg-muted data-open:bg-muted/50 focus-visible:ring-ring/50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all focus-visible:ring-[3px] focus-visible:outline-1 disabled:opacity-50 group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center disabled:pointer-events-none outline-none"
|
"bg-background hover:bg-muted focus:bg-muted data-open:hover:bg-muted data-open:focus:bg-muted data-open:bg-muted/50 focus-visible:ring-ring/50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-md px-4 py-2 text-sm font-medium transition-all focus-visible:ring-3 focus-visible:outline-1 disabled:opacity-50 group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center disabled:pointer-events-none outline-none"
|
||||||
);
|
)
|
||||||
|
|
||||||
function NavigationMenuTrigger({
|
function NavigationMenuTrigger({
|
||||||
className,
|
className,
|
||||||
@ -74,12 +74,9 @@ function NavigationMenuTrigger({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}{" "}
|
{children}{" "}
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
|
||||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:rotate-180"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</NavigationMenuPrimitive.Trigger>
|
</NavigationMenuPrimitive.Trigger>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigationMenuContent({
|
function NavigationMenuContent({
|
||||||
@ -90,12 +87,12 @@ function NavigationMenuContent({
|
|||||||
<NavigationMenuPrimitive.Content
|
<NavigationMenuPrimitive.Content
|
||||||
data-slot="navigation-menu-content"
|
data-slot="navigation-menu-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:ring-foreground/10 p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:duration-300 top-0 left-0 w-full group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto",
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:ring-foreground/10 p-2 pr-2.5 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:duration-300 top-0 left-0 w-full group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigationMenuViewport({
|
function NavigationMenuViewport({
|
||||||
@ -111,13 +108,13 @@ function NavigationMenuViewport({
|
|||||||
<NavigationMenuPrimitive.Viewport
|
<NavigationMenuPrimitive.Viewport
|
||||||
data-slot="navigation-menu-viewport"
|
data-slot="navigation-menu-viewport"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:zoom-out-95 data-open:zoom-in-90 ring-foreground/10 rounded-lg shadow ring-1 duration-100 origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden md:w-[var(--radix-navigation-menu-viewport-width)]",
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:zoom-out-95 data-open:zoom-in-90 ring-foreground/10 rounded-lg shadow ring-1 duration-100 origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden md:w-(--radix-navigation-menu-viewport-width)",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigationMenuLink({
|
function NavigationMenuLink({
|
||||||
@ -127,13 +124,10 @@ function NavigationMenuLink({
|
|||||||
return (
|
return (
|
||||||
<NavigationMenuPrimitive.Link
|
<NavigationMenuPrimitive.Link
|
||||||
data-slot="navigation-menu-link"
|
data-slot="navigation-menu-link"
|
||||||
className={cn(
|
className={cn("data-[active=true]:focus:bg-muted data-[active=true]:hover:bg-muted data-[active=true]:bg-muted/50 focus-visible:ring-ring/50 hover:bg-muted focus:bg-muted flex items-center gap-1.5 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-3 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
"data-active:focus:bg-muted data-active:hover:bg-muted data-active:bg-muted/50 focus-visible:ring-ring/50 hover:bg-muted focus:bg-muted flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4 [[data-slot=navigation-menu-content]_&]:rounded-md",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavigationMenuIndicator({
|
function NavigationMenuIndicator({
|
||||||
@ -144,14 +138,14 @@ function NavigationMenuIndicator({
|
|||||||
<NavigationMenuPrimitive.Indicator
|
<NavigationMenuPrimitive.Indicator
|
||||||
data-slot="navigation-menu-indicator"
|
data-slot="navigation-menu-indicator"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="bg-border rounded-tl-sm shadow-md relative top-[60%] h-2 w-2 rotate-45" />
|
<div className="bg-border rounded-tl-sm shadow-md relative top-[60%] h-2 w-2 rotate-45" />
|
||||||
</NavigationMenuPrimitive.Indicator>
|
</NavigationMenuPrimitive.Indicator>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@ -164,4 +158,4 @@ export {
|
|||||||
NavigationMenuIndicator,
|
NavigationMenuIndicator,
|
||||||
NavigationMenuViewport,
|
NavigationMenuViewport,
|
||||||
navigationMenuTriggerStyle,
|
navigationMenuTriggerStyle,
|
||||||
};
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ function Separator({
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
13
shared/libs/shadcn/ui/skeleton.tsx
Normal file
13
shared/libs/shadcn/ui/skeleton.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
21
shared/models/auth/logout.ts
Normal file
21
shared/models/auth/logout.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
|
||||||
|
|
||||||
|
export const logout = async () => {
|
||||||
|
const res = (await backendFetch("auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
})) as BackendResponse;
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Logout successful",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Logout failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import AuthSessionProvider from "./AuthSession.client";
|
|||||||
const AuthSessionProviderWrapper = async ({
|
const AuthSessionProviderWrapper = async ({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) => {
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
let session: UserSession | null = await validateAndDecodeJWT();
|
const session: UserSession | null = await validateAndDecodeJWT();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthSessionProvider session={session}>{children}</AuthSessionProvider>
|
<AuthSessionProvider session={session}>{children}</AuthSessionProvider>
|
||||||
|
|||||||
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/*";
|
||||||
86
shared/widgets/navbar/components/LogoutAlert.tsx
Normal file
86
shared/widgets/navbar/components/LogoutAlert.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shared/libs/shadcn/ui/alert-dialog";
|
||||||
|
import { Spinner } from "@/shared/libs/shadcn/ui/spinner";
|
||||||
|
import { logout } from "@/shared/models/auth/logout";
|
||||||
|
import { Button } from "@base-ui/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const LogoutAlert = ({
|
||||||
|
openState,
|
||||||
|
setOpenState,
|
||||||
|
}: {
|
||||||
|
openState: boolean;
|
||||||
|
setOpenState: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const continueLogout = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const res = await logout();
|
||||||
|
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 (
|
||||||
|
<AlertDialog open={openState}>
|
||||||
|
<AlertDialogContent size="sm" onEscapeKeyDown={() => setOpenState(false)}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action will log you out of your account. You can log back in at
|
||||||
|
any time. Do you want to proceed?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
disabled={isLoading}
|
||||||
|
className="hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpenState(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant="destructive" asChild>
|
||||||
|
<Button
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full hover:cursor-pointer"
|
||||||
|
onClick={continueLogout}
|
||||||
|
>
|
||||||
|
{isLoading && <Spinner />}
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LogoutAlert;
|
||||||
@ -15,28 +15,28 @@ const NavigationLink = () => {
|
|||||||
<div className="pl-10">
|
<div className="pl-10">
|
||||||
<NavigationMenu viewport={false}>
|
<NavigationMenu viewport={false}>
|
||||||
<NavigationMenuList className="flex-wrap">
|
<NavigationMenuList className="flex-wrap">
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem key={1}>
|
||||||
<NavigationMenuLink asChild>
|
<NavigationMenuLink asChild>
|
||||||
<Link href="/season" className="text-sm">
|
<Link href="/season" className="text-sm">
|
||||||
Season
|
Season
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem key={2}>
|
||||||
<NavigationMenuLink asChild>
|
<NavigationMenuLink asChild>
|
||||||
<Link href="/genres" className="text-sm">
|
<Link href="/genres" className="text-sm">
|
||||||
Genres
|
Genres
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem key={3}>
|
||||||
<NavigationMenuLink asChild>
|
<NavigationMenuLink asChild>
|
||||||
<Link href="/trending" className="text-sm">
|
<Link href="/trending" className="text-sm">
|
||||||
Trending
|
Trending
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem key={4}>
|
||||||
<NavigationMenuTrigger className="font-normal">
|
<NavigationMenuTrigger className="font-normal">
|
||||||
Media
|
Media
|
||||||
</NavigationMenuTrigger>
|
</NavigationMenuTrigger>
|
||||||
@ -62,7 +62,7 @@ const NavigationLink = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</NavigationMenuContent>
|
</NavigationMenuContent>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem key={5}>
|
||||||
<NavigationMenuTrigger className="font-normal">
|
<NavigationMenuTrigger className="font-normal">
|
||||||
Release
|
Release
|
||||||
</NavigationMenuTrigger>
|
</NavigationMenuTrigger>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/shared/libs/shadcn/ui/dropdown-menu";
|
} from "@/shared/libs/shadcn/ui/dropdown-menu";
|
||||||
|
import { Button } from "@base-ui/react";
|
||||||
import {
|
import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
CircleUserRound,
|
CircleUserRound,
|
||||||
@ -19,9 +20,16 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Webhook,
|
Webhook,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import LogoutAlert from "./LogoutAlert";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
const UserProfile = () => {
|
const UserProfile = () => {
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
|
const [openState, setOpenState] = React.useState(false);
|
||||||
|
const triggerLogoutPopup = () => {
|
||||||
|
setOpenState(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center">
|
<div className="h-full flex items-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -34,7 +42,7 @@ const UserProfile = () => {
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-38">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
@ -43,11 +51,11 @@ const UserProfile = () => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<ClockFading />
|
<ClockFading />
|
||||||
Activity
|
Your Activity
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Bookmark />
|
<Bookmark />
|
||||||
Bookmark
|
Saved Bookmarks
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Settings />
|
<Settings />
|
||||||
@ -71,13 +79,18 @@ const UserProfile = () => {
|
|||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive">
|
<DropdownMenuItem variant="destructive" asChild>
|
||||||
<LogOut />
|
<Button
|
||||||
Log Out
|
onClick={triggerLogoutPopup}
|
||||||
|
className="w-full hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut /> Logout
|
||||||
|
</Button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
<LogoutAlert openState={openState} setOpenState={setOpenState} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
@ -35,7 +36,7 @@ const SignInCard = () => {
|
|||||||
// Open OAuth endpoint in a new popup window
|
// Open OAuth endpoint in a new popup window
|
||||||
const getOauthEndpointUrl = async (
|
const getOauthEndpointUrl = async (
|
||||||
providerReqEndpoint: string,
|
providerReqEndpoint: string,
|
||||||
providerName: string
|
providerName: string,
|
||||||
) => {
|
) => {
|
||||||
const res = await getOauthEndpoint({
|
const res = await getOauthEndpoint({
|
||||||
endpointUrl: providerReqEndpoint,
|
endpointUrl: providerReqEndpoint,
|
||||||
@ -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(() => {
|
||||||
@ -70,7 +84,7 @@ const SignInCard = () => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<div className="grid w-full max-w-sm items-center gap-3">
|
<div className="grid w-full items-center gap-3">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input type="email" id="email" placeholder="e.g. user@example.com" />
|
<Input type="email" id="email" placeholder="e.g. user@example.com" />
|
||||||
</div>
|
</div>
|
||||||
@ -81,7 +95,7 @@ const SignInCard = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{oAuthProviders ? (
|
{oAuthProviders ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-2">
|
||||||
{oAuthProviders.data?.map((provider, index) => (
|
{oAuthProviders.data?.map((provider, index) => (
|
||||||
<Button
|
<Button
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
Reference in New Issue
Block a user