Compare commits
58 Commits
21a099b77f
...
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 | |||
| 0a9f011f08 | |||
| 19b15b89d2 | |||
| eae3b2b3fc | |||
| e27b18b22e | |||
| cb436fe40c | |||
| b2c21c5f01 | |||
| 34b4ec6232 | |||
| 940e84d168 | |||
| 77eeaf1adc | |||
| 28cd3178b9 |
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);
|
||||||
|
};
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import AuthCallbackIndex from "@/features/authCallback";
|
||||||
|
|
||||||
|
const page = async () => {
|
||||||
|
return <AuthCallbackIndex />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/(session)/(main)/layout.tsx
Normal file
13
app/(session)/(main)/layout.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import Navbar from "@/shared/widgets/navbar/components/Navbar";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const layout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
|
||||||
|
return (
|
||||||
|
<div className="max-w-396 mx-auto relative">
|
||||||
|
<Navbar />
|
||||||
|
<div className="pt-16">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default layout;
|
||||||
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;
|
||||||
@ -123,4 +123,77 @@
|
|||||||
body {
|
body {
|
||||||
@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 Navbar from "@/shared/widgets/navbar/components/Navbar";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Toaster } from "@/shared/libs/shadcn/ui/sonner";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
@ -30,10 +30,8 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<div className="max-w-400 mx-auto relative">
|
<main>{children}</main>
|
||||||
<Navbar />
|
<Toaster />
|
||||||
<div className="pt-16">{children}</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
20
bun.lock
20
bun.lock
@ -6,6 +6,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.0.0",
|
"@base-ui/react": "^1.0.0",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@ -15,8 +16,11 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"shadcn": "^3.6.3",
|
"shadcn": "^3.6.3",
|
||||||
|
"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",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@ -147,6 +151,10 @@
|
|||||||
|
|
||||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
|
"@iconify/react": ["@iconify/react@6.0.2", "", { "dependencies": { "@iconify/types": "^2.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg=="],
|
||||||
|
|
||||||
|
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||||
|
|
||||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||||
|
|
||||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||||
@ -657,6 +665,8 @@
|
|||||||
|
|
||||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||||
|
|
||||||
|
"detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
@ -941,6 +951,8 @@
|
|||||||
|
|
||||||
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
|
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
|
||||||
|
|
||||||
|
"is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="],
|
||||||
|
|
||||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||||
|
|
||||||
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
|
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
|
||||||
@ -1271,6 +1283,8 @@
|
|||||||
|
|
||||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||||
|
|
||||||
|
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
@ -1315,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=="],
|
||||||
@ -1369,6 +1385,10 @@
|
|||||||
|
|
||||||
"typescript-eslint": ["typescript-eslint@8.52.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/parser": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA=="],
|
"typescript-eslint": ["typescript-eslint@8.52.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/parser": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA=="],
|
||||||
|
|
||||||
|
"ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="],
|
||||||
|
|
||||||
|
"ua-parser-js": ["ua-parser-js@2.0.8", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-BdnBM5waFormdrOFBU+cA90R689V0tWUWlIG2i30UXxElHjuCu5+dOV2Etw3547jcQ/yaLtPm9wrqIuOY2bSJg=="],
|
||||||
|
|
||||||
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|||||||
@ -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;
|
||||||
61
features/authCallback/actions/submitProviderCallback.ts
Normal file
61
features/authCallback/actions/submitProviderCallback.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export const submitProviderCallback = async (
|
||||||
|
providerName: string,
|
||||||
|
queries?: unknown,
|
||||||
|
): Promise<
|
||||||
|
BackendResponse<{
|
||||||
|
authToken: string;
|
||||||
|
}>
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
const envKey = providerName.toUpperCase() + "_CALLBACK_URL";
|
||||||
|
|
||||||
|
const authClientCallbackUrl = (await backendFetch(
|
||||||
|
"auth/providers/" + providerName + "/callback",
|
||||||
|
)) as BackendResponse<{
|
||||||
|
callback_url: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (!authClientCallbackUrl.success)
|
||||||
|
throw new Error("Failed to get auth client callback URL");
|
||||||
|
|
||||||
|
const responseProvision = (await backendFetch(
|
||||||
|
`${authClientCallbackUrl.data?.callback_url}?callbackURI=${
|
||||||
|
process.env.APP_URL
|
||||||
|
}${process.env[envKey]}&${queries}`,
|
||||||
|
)) as BackendResponse<{
|
||||||
|
authToken: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (!responseProvision.success)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: responseProvision.status,
|
||||||
|
message: responseProvision.message,
|
||||||
|
error: responseProvision.error,
|
||||||
|
};
|
||||||
|
|
||||||
|
(await cookies()).set({
|
||||||
|
name: "auth_token",
|
||||||
|
value: responseProvision.data?.authToken || "",
|
||||||
|
httpOnly: true,
|
||||||
|
path: "/",
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: Number(process.env.SESSION_EXPIRE),
|
||||||
|
});
|
||||||
|
|
||||||
|
return responseProvision;
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 500,
|
||||||
|
message:
|
||||||
|
"Connection to authentication service failed. Please try again later.",
|
||||||
|
error: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
51
features/authCallback/index.tsx
Normal file
51
features/authCallback/index.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
import { Spinner } from "@/shared/libs/shadcn/ui/spinner";
|
||||||
|
import { submitProviderCallback } from "@/features/authCallback/actions/submitProviderCallback";
|
||||||
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const AuthCallbackIndex = () => {
|
||||||
|
const { name } = useParams();
|
||||||
|
const queries = useSearchParams().toString();
|
||||||
|
const [textDescription, setTextDescription] = useState(
|
||||||
|
"We are processing your authentication.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishOAuthFlow = (type: string, message?: string) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!window.opener) window.location.href = "/";
|
||||||
|
window.opener.postMessage(
|
||||||
|
{ type: type, message: message },
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
window.close();
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const response = await submitProviderCallback(name as string, queries);
|
||||||
|
if (response.success) {
|
||||||
|
setTextDescription("Authentication successful! Redirecting...");
|
||||||
|
finishOAuthFlow("oauth-success", response.message);
|
||||||
|
} else {
|
||||||
|
setTextDescription("Authentication failed. Please try again.");
|
||||||
|
finishOAuthFlow("oauth-failed", response.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [name, queries]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col items-center gap-2 pt-8">
|
||||||
|
<Spinner className="size-6" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-neutral-200 text-base">Please wait...</h1>
|
||||||
|
<p className="font-normal text-neutral-400 text-sm">
|
||||||
|
{textDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthCallbackIndex;
|
||||||
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;
|
||||||
@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.0.0",
|
"@base-ui/react": "^1.0.0",
|
||||||
|
"@iconify/react": "^6.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@ -19,8 +20,11 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"shadcn": "^3.6.3",
|
"shadcn": "^3.6.3",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
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;
|
||||||
14
shared/contexts/AuthContext.tsx
Normal file
14
shared/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { UserSession } from "../models/auth/validateAndDecodeJWT";
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
session: UserSession | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType>({
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
@ -1,26 +0,0 @@
|
|||||||
interface BackendResponse<T = unknown> {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
data?: T;
|
|
||||||
error?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const backendFetch = async (path: string, options: RequestInit = {}) => {
|
|
||||||
const res = await fetch(`${process.env.BACKEND_ENDPOINT}/${path}`, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
cache: "default",
|
|
||||||
});
|
|
||||||
|
|
||||||
const resJson = (await res.json()) as BackendResponse;
|
|
||||||
|
|
||||||
if (!res.ok || !resJson.success) {
|
|
||||||
throw new Error(`Elysia error: ${resJson.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return resJson;
|
|
||||||
};
|
|
||||||
45
shared/helpers/backendFetch.ts
Normal file
45
shared/helpers/backendFetch.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { UAParser } from "ua-parser-js";
|
||||||
|
|
||||||
|
export interface BackendResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
data?: T;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backendFetch = async (path: string, options: RequestInit = {}) => {
|
||||||
|
const userAgent = (await headers()).get("user-agent") || "";
|
||||||
|
const userIp = (await headers()).get("x-forwarded-for") || "unknown";
|
||||||
|
const ua = new UAParser(userAgent).getResult();
|
||||||
|
const clientInfo = {
|
||||||
|
os: ua.os.name ?? "unknown",
|
||||||
|
osVersion: ua.os.version ?? "unknown",
|
||||||
|
browser: ua.browser.name ?? "unknown",
|
||||||
|
browserVersion: ua.browser.version ?? "unknown",
|
||||||
|
deviceType: ua.device.type ?? "desktop",
|
||||||
|
ip: userIp,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${process.env.BACKEND_ENDPOINT}/${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-client-info": JSON.stringify(clientInfo),
|
||||||
|
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
|
||||||
|
cookie: (await headers()).get("cookie") || "",
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
cache: "default",
|
||||||
|
}).then((response) => response.json());
|
||||||
|
|
||||||
|
return res as BackendResponse;
|
||||||
|
} catch (res) {
|
||||||
|
if (process.env.NODE_ENV === "development") return res;
|
||||||
|
redirect("/status?reason=backend-unreachable");
|
||||||
|
}
|
||||||
|
};
|
||||||
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,
|
||||||
|
}
|
||||||
109
shared/libs/shadcn/ui/avatar.tsx
Normal file
109
shared/libs/shadcn/ui/avatar.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full aspect-square size-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
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,23 +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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
263
shared/libs/shadcn/ui/dropdown-menu.tsx
Normal file
263
shared/libs/shadcn/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
||||||
|
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
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-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}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
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-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
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"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
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"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
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
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-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
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
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-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
49
shared/libs/shadcn/ui/sonner.tsx
Normal file
49
shared/libs/shadcn/ui/sonner.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: (
|
||||||
|
<CircleCheckIcon className="size-4" />
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<TriangleAlertIcon className="size-4" />
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<OctagonXIcon className="size-4" />
|
||||||
|
),
|
||||||
|
loading: (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
10
shared/libs/shadcn/ui/spinner.tsx
Normal file
10
shared/libs/shadcn/ui/spinner.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { cn } from "@/shared/libs/shadcn/lib/utils"
|
||||||
|
import { Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
43
shared/models/auth/validateAndDecodeJWT.ts
Normal file
43
shared/models/auth/validateAndDecodeJWT.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export interface UserSession {
|
||||||
|
id: string;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
validUntil: Date;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string;
|
||||||
|
birthDate: null;
|
||||||
|
bioProfile: null;
|
||||||
|
preference: {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
langPreference: null;
|
||||||
|
adultFiltering: string;
|
||||||
|
adultAlert: string;
|
||||||
|
videoQuality: string;
|
||||||
|
serviceDefaultId: null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateAndDecodeJWT = async (): Promise<UserSession | null> => {
|
||||||
|
"use server";
|
||||||
|
const res = (await backendFetch("auth/token/validate", {
|
||||||
|
method: "POST",
|
||||||
|
})) as BackendResponse<UserSession>;
|
||||||
|
|
||||||
|
if (res.status === 403) {
|
||||||
|
redirect("/auth/logout");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data ?? null;
|
||||||
|
};
|
||||||
17
shared/providers/AuthSession.client.tsx
Normal file
17
shared/providers/AuthSession.client.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
import { AuthContext } from "../contexts/AuthContext";
|
||||||
|
import React from "react";
|
||||||
|
import { UserSession } from "../models/auth/validateAndDecodeJWT";
|
||||||
|
|
||||||
|
const AuthSessionProvider = ({
|
||||||
|
children,
|
||||||
|
session,
|
||||||
|
}: Readonly<{ children: React.ReactNode; session: UserSession | null }>) => {
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ session: session }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthSessionProvider;
|
||||||
17
shared/providers/AuthSession.tsx
Normal file
17
shared/providers/AuthSession.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
UserSession,
|
||||||
|
validateAndDecodeJWT,
|
||||||
|
} from "../models/auth/validateAndDecodeJWT";
|
||||||
|
import AuthSessionProvider from "./AuthSession.client";
|
||||||
|
|
||||||
|
const AuthSessionProviderWrapper = async ({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) => {
|
||||||
|
const session: UserSession | null = await validateAndDecodeJWT();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthSessionProvider session={session}>{children}</AuthSessionProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthSessionProviderWrapper;
|
||||||
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;
|
||||||
@ -2,10 +2,12 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import NavigationLink from "./NavigationLink";
|
import NavigationLink from "./NavigationLink";
|
||||||
import SignIn from "./SignIn";
|
import SignIn from "./SignIn";
|
||||||
import { Dialog, DialogTrigger } from "@/shared/libs/shadcn/ui/dialog";
|
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||||
import PopupWrapper from "../../signin/components/PopupWrapper";
|
import UserProfile from "./UserProfile";
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
|
const { session } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute z-10 top-0 w-full h-16 flex items-center justify-between">
|
<div className="absolute z-10 top-0 w-full h-16 flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -18,9 +20,7 @@ const Navbar = () => {
|
|||||||
/>
|
/>
|
||||||
<NavigationLink />
|
<NavigationLink />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>{session?.user ? <UserProfile /> : <SignIn />}</div>
|
||||||
<SignIn />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
98
shared/widgets/navbar/components/UserProfile.tsx
Normal file
98
shared/widgets/navbar/components/UserProfile.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { useAuth } from "@/shared/contexts/AuthContext";
|
||||||
|
import { Avatar, AvatarImage } from "@/shared/libs/shadcn/ui/avatar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/libs/shadcn/ui/dropdown-menu";
|
||||||
|
import { Button } from "@base-ui/react";
|
||||||
|
import {
|
||||||
|
Bookmark,
|
||||||
|
CircleUserRound,
|
||||||
|
ClockFading,
|
||||||
|
LifeBuoy,
|
||||||
|
LogOut,
|
||||||
|
MessagesSquare,
|
||||||
|
Settings,
|
||||||
|
Webhook,
|
||||||
|
} from "lucide-react";
|
||||||
|
import LogoutAlert from "./LogoutAlert";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const UserProfile = () => {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [openState, setOpenState] = React.useState(false);
|
||||||
|
const triggerLogoutPopup = () => {
|
||||||
|
setOpenState(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Avatar size="lg" className="cursor-pointer">
|
||||||
|
<AvatarImage
|
||||||
|
className="rounded-md"
|
||||||
|
src={session?.user?.avatar}
|
||||||
|
alt={session?.user?.name}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<CircleUserRound />
|
||||||
|
My Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<ClockFading />
|
||||||
|
Your Activity
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Bookmark />
|
||||||
|
Saved Bookmarks
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Settings />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<MessagesSquare />
|
||||||
|
Forum
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<LifeBuoy />
|
||||||
|
Help
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Webhook />
|
||||||
|
API
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" asChild>
|
||||||
|
<Button
|
||||||
|
onClick={triggerLogoutPopup}
|
||||||
|
className="w-full hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut /> Logout
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<LogoutAlert openState={openState} setOpenState={setOpenState} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserProfile;
|
||||||
14
shared/widgets/signin/actions/getAllThirdPartyAuth.ts
Normal file
14
shared/widgets/signin/actions/getAllThirdPartyAuth.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
"use server";
|
||||||
|
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
|
||||||
|
|
||||||
|
export type GetALlThirdPartyAuthCallback = BackendResponse<
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
req_endpoint: string;
|
||||||
|
}[]
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const getAllThirdPartyAuth = async () => {
|
||||||
|
return (await backendFetch("auth/providers")) as GetALlThirdPartyAuthCallback;
|
||||||
|
};
|
||||||
20
shared/widgets/signin/actions/getOauthEndpoint.ts
Normal file
20
shared/widgets/signin/actions/getOauthEndpoint.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"use server";
|
||||||
|
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
|
||||||
|
|
||||||
|
interface GetOauthEndpointParams {
|
||||||
|
endpointUrl: string;
|
||||||
|
providerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOauthEndpoint = async ({
|
||||||
|
endpointUrl,
|
||||||
|
providerName,
|
||||||
|
}: GetOauthEndpointParams) => {
|
||||||
|
const envKey = providerName.toUpperCase() + "_CALLBACK_URL";
|
||||||
|
|
||||||
|
return (await backendFetch(
|
||||||
|
`${endpointUrl}?callback=${process.env.APP_URL}${process.env[envKey]}`,
|
||||||
|
)) as BackendResponse<{
|
||||||
|
endpointUrl: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
@ -10,8 +10,70 @@ import {
|
|||||||
import { Input } from "@/shared/libs/shadcn/ui/input";
|
import { Input } from "@/shared/libs/shadcn/ui/input";
|
||||||
import { Label } from "@/shared/libs/shadcn/ui/label";
|
import { Label } from "@/shared/libs/shadcn/ui/label";
|
||||||
import { Separator } from "@/shared/libs/shadcn/ui/separator";
|
import { Separator } from "@/shared/libs/shadcn/ui/separator";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
getAllThirdPartyAuth,
|
||||||
|
GetALlThirdPartyAuthCallback,
|
||||||
|
} from "../actions/getAllThirdPartyAuth";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { Spinner } from "@/shared/libs/shadcn/ui/spinner";
|
||||||
|
import { getOauthEndpoint } from "../actions/getOauthEndpoint";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const SignInCard = () => {
|
const SignInCard = () => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [oAuthProviders, setOAuthProviders] =
|
||||||
|
useState<GetALlThirdPartyAuthCallback | null>(null);
|
||||||
|
|
||||||
|
// Fetch available third-party auth providers on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const res = await getAllThirdPartyAuth();
|
||||||
|
setOAuthProviders(res);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Open OAuth endpoint in a new popup window
|
||||||
|
const getOauthEndpointUrl = async (
|
||||||
|
providerReqEndpoint: string,
|
||||||
|
providerName: string,
|
||||||
|
) => {
|
||||||
|
const res = await getOauthEndpoint({
|
||||||
|
endpointUrl: providerReqEndpoint,
|
||||||
|
providerName: providerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
window.open(res.data?.endpointUrl, "oauthPopup", "width=500,height=600");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle the feedback from popup window for OAuth
|
||||||
|
const handleMessage = useCallback((event: MessageEvent) => {
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
if (event.data.type === "oauth-success") {
|
||||||
|
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(() => {
|
||||||
|
window.addEventListener("message", handleMessage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("message", handleMessage);
|
||||||
|
};
|
||||||
|
}, [handleMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent showCloseButton={false}>
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -22,16 +84,51 @@ 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>
|
||||||
|
<div className="my-4 flex items-center gap-2 ">
|
||||||
|
<Separator className="flex-1 bg-neutral-700" />
|
||||||
|
<p className="text-neutral-500 text-sm">or continue with</p>
|
||||||
|
<Separator className="flex-1 bg-neutral-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{oAuthProviders ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{oAuthProviders.data?.map((provider, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full text-neutral-300 text-xs font-normal"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() =>
|
||||||
|
getOauthEndpointUrl(provider.req_endpoint, provider.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isLoading && <Spinner />}
|
||||||
|
<Icon icon={provider.icon} />
|
||||||
|
Continue with {provider.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" className="w-full" variant="outline" disabled>
|
||||||
|
There are no third-party auth providers available.
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline" disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button type="submit">Continue</Button>
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading && <Spinner />}
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user