Compare commits

..

48 Commits

Author SHA1 Message Date
72f13c7c2e ♻️ refactor: restructure hero swiper to be media-specific 2026-03-25 19:44:54 +07:00
f9104c2580 Merge pull request 'feat/recommendation' (#10) from feat/recommendation into main
All checks were successful
Sync to GitHub / sync (push) Successful in 11s
Reviewed-on: #10
2026-03-15 22:36:54 +07:00
eecaeb13e8 🚨 fix: resolve linting type error
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m44s
2026-03-15 22:32:21 +07:00
74ad82c4f0 feat: add scroll button and card skeleton loading 2026-03-15 22:23:41 +07:00
97ef74e0f7 💄 style: add scroll button UI 2026-03-15 21:25:53 +07:00
5cb3b909be 👔 feat: add title and additional attributes to card 2026-03-15 21:11:20 +07:00
8393e6393c 🚧 wip: add rating to card 2026-03-14 12:00:00 +07:00
c02832674b 🚧 wip: add image support to card 2026-03-13 12:00:00 +07:00
e3211d240a 🚧 wip: add recommendation component 2026-03-12 12:00:00 +07:00
01a15210ea Merge pull request 'feat/hero' (#9) from feat/hero into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #9
2026-03-03 14:01:42 +07:00
29f2d3fa59 🔧 chore: replace dummy data with real data
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m4s
2026-03-03 13:58:11 +07:00
2f9fef54ff 💄 style: adjust UI for image-only banner 2026-03-01 21:55:24 +07:00
119e0f447c feat: add base banner elements 2026-03-01 21:40:14 +07:00
24ec3588d5 🚧 wip: add base hero section component 2026-02-28 14:33:39 +07:00
eee8546260 Merge pull request '🚸 ux: handle duplicate email account error flow' (#8) from fix/auth into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #8
2026-02-19 17:20:03 +07:00
f5f0bb8c58 🚸 ux: handle duplicate email account error flow
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m2s
2026-02-19 17:17:23 +07:00
76f17020d4 Merge pull request 'fix/auth' (#7) from fix/auth into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #7
2026-02-18 12:58:27 +07:00
879afd94de 🩹 fix: resolve build error
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m4s
2026-02-18 12:56:29 +07:00
39124f0db4 🚸 ux: improve logout flow completely 2026-02-18 12:53:58 +07:00
0c9ca45b36 🥅 fix: handle logout failure warning 2026-02-18 12:27:24 +07:00
4fc87b7134 🛂 security: fix auth token validation flow 2026-02-17 21:32:27 +07:00
5eb7f753a5 Merge pull request 'feat/logout' (#6) from feat/logout into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #6
2026-02-14 21:45:42 +07:00
36ad865c33 feat: add logout feature
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m44s
2026-02-14 21:37:06 +07:00
9f0f5e9c55 💄 style: add logout confirmation popup UI 2026-02-14 21:12:56 +07:00
686d24084f 🐛 fix: forward browser cookies to backend via nextjs proxy 2026-02-10 23:37:03 +07:00
ef5f7ef2e0 Merge pull request '🚨 fix: resolve linting errors' (#5) from fix/lint into main
All checks were successful
Sync to GitHub / sync (push) Successful in 7s
Reviewed-on: #5
2026-02-10 20:37:39 +07:00
34eb8d3a8b 🚨 fix: resolve linting errors
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 41s
2026-02-10 20:36:19 +07:00
e187f93aef Merge pull request 'feat/down-page' (#4) from feat/down-page into main
All checks were successful
Sync to GitHub / sync (push) Successful in 12s
Reviewed-on: #4
2026-02-09 23:23:53 +07:00
0664282572 🦺 fix: validate service status before showing error page
Some checks failed
Integration Tests / integration-tests (pull_request) Failing after 54s
2026-02-09 23:23:05 +07:00
5baf988984 feat: add service-down error page 2026-02-09 23:08:08 +07:00
48b3dbdab3 💄 style: minor UI improvements in signup popup 2026-02-08 22:50:11 +07:00
9d5412bacb Merge pull request '💄 style: change shadcn theme from nova to vega' (#3) from ui/change-theme into main
All checks were successful
Sync to GitHub / sync (push) Successful in 7s
Reviewed-on: #3
2026-02-08 21:52:52 +07:00
66766c0a36 💄 style: change shadcn theme from nova to vega
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m1s
2026-02-08 21:50:32 +07:00
9ccb91e2fc Merge pull request '👷 ci: add build and lint checks to CI' (#2) from ci into main
All checks were successful
Sync to GitHub / sync (push) Successful in 7s
Reviewed-on: #2
2026-02-07 17:37:29 +07:00
d4cacf13ae 💚 ci: fix CI by adding nodejs layer
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 41s
2026-02-07 17:34:38 +07:00
5f3a40df8a 👷 ci: add build and lint checks to CI
Some checks failed
Integration Tests / integration-tests (pull_request) Failing after 47s
2026-02-07 17:30:20 +07:00
ba125f1381 Merge pull request '🚨 fix: resolve all linting errors' (#1) from fix/linting into main
Reviewed-on: #1
2026-02-07 14:14:32 +07:00
7e8d26dc53 🚨 fix: resolve all linting errors 2026-02-07 14:14:00 +07:00
0a9f011f08 🔒 security: handle unauthorization user 2026-01-21 10:29:48 +07:00
19b15b89d2 feat: user profile in navbar 2026-01-21 09:48:12 +07:00
eae3b2b3fc feat: create auth provider context 2026-01-20 11:27:09 +07:00
e27b18b22e feat: add client info in backend fetch header 2026-01-20 08:25:10 +07:00
cb436fe40c ♻️ refactor(auth): replace redirect flow to popup window 2026-01-09 09:27:44 +07:00
b2c21c5f01 feat: create provider callback handler 2026-01-09 08:23:14 +07:00
34b4ec6232 🚚 mv: change layout folder structure
Separate the navbar from the root layout, keeping the root layout clean. Create two child layout folders:
1. main: for basic layouts such as the navbar
2. clean: for clean layouts without any extra elements.
2026-01-08 15:03:07 +07:00
940e84d168 🔧 chore: create oauth endpoint req to backend 2026-01-08 14:58:21 +07:00
77eeaf1adc 🔧 chore: add handle oauth endpoint login 2026-01-07 23:28:07 +07:00
28cd3178b9 feat: add oauth button 2026-01-07 17:21:14 +07:00
61 changed files with 2120 additions and 96 deletions

33
.gitea/workflows/ci.yml Normal file
View 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

View 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

View 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."

View File

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

View File

@ -0,0 +1,7 @@
import AuthCallbackIndex from "@/features/authCallback";
const page = async () => {
return <AuthCallbackIndex />;
};
export default page;

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

View File

@ -0,0 +1,7 @@
import React from "react";
const page = () => {
return <div>page</div>;
};
export default page;

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,13 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
{
rules: {
// Disable the rule that enforces the use of `next/image` for image optimization.
"@next/next/no-img-element": "off",
},
},
// Override default ignores of eslint-config-next. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:

View File

@ -0,0 +1,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,
};
}
};

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

View File

@ -0,0 +1,134 @@
"use server";
export type RecommendationAnime = {
title: string;
rating?: number;
type: string;
status: string;
episodes: number;
release_year: string;
thumbnail_url: string;
};
export const getRecommendationAnimeAction = async (): Promise<
RecommendationAnime[]
> => {
await new Promise((resolve) => setTimeout(resolve, 2000));
return [
{
title: "Frieren: Beyond Journey's End",
rating: 9.39,
type: "TV",
status: "finished",
episodes: 28,
release_year: "2023",
thumbnail_url: "https://m.media-amazon.com/images/I/816AbVQc+0L.jpg",
},
{
title: "Steins;Gate",
rating: 9.07,
type: "TV",
status: "finished",
episodes: 24,
release_year: "2011",
thumbnail_url:
"https://m.media-amazon.com/images/M/MV5BZjI1YjZiMDUtZTI3MC00YTA5LWIzMmMtZmQ0NTZiYWM4NTYwXkEyXkFqcGc@._V1_FMjpg_UX1000_.jpg",
},
{
title: "Spirited Away",
rating: 8.78,
type: "Movie",
status: "finished",
episodes: 1,
release_year: "2001",
thumbnail_url:
"https://printedoriginals.com/cdn/shop/products/spirited-away-french-143975.jpg?v=1602427397",
},
{
title: "One Piece",
rating: 8.72,
type: "TV",
status: "airing",
episodes: 1100,
release_year: "1999",
thumbnail_url: "https://myanimelist.net/images/anime/1244/138851.jpg",
},
{
title: "Cyberpunk: Edgerunners",
rating: 8.6,
type: "ONA",
status: "finished",
episodes: 10,
release_year: "2022",
thumbnail_url:
"https://myanimelist.net/images/about_me/ranking_items/14292440-859e4272-536e-4760-845f-78fb48eccafe.jpg?t=1767555420",
},
{
title: "Your Name",
rating: 8.85,
type: "Movie",
status: "finished",
episodes: 1,
release_year: "2016",
thumbnail_url:
"https://m.media-amazon.com/images/M/MV5BMjM4YTE3OGEtYTY1OS00ZWEzLTg1OTctMTkyODA0ZDM3ZmJlXkEyXkFqcGc@._V1_.jpg",
},
{
title: "Hunter x Hunter (2011)",
rating: 9.04,
type: "TV",
status: "finished",
episodes: 148,
release_year: "2011",
thumbnail_url: "https://myanimelist.net/images/anime/1337/99013.jpg",
},
{
title: "Hellsing Ultimate",
rating: 8.36,
type: "OVA",
status: "finished",
episodes: 10,
release_year: "2006",
thumbnail_url: "https://cdn.myanimelist.net/images/anime/6/7333l.jpg",
},
{
title: "Tower of God Season 2",
rating: 7.5,
type: "TV",
status: "airing",
episodes: 12,
release_year: "2024",
thumbnail_url:
"https://www.animationmagazine.net/wordpress/wp-content/uploads/TOG2_ENLOGO_v2.jpg",
},
{
title: "Violet Evergarden: The Movie",
rating: 8.89,
type: "Movie",
status: "finished",
episodes: 1,
release_year: "2020",
thumbnail_url: "https://myanimelist.net/images/anime/1614/106512l.jpg",
},
{
title: "Devilman Crybaby",
rating: 7.75,
type: "ONA",
status: "finished",
episodes: 10,
release_year: "2018",
thumbnail_url: "https://myanimelist.net/images/anime/1046/122722.jpg",
},
{
title:
"Mobile Suit Gundam: The Origin (lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua)",
rating: 8.42,
type: "OVA",
status: "finished",
episodes: 6,
release_year: "2015",
thumbnail_url: "https://myanimelist.net/images/anime/4/72702.jpg",
},
];
};

View File

@ -1,7 +1,13 @@
"use client"; import Hero from "./sections/Hero/wrapper";
import Recommendation from "./sections/Recommendation/wrapper";
const HomeIndex = () => { const HomeIndex = () => {
return <div className="text-center w-full">HomePage</div>; return (
<div className="w-full pt-4 pb-12">
<Hero />
<Recommendation />
</div>
);
}; };
export default HomeIndex; export default HomeIndex;

View File

@ -0,0 +1,90 @@
"use client";
import "swiper/css";
import { Badge } from "@/shared/libs/shadcn/ui/badge";
import { Button } from "@/shared/libs/shadcn/ui/button";
import { useRouter } from "next/navigation";
import { Autoplay, Navigation, Pagination } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import { Icon } from "@iconify/react";
export interface HeroSwiperProps {
data: {
id: string;
title: string;
slug: string;
imageUrl: string;
synopsis: string;
genres: {
slug: string;
name: string;
}[];
}[];
}
const HeroSwiper = (props: HeroSwiperProps) => {
const router = useRouter();
return (
<div className="h-full rounded-lg overflow-hidden">
<Swiper
spaceBetween={0}
slidesPerView={1}
onSlideChange={() => console.log("slide change")}
onSwiper={(swiper) => console.log(swiper)}
className="h-full"
autoplay={{ delay: 5000, disableOnInteraction: false }}
modules={[Autoplay, Pagination, Navigation]}
>
{props.data.map((slide) => (
<SwiperSlide key={slide.id} className="relative overflow-hidden">
<img
src={slide.imageUrl}
alt={slide.title}
className="absolute top-0 left-0 z-0 object-cover w-full h-full opacity-80"
/>
<div
className="absolute top-0 left-0 z-10 h-full w-full py-16 px-20"
style={{
background:
"linear-gradient(90deg,rgba(0, 0, 0, 0.64) 0%, rgba(0, 0, 0, 0.42) 46%, rgba(0, 0, 0, 0) 100%)",
}}
>
<h1 className="text-6xl font-semibold tracking-tight">
{slide.title}
</h1>
<div className="mt-4 flex gap-1.5">
{slide.genres.map((genre) => (
<Badge
className="bg-neutral-200 text-neutral-800"
key={genre.slug}
>
{genre.name}
</Badge>
))}
</div>
<p className="mt-4 font-medium text-base max-w-[40vw] line-clamp-6">
{slide.synopsis}
</p>
<Button
size="lg"
onClick={() => router.push(`/media/${slide.slug}`)}
className="mt-6 h-12 rounded-xl flex gap-2 px-4 hover:bg-neutral-950 group"
>
<div className="bg-neutral-950 p-2 rounded-full group-hover:bg-primary">
<Icon
icon="solar:play-bold"
className="text-primary group-hover:text-neutral-950"
/>
</div>
<span className="font-semibold text-neutral-950 group-hover:text-primary">
Watch Now
</span>
</Button>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
);
};
export default HeroSwiper;

View File

@ -0,0 +1,17 @@
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
import HeroSwiper, { HeroSwiperProps } from "./components/Swiper";
const HeroMain = async () => {
const testing = async () => {
return (await backendFetch("hero-banner")) as BackendResponse<
HeroSwiperProps["data"]
>;
};
const response = await testing();
if (!response.data) return <div></div>;
return <HeroSwiper data={response.data} />;
};
export default HeroMain;

View File

@ -0,0 +1,8 @@
import { Skeleton } from "@/shared/libs/shadcn/ui/skeleton";
import React from "react";
const HeroSkeleton = () => {
return <Skeleton className="w-full h-full" />;
};
export default HeroSkeleton;

View File

@ -0,0 +1,15 @@
import { Suspense } from "react";
import HeroSkeleton from "./skeleton";
import HeroMain from "./main";
const Hero = () => {
return (
<div className="h-120 w-full">
<Suspense fallback={<HeroSkeleton />}>
<HeroMain />
</Suspense>
</div>
);
};
export default Hero;

View File

@ -0,0 +1,40 @@
import { RecommendationAnime } from "@/features/home/actions/getRecommenationAnime";
import { Icon } from "@iconify/react";
const AnimeRecommendationCard = ({ data }: { data: RecommendationAnime }) => {
return (
<div>
<div className="w-64 h-88 rounded-lg overflow-hidden relative">
{data.status === "airing" && (
<div className="absolute top-2 left-2 bg-neutral-800 flex items-center gap-0.5 px-2 py-1 rounded-full">
<Icon
icon="icon-park-outline:dot"
className="h-auto w-4 text-red-500 blink-strobe"
/>
<span className="text-xs font-medium">Airing</span>
</div>
)}
<div className="absolute right-2 top-2 bg-amber-400 text-neutral-950 flex items-center py-1 px-1.5 rounded">
<Icon icon="material-symbols:star-rounded" className="h-auto w-4" />
<span className="text-xs tracking-tight font-medium">
{data.rating ?? "N/A"}
</span>
</div>
<img
className="w-full h-full object-cover"
src={data.thumbnail_url}
alt={data.title}
draggable={false}
/>
</div>
<div className="mt-3 px-1 mb-1">
<h3 className=" font-semibold mt-1 line-clamp-1">{data.title}</h3>
<div className="flex gap-2 text-sm text-neutral-400 mt-0.5">
<span>{data.release_year}</span>
</div>
</div>
</div>
);
};
export default AnimeRecommendationCard;

View File

@ -0,0 +1,26 @@
import { Button } from "@/shared/libs/shadcn/ui/button";
import { ButtonGroup } from "@/shared/libs/shadcn/ui/button-group";
import { ArrowLeft, ArrowRight } from "lucide-react";
const ScrollingButton = ({
scrollLeft,
scrollRight,
}: {
scrollLeft: () => void;
scrollRight: () => void;
}) => {
return (
<div>
<ButtonGroup>
<Button variant="outline" onClick={scrollLeft}>
<ArrowLeft />
</Button>
<Button variant="outline" onClick={scrollRight}>
<ArrowRight />
</Button>
</ButtonGroup>
</div>
);
};
export default ScrollingButton;

View File

@ -0,0 +1,45 @@
"use client";
import { useRef } from "react";
import { RecommendationAnime } from "../../actions/getRecommenationAnime";
import AnimeRecommendationCard from "./components/Card";
import ScrollingButton from "./components/ScrollingButton";
const RecommendationClient = ({
result,
}: {
result: RecommendationAnime[];
}) => {
const scrollingContainer = useRef<HTMLDivElement | null>(null);
const scrollLeft = () => {
console.log("scroll left");
if (scrollingContainer.current) {
scrollingContainer.current.scrollBy({ left: -788, behavior: "smooth" });
}
};
const scrollRight = () => {
console.log("scroll right");
if (scrollingContainer.current) {
scrollingContainer.current.scrollBy({ left: 788, behavior: "smooth" });
}
};
return (
<div>
<div className="absolute top-0 right-0">
<ScrollingButton scrollLeft={scrollLeft} scrollRight={scrollRight} />
</div>
<div
className="flex gap-2 w-full overflow-x-scroll py-2 mt-2 hide-scrollbar relative"
ref={scrollingContainer}
>
{result.map((item, index) => (
<AnimeRecommendationCard data={item} key={index} />
))}
</div>
</div>
);
};
export default RecommendationClient;

View File

@ -0,0 +1,10 @@
import { getRecommendationAnimeAction } from "../../actions/getRecommenationAnime";
import RecommendationClient from "./main.client";
const RecommendationMain = async () => {
const data = async () => await getRecommendationAnimeAction();
const result = await data();
return <RecommendationClient result={result} />;
};
export default RecommendationMain;

View File

@ -0,0 +1,19 @@
import { Skeleton } from "@/shared/libs/shadcn/ui/skeleton";
const RecommendationSkeleton = () => {
const skeletonLenght = 6;
return (
<div className="flex gap-2 w-full overflow-hidden mt-4">
{[...Array(skeletonLenght)].map((_, index) => (
<div key={index}>
<Skeleton className="h-88 w-64" />
<Skeleton className="mt-3 h-6 w-64 rounded-full" />
<Skeleton className="mt-1 h-4 w-12 rounded-full" />
</div>
))}
</div>
);
};
export default RecommendationSkeleton;

View File

@ -0,0 +1,20 @@
import { Suspense } from "react";
import RecommendationMain from "./main";
import RecommendationSkeleton from "./skeleton";
const Recommendation = async () => {
return (
<div className="mt-12 relative">
<div className="flex justify-between">
<h1 className="text-[26px] text-neutral-100 font-semibold w-fit tracking-tight">
Maybe You Like
</h1>
</div>
<Suspense fallback={<RecommendationSkeleton />}>
<RecommendationMain />
</Suspense>
</div>
);
};
export default Recommendation;

32
features/status/index.tsx Normal file
View 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&apos;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;

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 152 KiB

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

View File

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

View 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");
}
};

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

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

View File

@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/shared/libs/shadcn/lib/utils"
const badgeVariants = cva(
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/shared/libs/shadcn/lib/utils"
import { Separator } from "@/shared/libs/shadcn/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md!",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

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

View File

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

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

View File

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

View File

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

View File

@ -17,7 +17,7 @@ function Separator({
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[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}

View File

@ -0,0 +1,13 @@
import { cn } from "@/shared/libs/shadcn/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

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

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

View 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",
};
}
};

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

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

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

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

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

View File

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

View File

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

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

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

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

View File

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