Chandima Prabhath commited on
Commit
cc2caf9
·
1 Parent(s): 0a9fda9

Track bun.lockb with Git LFS

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. frontend +0 -1
  3. frontend/.gitignore +24 -0
  4. frontend/README.md +73 -0
  5. frontend/bun.lockb +3 -0
  6. frontend/components.json +20 -0
  7. frontend/eslint.config.js +29 -0
  8. frontend/index.html +26 -0
  9. frontend/package-lock.json +0 -0
  10. frontend/package.json +85 -0
  11. frontend/postcss.config.js +6 -0
  12. frontend/public/favicon.ico +0 -0
  13. frontend/public/placeholder.svg +1 -0
  14. frontend/public/robots.txt +14 -0
  15. frontend/src/App.css +31 -0
  16. frontend/src/App.tsx +44 -0
  17. frontend/src/components/ContentCard.tsx +435 -0
  18. frontend/src/components/ContentGrid.tsx +45 -0
  19. frontend/src/components/ContentRow.tsx +107 -0
  20. frontend/src/components/EpisodesPanel.tsx +117 -0
  21. frontend/src/components/Footer.tsx +58 -0
  22. frontend/src/components/HeroSection.tsx +225 -0
  23. frontend/src/components/MoviePlayer.tsx +284 -0
  24. frontend/src/components/Navbar.tsx +157 -0
  25. frontend/src/components/PageHeader.tsx +18 -0
  26. frontend/src/components/TVShowPlayer.tsx +327 -0
  27. frontend/src/components/VideoPlayer.tsx +582 -0
  28. frontend/src/components/VideoPlayerControls.tsx +34 -0
  29. frontend/src/components/WatchTogether.tsx +314 -0
  30. frontend/src/components/ui/accordion.tsx +56 -0
  31. frontend/src/components/ui/alert-dialog.tsx +139 -0
  32. frontend/src/components/ui/alert.tsx +59 -0
  33. frontend/src/components/ui/aspect-ratio.tsx +5 -0
  34. frontend/src/components/ui/avatar.tsx +48 -0
  35. frontend/src/components/ui/badge.tsx +36 -0
  36. frontend/src/components/ui/breadcrumb.tsx +115 -0
  37. frontend/src/components/ui/button.tsx +56 -0
  38. frontend/src/components/ui/calendar.tsx +64 -0
  39. frontend/src/components/ui/card.tsx +79 -0
  40. frontend/src/components/ui/carousel.tsx +260 -0
  41. frontend/src/components/ui/chart.tsx +363 -0
  42. frontend/src/components/ui/checkbox.tsx +28 -0
  43. frontend/src/components/ui/collapsible.tsx +9 -0
  44. frontend/src/components/ui/command.tsx +153 -0
  45. frontend/src/components/ui/context-menu.tsx +198 -0
  46. frontend/src/components/ui/dialog.tsx +120 -0
  47. frontend/src/components/ui/drawer.tsx +116 -0
  48. frontend/src/components/ui/dropdown-menu.tsx +198 -0
  49. frontend/src/components/ui/form.tsx +176 -0
  50. frontend/src/components/ui/hover-card.tsx +27 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ frontend/bun.lockb filter=lfs diff=lfs merge=lfs -text
frontend DELETED
@@ -1 +0,0 @@
1
- Subproject commit baf5d1a56279e810a15f870759dbe098af9b6a2d
 
 
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Welcome to your Lovable project
2
+
3
+ ## Project info
4
+
5
+ **URL**: https://lovable.dev/projects/b1d069d7-7ee0-4ec6-854e-a62a24d11834
6
+
7
+ ## How can I edit this code?
8
+
9
+ There are several ways of editing your application.
10
+
11
+ **Use Lovable**
12
+
13
+ Simply visit the [Lovable Project](https://lovable.dev/projects/b1d069d7-7ee0-4ec6-854e-a62a24d11834) and start prompting.
14
+
15
+ Changes made via Lovable will be committed automatically to this repo.
16
+
17
+ **Use your preferred IDE**
18
+
19
+ If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
20
+
21
+ The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
22
+
23
+ Follow these steps:
24
+
25
+ ```sh
26
+ # Step 1: Clone the repository using the project's Git URL.
27
+ git clone <YOUR_GIT_URL>
28
+
29
+ # Step 2: Navigate to the project directory.
30
+ cd <YOUR_PROJECT_NAME>
31
+
32
+ # Step 3: Install the necessary dependencies.
33
+ npm i
34
+
35
+ # Step 4: Start the development server with auto-reloading and an instant preview.
36
+ npm run dev
37
+ ```
38
+
39
+ **Edit a file directly in GitHub**
40
+
41
+ - Navigate to the desired file(s).
42
+ - Click the "Edit" button (pencil icon) at the top right of the file view.
43
+ - Make your changes and commit the changes.
44
+
45
+ **Use GitHub Codespaces**
46
+
47
+ - Navigate to the main page of your repository.
48
+ - Click on the "Code" button (green button) near the top right.
49
+ - Select the "Codespaces" tab.
50
+ - Click on "New codespace" to launch a new Codespace environment.
51
+ - Edit files directly within the Codespace and commit and push your changes once you're done.
52
+
53
+ ## What technologies are used for this project?
54
+
55
+ This project is built with:
56
+
57
+ - Vite
58
+ - TypeScript
59
+ - React
60
+ - shadcn-ui
61
+ - Tailwind CSS
62
+
63
+ ## How can I deploy this project?
64
+
65
+ Simply open [Lovable](https://lovable.dev/projects/b1d069d7-7ee0-4ec6-854e-a62a24d11834) and click on Share -> Publish.
66
+
67
+ ## Can I connect a custom domain to my Lovable project?
68
+
69
+ Yes it is!
70
+
71
+ To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
72
+
73
+ Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)
frontend/bun.lockb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b55cef577ab4a57c26cbf146d3ba017d6f2c4c23119a968772d52f013c37f119
3
+ size 200043
frontend/components.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "default",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "tailwind.config.ts",
8
+ "css": "src/index.css",
9
+ "baseColor": "slate",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ }
20
+ }
frontend/eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+ import reactHooks from "eslint-plugin-react-hooks";
4
+ import reactRefresh from "eslint-plugin-react-refresh";
5
+ import tseslint from "typescript-eslint";
6
+
7
+ export default tseslint.config(
8
+ { ignores: ["dist"] },
9
+ {
10
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
11
+ files: ["**/*.{ts,tsx}"],
12
+ languageOptions: {
13
+ ecmaVersion: 2020,
14
+ globals: globals.browser,
15
+ },
16
+ plugins: {
17
+ "react-hooks": reactHooks,
18
+ "react-refresh": reactRefresh,
19
+ },
20
+ rules: {
21
+ ...reactHooks.configs.recommended.rules,
22
+ "react-refresh/only-export-components": [
23
+ "warn",
24
+ { allowConstantExport: true },
25
+ ],
26
+ "@typescript-eslint/no-unused-vars": "off",
27
+ },
28
+ }
29
+ );
frontend/index.html ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>streamwave-vista-project</title>
7
+ <meta name="description" content="Lovable Generated Project" />
8
+ <meta name="author" content="Lovable" />
9
+
10
+ <meta property="og:title" content="streamwave-vista-project" />
11
+ <meta property="og:description" content="Lovable Generated Project" />
12
+ <meta property="og:type" content="website" />
13
+ <meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
14
+
15
+ <meta name="twitter:card" content="summary_large_image" />
16
+ <meta name="twitter:site" content="@lovable_dev" />
17
+ <meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
18
+ </head>
19
+
20
+ <body>
21
+ <div id="root"></div>
22
+ <!-- IMPORTANT: DO NOT REMOVE THIS SCRIPT TAG OR THIS VERY COMMENT! -->
23
+ <script src="https://cdn.gpteng.co/gptengineer.js" type="module"></script>
24
+ <script type="module" src="/src/main.tsx"></script>
25
+ </body>
26
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "vite_react_shadcn_ts",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "build:dev": "vite build --mode development",
10
+ "lint": "eslint .",
11
+ "preview": "vite preview"
12
+ },
13
+ "dependencies": {
14
+ "@heroicons/react": "^2.2.0",
15
+ "@hookform/resolvers": "^3.9.0",
16
+ "@radix-ui/react-accordion": "^1.2.0",
17
+ "@radix-ui/react-alert-dialog": "^1.1.1",
18
+ "@radix-ui/react-aspect-ratio": "^1.1.0",
19
+ "@radix-ui/react-avatar": "^1.1.0",
20
+ "@radix-ui/react-checkbox": "^1.1.1",
21
+ "@radix-ui/react-collapsible": "^1.1.0",
22
+ "@radix-ui/react-context-menu": "^2.2.1",
23
+ "@radix-ui/react-dialog": "^1.1.2",
24
+ "@radix-ui/react-dropdown-menu": "^2.1.1",
25
+ "@radix-ui/react-hover-card": "^1.1.1",
26
+ "@radix-ui/react-label": "^2.1.0",
27
+ "@radix-ui/react-menubar": "^1.1.1",
28
+ "@radix-ui/react-navigation-menu": "^1.2.0",
29
+ "@radix-ui/react-popover": "^1.1.1",
30
+ "@radix-ui/react-progress": "^1.1.0",
31
+ "@radix-ui/react-radio-group": "^1.2.0",
32
+ "@radix-ui/react-scroll-area": "^1.1.0",
33
+ "@radix-ui/react-select": "^2.1.1",
34
+ "@radix-ui/react-separator": "^1.1.0",
35
+ "@radix-ui/react-slider": "^1.2.0",
36
+ "@radix-ui/react-slot": "^1.1.0",
37
+ "@radix-ui/react-switch": "^1.1.0",
38
+ "@radix-ui/react-tabs": "^1.1.0",
39
+ "@radix-ui/react-toast": "^1.2.1",
40
+ "@radix-ui/react-toggle": "^1.1.0",
41
+ "@radix-ui/react-toggle-group": "^1.1.0",
42
+ "@radix-ui/react-tooltip": "^1.1.4",
43
+ "@tanstack/react-query": "^5.56.2",
44
+ "class-variance-authority": "^0.7.1",
45
+ "clsx": "^2.1.1",
46
+ "cmdk": "^1.0.0",
47
+ "date-fns": "^3.6.0",
48
+ "embla-carousel-react": "^8.3.0",
49
+ "framer-motion": "^12.6.3",
50
+ "input-otp": "^1.2.4",
51
+ "lucide-react": "^0.462.0",
52
+ "next-themes": "^0.3.0",
53
+ "react": "^18.3.1",
54
+ "react-day-picker": "^8.10.1",
55
+ "react-dom": "^18.3.1",
56
+ "react-hook-form": "^7.53.0",
57
+ "react-resizable-panels": "^2.1.3",
58
+ "react-router-dom": "^6.26.2",
59
+ "recharts": "^2.12.7",
60
+ "sonner": "^1.5.0",
61
+ "tailwind-merge": "^2.5.2",
62
+ "tailwindcss-animate": "^1.0.7",
63
+ "vaul": "^0.9.3",
64
+ "zod": "^3.23.8"
65
+ },
66
+ "devDependencies": {
67
+ "@eslint/js": "^9.9.0",
68
+ "@tailwindcss/typography": "^0.5.15",
69
+ "@types/node": "^22.5.5",
70
+ "@types/react": "^18.3.3",
71
+ "@types/react-dom": "^18.3.0",
72
+ "@vitejs/plugin-react-swc": "^3.5.0",
73
+ "autoprefixer": "^10.4.20",
74
+ "eslint": "^9.9.0",
75
+ "eslint-plugin-react-hooks": "^5.1.0-rc.0",
76
+ "eslint-plugin-react-refresh": "^0.4.9",
77
+ "globals": "^15.9.0",
78
+ "lovable-tagger": "^1.1.7",
79
+ "postcss": "^8.4.47",
80
+ "tailwindcss": "^3.4.11",
81
+ "typescript": "^5.5.3",
82
+ "typescript-eslint": "^8.0.1",
83
+ "vite": "^5.4.1"
84
+ }
85
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/public/favicon.ico ADDED
frontend/public/placeholder.svg ADDED
frontend/public/robots.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ User-agent: Googlebot
2
+ Allow: /
3
+
4
+ User-agent: Bingbot
5
+ Allow: /
6
+
7
+ User-agent: Twitterbot
8
+ Allow: /
9
+
10
+ User-agent: facebookexternalhit
11
+ Allow: /
12
+
13
+ User-agent: *
14
+ Allow: /
frontend/src/App.css ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .logo {
2
+ height: 6em;
3
+ padding: 1.5em;
4
+ will-change: filter;
5
+ transition: filter 300ms;
6
+ }
7
+ .logo:hover {
8
+ filter: drop-shadow(0 0 2em #646cffaa);
9
+ }
10
+ .logo.react:hover {
11
+ filter: drop-shadow(0 0 2em #61dafbaa);
12
+ }
13
+
14
+ @keyframes logo-spin {
15
+ from {
16
+ transform: rotate(0deg);
17
+ }
18
+ to {
19
+ transform: rotate(360deg);
20
+ }
21
+ }
22
+
23
+ @media (prefers-reduced-motion: no-preference) {
24
+ a:nth-of-type(2) .logo {
25
+ animation: logo-spin infinite 20s linear;
26
+ }
27
+ }
28
+
29
+ .read-the-docs {
30
+ color: #888;
31
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Routes, Route, BrowserRouter } from 'react-router-dom';
4
+ import Index from './pages/Index';
5
+ import MainLayout from './pages/MainLayout';
6
+ import HomePage from './pages/HomePage';
7
+ import MoviesPage from './pages/MoviesPage';
8
+ import TvShowsPage from './pages/TvShowsPage';
9
+ import SearchPage from './pages/SearchPage';
10
+ import MovieDetailPage from './pages/MovieDetailPage';
11
+ import TvShowDetailPage from './pages/TvShowDetailPage';
12
+ import MoviePlayerPage from './pages/MoviePlayerPage';
13
+ import TvShowPlayerPage from './pages/TvShowPlayerPage';
14
+ import ProfilePage from './pages/ProfilePage';
15
+ import MyListPage from './pages/MyListPage';
16
+ import NotFound from './pages/NotFound';
17
+
18
+ function App() {
19
+ return (
20
+ <BrowserRouter>
21
+ <Routes>
22
+ <Route path="/" element={<Index />} />
23
+
24
+ <Route path="/" element={<MainLayout />}>
25
+ <Route path="/home" element={<HomePage />} />
26
+ <Route path="/movies" element={<MoviesPage />} />
27
+ <Route path="/tv-shows" element={<TvShowsPage />} />
28
+ <Route path="/search" element={<SearchPage />} />
29
+ <Route path="/movie/:title" element={<MovieDetailPage />} />
30
+ <Route path="/tv-show/:title" element={<TvShowDetailPage />} />
31
+ <Route path="/profile" element={<ProfilePage />} />
32
+ <Route path="/my-list" element={<MyListPage />} />
33
+ <Route path="*" element={<NotFound />} />
34
+ </Route>
35
+
36
+ {/* Full-Screen Pages */}
37
+ <Route path="/movie/:title/watch" element={<MoviePlayerPage />} />
38
+ <Route path="/tv-show/:title/watch" element={<TvShowPlayerPage />} />
39
+ </Routes>
40
+ </BrowserRouter>
41
+ );
42
+ }
43
+
44
+ export default App;
frontend/src/components/ContentCard.tsx ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Link } from 'react-router-dom';
4
+ import { Play, Info, Plus, Check, Clock, Loader2 } from 'lucide-react';
5
+ import { getMovieCard, getTvShowCard } from '../lib/api';
6
+ import { isInMyList, addToMyList, removeFromMyList } from '../lib/storage';
7
+ import { useToast } from '@/hooks/use-toast';
8
+
9
+ // -- Common Trailer type --
10
+ export interface Trailer {
11
+ id: number;
12
+ name: string;
13
+ url: string;
14
+ language: string;
15
+ runtime: number;
16
+ }
17
+
18
+ // -- TV Show types --
19
+ export interface TvShowPortrait {
20
+ id: number;
21
+ image: string;
22
+ thumbnail: string;
23
+ language: string;
24
+ type: number;
25
+ score: number;
26
+ width: number;
27
+ height: number;
28
+ includesText: boolean;
29
+ thumbnailWidth: number;
30
+ thumbnailHeight: number;
31
+ updatedAt: number;
32
+ status: {
33
+ id: number;
34
+ name: string | null;
35
+ };
36
+ tagOptions: any;
37
+ }
38
+
39
+ export interface TvShowBanner {
40
+ id: number;
41
+ image: string;
42
+ thumbnail: string;
43
+ language: string;
44
+ type: number;
45
+ score: number;
46
+ width: number;
47
+ height: number;
48
+ includesText: boolean;
49
+ thumbnailWidth: number;
50
+ thumbnailHeight: number;
51
+ updatedAt: number;
52
+ status: {
53
+ id: number;
54
+ name: string | null;
55
+ };
56
+ tagOptions: any;
57
+ }
58
+
59
+ export interface TvShowCardData {
60
+ title: string;
61
+ year: string;
62
+ image: string;
63
+ portrait: TvShowPortrait[];
64
+ banner: TvShowBanner[];
65
+ overview: string;
66
+ trailers: Trailer[];
67
+ genres?: { name: string }[];
68
+ }
69
+
70
+ // -- Movie types --
71
+ export interface MoviePortrait {
72
+ id: number;
73
+ image: string;
74
+ thumbnail: string;
75
+ language: string;
76
+ type: number;
77
+ score: number;
78
+ width: number;
79
+ height: number;
80
+ includesText: boolean;
81
+ }
82
+
83
+ export interface MovieBanner {
84
+ id: number;
85
+ image: string;
86
+ thumbnail: string;
87
+ language: string | null;
88
+ type: number;
89
+ score: number;
90
+ width: number;
91
+ height: number;
92
+ includesText: boolean;
93
+ }
94
+
95
+ export interface MovieCardData {
96
+ title: string;
97
+ year: string;
98
+ image: string;
99
+ portrait: MoviePortrait[];
100
+ banner: MovieBanner[];
101
+ overview: string;
102
+ trailers: Trailer[];
103
+ genres?: { name: string }[];
104
+ }
105
+
106
+ interface ContentCardProps {
107
+ type: 'movie' | 'tvshow';
108
+ title: string;
109
+ image?: string;
110
+ description?: string;
111
+ genre?: string[];
112
+ year?: number | string;
113
+ prefetchData?: boolean;
114
+ }
115
+
116
+ interface PlaybackProgress {
117
+ currentTime: number;
118
+ duration: number;
119
+ lastPlayed: string;
120
+ completed: boolean;
121
+ }
122
+
123
+ const ContentCard: React.FC<ContentCardProps> = ({
124
+ type,
125
+ title,
126
+ image,
127
+ description: initialDescription,
128
+ genre: initialGenre,
129
+ year: initialYear,
130
+ prefetchData = true
131
+ }) => {
132
+ const [isHovered, setIsHovered] = useState(false);
133
+ const [progress, setProgress] = useState<{ percent: number, completed: boolean } | null>(null);
134
+ const [loading, setLoading] = useState(prefetchData);
135
+ const [cardData, setCardData] = useState<MovieCardData | TvShowCardData | null>(null);
136
+ const [inMyList, setInMyList] = useState(false);
137
+ const [addingToList, setAddingToList] = useState(false);
138
+ const [selectedImage, setSelectedImage] = useState<string | null>(null);
139
+ const { toast } = useToast();
140
+
141
+ const fallbackImage = '/placeholder.svg';
142
+ const path = type === 'movie' ? `/movie/${encodeURIComponent(title)}` : `/tv-show/${encodeURIComponent(title)}`;
143
+
144
+ // Derived data with fallbacks
145
+ const description = cardData?.overview || initialDescription || '';
146
+ const genre = (cardData?.genres?.map((g: any) => g.name) || initialGenre || []);
147
+ const year = cardData?.year || initialYear || '';
148
+
149
+ // Function to randomly select an image from available banners or portraits
150
+ const selectRandomImage = (cardData: MovieCardData | TvShowCardData | null) => {
151
+ if (!cardData) return null;
152
+
153
+ // First try to get banner images (landscape)
154
+ if (cardData.banner && cardData.banner.length > 0) {
155
+ const randomIndex = Math.floor(Math.random() * cardData.banner.length);
156
+ return cardData.banner[randomIndex].image;
157
+ }
158
+
159
+ // Fall back to portrait images if no banners
160
+ if (cardData.portrait && cardData.portrait.length > 0) {
161
+ const randomIndex = Math.floor(Math.random() * cardData.portrait.length);
162
+ return cardData.portrait[randomIndex].image;
163
+ }
164
+
165
+ // Finally fall back to the default image
166
+ return cardData.image || image || fallbackImage;
167
+ };
168
+
169
+ // Check if item is in user's list
170
+ useEffect(() => {
171
+ const checkMyList = async () => {
172
+ const isInList = await isInMyList(title, type);
173
+ setInMyList(isInList);
174
+ };
175
+
176
+ checkMyList();
177
+ }, [title, type]);
178
+
179
+ // Toggle my list status
180
+ const toggleMyList = async (e: React.MouseEvent) => {
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+
184
+ setAddingToList(true);
185
+
186
+ try {
187
+ if (inMyList) {
188
+ await removeFromMyList(title, type);
189
+ setInMyList(false);
190
+ toast({
191
+ title: "Removed from My List",
192
+ description: `${title} has been removed from your list`
193
+ });
194
+ } else {
195
+ await addToMyList({
196
+ type,
197
+ title,
198
+ addedAt: new Date().toISOString()
199
+ });
200
+ setInMyList(true);
201
+ toast({
202
+ title: "Added to My List",
203
+ description: `${title} has been added to your list`
204
+ });
205
+ }
206
+ } catch (error) {
207
+ console.error('Error updating My List:', error);
208
+ toast({
209
+ title: "Error",
210
+ description: "Failed to update your list",
211
+ variant: "destructive"
212
+ });
213
+ } finally {
214
+ setAddingToList(false);
215
+ }
216
+ };
217
+
218
+ // Load content data
219
+ useEffect(() => {
220
+ if (!prefetchData) {
221
+ setLoading(false);
222
+ return;
223
+ }
224
+
225
+ const fetchData = async () => {
226
+ try {
227
+ setLoading(true);
228
+
229
+ let data;
230
+ if (type === 'movie') {
231
+ data = await getMovieCard(title);
232
+ } else {
233
+ data = await getTvShowCard(title);
234
+ // TV show data is nested in a data property
235
+ data = data?.data || data;
236
+ }
237
+
238
+ if (data) {
239
+ setCardData(data);
240
+ const randomImage = selectRandomImage(data);
241
+ setSelectedImage(randomImage);
242
+ }
243
+ } catch (error) {
244
+ console.error(`Error fetching ${type} data:`, error);
245
+ } finally {
246
+ setLoading(false);
247
+ }
248
+ };
249
+
250
+ fetchData();
251
+ }, [type, title, prefetchData, image]);
252
+
253
+ // Load playback progress on mount
254
+ useEffect(() => {
255
+ try {
256
+ const progressKey = type === 'movie' ? `movie-progress-${title}` : `playback-${title}`;
257
+ const storedProgress = localStorage.getItem(progressKey);
258
+
259
+ if (storedProgress) {
260
+ let maxProgress = 0;
261
+ let isCompleted = false;
262
+
263
+ if (type === 'movie') {
264
+ const progressData = JSON.parse(storedProgress);
265
+ maxProgress = Math.min(100, Math.floor((progressData.currentTime / progressData.duration) * 100));
266
+ isCompleted = progressData.completed;
267
+ }
268
+ // For TV shows, find the latest episode with progress
269
+ else {
270
+ const progressData = JSON.parse(storedProgress);
271
+ let latestPlaybackTime = 0;
272
+
273
+ Object.values(progressData).forEach((item: PlaybackProgress) => {
274
+ if (new Date(item.lastPlayed).getTime() > latestPlaybackTime) {
275
+ latestPlaybackTime = new Date(item.lastPlayed).getTime();
276
+ maxProgress = Math.min(100, Math.floor((item.currentTime / item.duration) * 100));
277
+ isCompleted = item.completed;
278
+ }
279
+ });
280
+ }
281
+
282
+ if (maxProgress > 0 || isCompleted) {
283
+ setProgress({ percent: maxProgress, completed: isCompleted });
284
+ }
285
+ }
286
+ } catch (error) {
287
+ console.error("Failed to load playback progress:", error);
288
+ }
289
+ }, [title, type]);
290
+
291
+ const displayImage = selectedImage || image || fallbackImage;
292
+
293
+ return (
294
+ <div
295
+ className="relative flex-shrink-0 w-[240px] md:w-[280px] card-hover group"
296
+ onMouseEnter={() => setIsHovered(true)}
297
+ onMouseLeave={() => setIsHovered(false)}
298
+ >
299
+ <div className="relative rounded-md overflow-hidden shadow-xl bg-theme-card h-[140px] md:h-[160px]">
300
+ {/* Base card image */}
301
+ <Link to={path} className="block h-full">
302
+ {loading ? (
303
+ <div className="w-full h-full bg-theme-card flex justify-center items-center animate-pulse">
304
+ <Loader2 className="w-8 h-8 animate-spin text-theme-primary/40" />
305
+ </div>
306
+ ) : (
307
+ <img
308
+ src={displayImage}
309
+ alt={title}
310
+ className={`w-full h-full object-cover transition-all duration-300 ${
311
+ isHovered ? 'scale-105 brightness-30' : 'scale-100 brightness-90'
312
+ }`}
313
+ onError={(e) => {
314
+ const target = e.target as HTMLImageElement;
315
+ target.src = fallbackImage;
316
+ }}
317
+ />
318
+ )}
319
+ </Link>
320
+
321
+ {/* Progress indicator */}
322
+ {progress && progress.percent > 0 && !progress.completed && (
323
+ <div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-800/50 z-10">
324
+ <div
325
+ className="h-full bg-theme-primary"
326
+ style={{ width: `${progress.percent}%` }}
327
+ ></div>
328
+ </div>
329
+ )}
330
+
331
+ {/* Title overlay (simple version when not hovered) */}
332
+ <div className={`absolute inset-x-0 bottom-0 p-3 ${isHovered ? 'opacity-0' : 'opacity-100'}
333
+ transition-opacity duration-300 bg-gradient-to-t from-black/90 to-transparent`}>
334
+ <div className="flex items-center">
335
+ <h3 className="font-bold text-sm line-clamp-1 flex-1">{title}</h3>
336
+ {progress?.completed && (
337
+ <div className="ml-1 bg-green-600 text-white p-0.5 rounded-full">
338
+ <Check className="w-3 h-3" />
339
+ </div>
340
+ )}
341
+ </div>
342
+ <div className="flex justify-between items-center text-xs text-gray-300 mt-1">
343
+ <div className="flex gap-1 items-center">
344
+ {year && <span>{year}</span>}
345
+ {genre && genre.length > 0 && <span className="hidden sm:inline">• {genre[0]}</span>}
346
+ </div>
347
+ {progress && !progress.completed && progress.percent > 0 && (
348
+ <div className="flex items-center ml-1 text-xs text-gray-400">
349
+ <Clock className="w-3 h-3 mr-0.5" />
350
+ <span>{progress.percent}%</span>
351
+ </div>
352
+ )}
353
+ </div>
354
+ </div>
355
+
356
+ {/* Expanded hover overlay with detailed info and buttons */}
357
+ <div
358
+ className={`fixed group-hover:absolute inset-0 z-20 bg-gradient-to-b from-black/90 to-theme-background-dark
359
+ transition-opacity duration-300 flex flex-col justify-between p-3 w-[240px] md:w-[280px] h-[140px] md:h-[160px]
360
+ ${isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
361
+ >
362
+ {/* Top section - title and info */}
363
+ <div>
364
+ <div className="flex items-center justify-between">
365
+ <h3 className="text-base font-bold line-clamp-1 flex-1">{title}</h3>
366
+ {progress?.completed && (
367
+ <div className="ml-1 bg-green-600 text-white p-0.5 rounded-full">
368
+ <Check className="w-3 h-3" />
369
+ </div>
370
+ )}
371
+ </div>
372
+
373
+ <div className="flex gap-1 items-center text-xs text-gray-300 mt-0.5">
374
+ {year && <span>{year}</span>}
375
+ {genre && genre.length > 0 && <span>• {genre[0]}</span>}
376
+ </div>
377
+
378
+ {description && (
379
+ <p className="text-xs mt-2 line-clamp-2 text-gray-300">{description}</p>
380
+ )}
381
+
382
+ {progress && !progress.completed && progress.percent > 0 && (
383
+ <div className="mt-2">
384
+ <div className="relative w-full h-1 bg-gray-800 rounded overflow-hidden">
385
+ <div
386
+ className="absolute left-0 top-0 h-full bg-theme-primary"
387
+ style={{ width: `${progress.percent}%` }}
388
+ ></div>
389
+ </div>
390
+ <p className="text-xs text-gray-400 mt-1">{progress.percent}% watched</p>
391
+ </div>
392
+ )}
393
+ </div>
394
+
395
+ {/* Bottom section - action buttons */}
396
+ <div className="mt-2">
397
+ <div className="flex justify-between space-x-2">
398
+ <button
399
+ onClick={toggleMyList}
400
+ disabled={addingToList}
401
+ className={`flex-shrink-0 bg-theme-card/80 hover:bg-theme-card-hover border border-theme-border p-2
402
+ rounded-full transition-colors ${addingToList ? 'opacity-50' : ''}`}
403
+ >
404
+ {addingToList ? (
405
+ <Loader2 className="w-4 h-4 animate-spin" />
406
+ ) : inMyList ? (
407
+ <Check className="w-4 h-4" />
408
+ ) : (
409
+ <Plus className="w-4 h-4" />
410
+ )}
411
+ </button>
412
+
413
+ <Link
414
+ to={`${path}/watch`}
415
+ className="flex-grow bg-theme-primary hover:bg-theme-primary-hover text-white py-1.5 rounded flex items-center justify-center gap-1 font-medium text-sm transition-colors"
416
+ >
417
+ <Play className="w-4 h-4" />
418
+ <span>{progress && progress.percent > 0 && !progress.completed ? "Resume" : "Play"}</span>
419
+ </Link>
420
+
421
+ <Link
422
+ to={path}
423
+ className="flex-shrink-0 bg-theme-card/80 hover:bg-theme-card-hover border border-theme-border p-2 rounded-full transition-colors"
424
+ >
425
+ <Info className="w-4 h-4" />
426
+ </Link>
427
+ </div>
428
+ </div>
429
+ </div>
430
+ </div>
431
+ </div>
432
+ );
433
+ };
434
+
435
+ export default ContentCard;
frontend/src/components/ContentGrid.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import ContentCard from './ContentCard';
4
+
5
+ export interface ContentItem {
6
+ type: 'movie' | 'tvshow';
7
+ title: string;
8
+ image?: string;
9
+ description?: string;
10
+ genre?: string[];
11
+ year?: number | string;
12
+ }
13
+
14
+ interface ContentGridProps {
15
+ items: ContentItem[];
16
+ emptyMessage?: string;
17
+ }
18
+
19
+ const ContentGrid: React.FC<ContentGridProps> = ({ items, emptyMessage = "No content available" }) => {
20
+ if (!items || items.length === 0) {
21
+ return (
22
+ <div className="flex items-center justify-center py-16">
23
+ <p className="text-netflix-gray text-lg">{emptyMessage}</p>
24
+ </div>
25
+ );
26
+ }
27
+
28
+ return (
29
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-4 md:gap-6 px-4 md:px-8">
30
+ {items.map((item, index) => (
31
+ <ContentCard
32
+ key={`${item.title}-${index}`}
33
+ type={item.type}
34
+ title={item.title}
35
+ image={item.image}
36
+ description={item.description}
37
+ genre={item.genre}
38
+ year={item.year}
39
+ />
40
+ ))}
41
+ </div>
42
+ );
43
+ };
44
+
45
+ export default ContentGrid;
frontend/src/components/ContentRow.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useRef, useEffect } from 'react';
3
+ import ContentCard from './ContentCard';
4
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
5
+
6
+ interface ContentItem {
7
+ type: 'movie' | 'tvshow';
8
+ title: string;
9
+ image: string;
10
+ description?: string;
11
+ genre?: string[];
12
+ year?: number | string;
13
+ }
14
+
15
+ interface ContentRowProps {
16
+ title: string;
17
+ items: ContentItem[];
18
+ }
19
+
20
+ const ContentRow: React.FC<ContentRowProps> = ({ title, items }) => {
21
+ const rowRef = useRef<HTMLDivElement>(null);
22
+ const [showLeftButton, setShowLeftButton] = useState(false);
23
+ const [showRightButton, setShowRightButton] = useState(true);
24
+
25
+ // Handle scroll events to show/hide buttons
26
+ const handleScroll = () => {
27
+ if (rowRef.current) {
28
+ const { scrollLeft, scrollWidth, clientWidth } = rowRef.current;
29
+ setShowLeftButton(scrollLeft > 20);
30
+ setShowRightButton(scrollLeft < scrollWidth - clientWidth - 20);
31
+ }
32
+ };
33
+
34
+ // Set up scroll listeners and initial state
35
+ useEffect(() => {
36
+ handleScroll();
37
+ window.addEventListener('resize', handleScroll);
38
+ return () => window.removeEventListener('resize', handleScroll);
39
+ }, [items]);
40
+
41
+ const scroll = (direction: 'left' | 'right') => {
42
+ if (rowRef.current) {
43
+ const card = rowRef.current.querySelector('.card-hover');
44
+ const cardWidth = card ? card.clientWidth + 16 : 280; // Card width + margin
45
+ const scrollAmount = direction === 'left' ? -cardWidth * 3 : cardWidth * 3;
46
+ rowRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
47
+ }
48
+ };
49
+
50
+ // Don't render the row if there are no items
51
+ if (items.length === 0) {
52
+ return null;
53
+ }
54
+
55
+ return (
56
+ <div className="content-row mb-8">
57
+ <h2 className="text-xl font-bold px-4 md:px-8 mb-4">{title}</h2>
58
+
59
+ <div className="relative group">
60
+ {/* Left scroll button */}
61
+ <button
62
+ onClick={() => scroll('left')}
63
+ className={`absolute left-2 top-1/2 transform -translate-y-1/2 z-30
64
+ bg-black/40 hover:bg-black/60 rounded-full p-2 transition-all duration-200
65
+ ${showLeftButton ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
66
+ aria-label="Scroll left"
67
+ >
68
+ <ChevronLeft className="w-6 h-6 text-white" />
69
+ </button>
70
+
71
+ {/* Content row */}
72
+ <div
73
+ ref={rowRef}
74
+ onScroll={handleScroll}
75
+ className="flex gap-4 overflow-x-auto py-4 px-4 md:px-8 scrollbar-none scroll-smooth"
76
+ style={{ scrollbarWidth: 'none' }}
77
+ >
78
+ {items.map((item, index) => (
79
+ <div key={`${item.title}-${index}`} className="flex-shrink-0">
80
+ <ContentCard
81
+ type={item.type}
82
+ title={item.title}
83
+ image={item.image}
84
+ description={item.description}
85
+ genre={item.genre}
86
+ year={item.year}
87
+ />
88
+ </div>
89
+ ))}
90
+ </div>
91
+
92
+ {/* Right scroll button */}
93
+ <button
94
+ onClick={() => scroll('right')}
95
+ className={`absolute right-2 top-1/2 transform -translate-y-1/2 z-30
96
+ bg-black/40 hover:bg-black/60 rounded-full p-2 transition-all duration-200
97
+ ${showRightButton ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
98
+ aria-label="Scroll right"
99
+ >
100
+ <ChevronRight className="w-6 h-6 text-white" />
101
+ </button>
102
+ </div>
103
+ </div>
104
+ );
105
+ };
106
+
107
+ export default ContentRow;
frontend/src/components/EpisodesPanel.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Check, Play, X } from 'lucide-react';
4
+
5
+ interface Episode {
6
+ episode_number: number;
7
+ name: string;
8
+ fileName?: string;
9
+ }
10
+
11
+ interface Season {
12
+ season_number: number;
13
+ name: string;
14
+ episodes: Episode[];
15
+ }
16
+
17
+ interface PlaybackProgress {
18
+ [key: string]: {
19
+ currentTime: number;
20
+ duration: number;
21
+ lastPlayed: string;
22
+ completed: boolean;
23
+ };
24
+ }
25
+
26
+ interface EpisodesPanelProps {
27
+ seasons: Season[];
28
+ selectedSeason: string;
29
+ selectedEpisode: string;
30
+ playbackProgress: PlaybackProgress;
31
+ onSelectEpisode: (seasonName: string, episode: Episode) => void;
32
+ onClose: () => void;
33
+ showTitle?: string;
34
+ }
35
+
36
+ const EpisodesPanel: React.FC<EpisodesPanelProps> = ({
37
+ seasons,
38
+ selectedSeason,
39
+ selectedEpisode,
40
+ playbackProgress,
41
+ onSelectEpisode,
42
+ onClose,
43
+ showTitle = 'Episodes'
44
+ }) => {
45
+ // Helper function to get episode progress
46
+ const getEpisodeProgress = (seasonName: string, episodeFileName: string) => {
47
+ const episodeId = `${seasonName}-${episodeFileName}`;
48
+ return playbackProgress[episodeId] || null;
49
+ };
50
+
51
+ return (
52
+ <div className="h-full flex flex-col">
53
+ <div className="p-6 flex justify-between items-center border-b border-gray-800">
54
+ <h2 className="text-2xl font-bold text-white">{showTitle}</h2>
55
+ <button
56
+ onClick={onClose}
57
+ className="text-white hover:text-gray-300 transition-colors"
58
+ aria-label="Close episodes panel"
59
+ >
60
+ <X className="h-6 w-6" />
61
+ </button>
62
+ </div>
63
+
64
+ <div className="flex-1 overflow-y-auto p-6 space-y-8">
65
+ {seasons.map((season) => (
66
+ <div key={season.name} className="mb-6">
67
+ <h3 className="text-xl font-bold mb-3 text-white">{season.name}</h3>
68
+ <div className="space-y-2">
69
+ {season.episodes.map((episode) => {
70
+ const progress = getEpisodeProgress(season.name, episode.fileName || '');
71
+ const progressPercent = progress
72
+ ? Math.min(100, Math.floor((progress.currentTime / progress.duration) * 100))
73
+ : 0;
74
+
75
+ return (
76
+ <div
77
+ key={episode.fileName}
78
+ className={`p-3 rounded-md flex items-start hover:bg-gray-800 cursor-pointer transition-colors ${selectedEpisode === episode.fileName ? 'bg-gray-800' : 'bg-gray-900/60'}`}
79
+ onClick={() => onSelectEpisode(season.name, episode)}
80
+ >
81
+ <div className="flex-shrink-0 mr-3">
82
+ {selectedEpisode === episode.fileName ? (
83
+ <div className="w-4 h-4 rounded-full bg-red-600 flex items-center justify-center">
84
+ <Play size={8} className="text-white ml-0.5" />
85
+ </div>
86
+ ) : (
87
+ <div className="w-4 h-4 rounded-full bg-gray-700 flex items-center justify-center">
88
+ <span className="text-xs text-white">{episode.episode_number}</span>
89
+ </div>
90
+ )}
91
+ </div>
92
+ <div className="flex-1">
93
+ <div className="flex justify-between">
94
+ <h4 className="font-medium text-white">{episode.name}</h4>
95
+ {progress?.completed && (
96
+ <Check size={16} className="text-green-500" />
97
+ )}
98
+ </div>
99
+ <div className="relative w-full h-1 bg-gray-700 mt-2 rounded overflow-hidden">
100
+ <div
101
+ className="absolute left-0 top-0 h-full bg-red-600"
102
+ style={{ width: `${progressPercent}%` }}
103
+ />
104
+ </div>
105
+ </div>
106
+ </div>
107
+ );
108
+ })}
109
+ </div>
110
+ </div>
111
+ ))}
112
+ </div>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default EpisodesPanel;
frontend/src/components/Footer.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import { Link } from 'react-router-dom';
4
+
5
+ const Footer: React.FC = () => {
6
+ return (
7
+ <footer className="bg-netflix-black text-netflix-gray py-12 px-4 md:px-16">
8
+ <div className="max-w-6xl mx-auto">
9
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-8">
10
+ <div>
11
+ <h3 className="text-netflix-light text-lg font-semibold mb-4">Navigation</h3>
12
+ <ul className="space-y-2">
13
+ <li><Link to="/" className="hover:text-netflix-light transition">Home</Link></li>
14
+ <li><Link to="/movies" className="hover:text-netflix-light transition">Movies</Link></li>
15
+ <li><Link to="/tv-shows" className="hover:text-netflix-light transition">TV Shows</Link></li>
16
+ <li><Link to="/my-list" className="hover:text-netflix-light transition">My List</Link></li>
17
+ </ul>
18
+ </div>
19
+
20
+ <div>
21
+ <h3 className="text-netflix-light text-lg font-semibold mb-4">Categories</h3>
22
+ <ul className="space-y-2">
23
+ <li><Link to="/movies?genre=action" className="hover:text-netflix-light transition">Action</Link></li>
24
+ <li><Link to="/movies?genre=comedy" className="hover:text-netflix-light transition">Comedy</Link></li>
25
+ <li><Link to="/movies?genre=drama" className="hover:text-netflix-light transition">Drama</Link></li>
26
+ <li><Link to="/tv-shows?genre=reality" className="hover:text-netflix-light transition">Reality</Link></li>
27
+ </ul>
28
+ </div>
29
+
30
+ <div>
31
+ <h3 className="text-netflix-light text-lg font-semibold mb-4">About</h3>
32
+ <ul className="space-y-2">
33
+ <li><Link to="/about" className="hover:text-netflix-light transition">About Us</Link></li>
34
+ <li><Link to="/contact" className="hover:text-netflix-light transition">Contact</Link></li>
35
+ <li><Link to="/terms" className="hover:text-netflix-light transition">Terms of Use</Link></li>
36
+ <li><Link to="/privacy" className="hover:text-netflix-light transition">Privacy Policy</Link></li>
37
+ </ul>
38
+ </div>
39
+
40
+ <div>
41
+ <h3 className="text-netflix-light text-lg font-semibold mb-4">Connect</h3>
42
+ <ul className="space-y-2">
43
+ <li><a href="#" className="hover:text-netflix-light transition">Twitter</a></li>
44
+ <li><a href="#" className="hover:text-netflix-light transition">Instagram</a></li>
45
+ <li><a href="#" className="hover:text-netflix-light transition">Facebook</a></li>
46
+ <li><a href="#" className="hover:text-netflix-light transition">YouTube</a></li>
47
+ </ul>
48
+ </div>
49
+ </div>
50
+ <div className="mt-12 pt-6 border-t border-netflix-gray/30 text-center text-xs">
51
+ <p className="mb-2">© 2025 Nexora. All rights reserved.</p>
52
+ </div>
53
+ </div>
54
+ </footer>
55
+ );
56
+ };
57
+
58
+ export default Footer;
frontend/src/components/HeroSection.tsx ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { getRecentItems } from '../lib/api';
3
+ import { useNavigate } from 'react-router-dom';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+ import { Play, Info, ChevronLeft, ChevronRight } from 'lucide-react';
6
+ import { Link } from 'react-router-dom';
7
+
8
+ interface SlideItem {
9
+ id: string;
10
+ type: 'movie' | 'tvshow';
11
+ title: string;
12
+ description: string;
13
+ backdrop: string;
14
+ genre?: string[];
15
+ year?: string | number;
16
+ }
17
+
18
+ interface DynamicHeroSlideshowProps {
19
+ slides: SlideItem[];
20
+ autoplaySpeed?: number;
21
+ }
22
+
23
+ const DynamicHeroSlideshow: React.FC<DynamicHeroSlideshowProps> = ({
24
+ slides,
25
+ autoplaySpeed = 6000
26
+ }) => {
27
+ const [currentIndex, setCurrentIndex] = useState(0);
28
+ const [isAutoplay, setIsAutoplay] = useState(true);
29
+
30
+ const navigate = useNavigate();
31
+
32
+ useEffect(() => {
33
+ if (!slides.length) return;
34
+
35
+ let interval: NodeJS.Timeout | null = null;
36
+
37
+ if (isAutoplay) {
38
+ interval = setInterval(() => {
39
+ setCurrentIndex((prevIndex) => (prevIndex + 1) % slides.length);
40
+ }, autoplaySpeed);
41
+ }
42
+
43
+ return () => {
44
+ if (interval) clearInterval(interval);
45
+ };
46
+ }, [slides, isAutoplay, autoplaySpeed]);
47
+
48
+ const handleNext = () => {
49
+ setIsAutoplay(false);
50
+ setCurrentIndex((prevIndex) => (prevIndex + 1) % slides.length);
51
+ };
52
+
53
+ const handlePrev = () => {
54
+ setIsAutoplay(false);
55
+ setCurrentIndex((prevIndex) => (prevIndex - 1 + slides.length) % slides.length);
56
+ };
57
+
58
+ const handleDotClick = (index: number) => {
59
+ setIsAutoplay(false);
60
+ setCurrentIndex(index);
61
+ };
62
+
63
+ if (!slides.length) return null;
64
+
65
+ const currentSlide = slides[currentIndex];
66
+ const path = currentSlide.type === 'movie'
67
+ ? `/movie/${encodeURIComponent(currentSlide.title)}`
68
+ : `/tv-show/${encodeURIComponent(currentSlide.title)}`;
69
+
70
+ return (
71
+ <div className="relative w-full min-h-[450px] sm:min-h-[550px] md:min-h-[650px]">
72
+ {/* Backdrop Slideshow */}
73
+ <AnimatePresence mode="wait">
74
+ <motion.div
75
+ key={currentSlide.id}
76
+ className="absolute inset-0 w-full h-full"
77
+ initial={{ opacity: 0 }}
78
+ animate={{ opacity: 1 }}
79
+ exit={{ opacity: 0 }}
80
+ transition={{ duration: 0.8 }}
81
+ >
82
+ <img
83
+ src={currentSlide.backdrop}
84
+ alt={currentSlide.title}
85
+ className="w-full h-full object-cover object-top"
86
+ onError={(e) => {
87
+ const target = e.target as HTMLImageElement;
88
+ target.src = '/placeholder.svg';
89
+ }}
90
+ />
91
+ <div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-transparent" />
92
+ <div className="absolute inset-0 bg-gradient-to-r from-black/80 via-black/40 to-transparent" />
93
+ </motion.div>
94
+ </AnimatePresence>
95
+
96
+ {/* Navigation arrows */}
97
+ <button
98
+ onClick={handlePrev}
99
+ className="absolute left-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30 text-white backdrop-blur-sm hover:bg-indigo-800/40 transition-colors"
100
+ aria-label="Previous slide"
101
+ >
102
+ <ChevronLeft className="w-6 h-6" />
103
+ </button>
104
+
105
+ <button
106
+ onClick={handleNext}
107
+ className="absolute right-4 top-1/2 -translate-y-1/2 z-20 p-2 rounded-full bg-black/30 text-white backdrop-blur-sm hover:bg-indigo-800/40 transition-colors"
108
+ aria-label="Next slide"
109
+ >
110
+ <ChevronRight className="w-6 h-6" />
111
+ </button>
112
+
113
+ {/* Content */}
114
+ <AnimatePresence mode="wait">
115
+ <motion.div
116
+ key={`content-${currentSlide.id}`}
117
+ initial={{ opacity: 0, y: 20 }}
118
+ animate={{ opacity: 1, y: 0 }}
119
+ exit={{ opacity: 0, y: -20 }}
120
+ transition={{ duration: 0.5, delay: 0.3 }}
121
+ className="absolute z-10 flex flex-col justify-end h-full bottom-0 pb-16 pt-24 px-4 sm:px-8 md:px-16 max-w-4xl"
122
+ >
123
+ <div className="animate-slide-up">
124
+ <h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold mb-3 text-white">{currentSlide.title}</h1>
125
+
126
+ <div className="flex flex-wrap items-center text-sm text-gray-300 mb-4">
127
+ {currentSlide.year && <span className="mr-3">{currentSlide.year}</span>}
128
+ {currentSlide.genre && currentSlide.genre.length > 0 && (
129
+ <span className="mr-3">{currentSlide.genre.slice(0, 3).join(' • ')}</span>
130
+ )}
131
+ <span className="capitalize bg-indigo-500/40 px-2 py-0.5 rounded">{currentSlide.type}</span>
132
+ </div>
133
+
134
+ <p className="text-sm sm:text-base md:text-lg mb-6 line-clamp-3 sm:line-clamp-4 max-w-2xl text-gray-100">
135
+ {currentSlide.description}
136
+ </p>
137
+
138
+ <div className="flex space-x-3">
139
+ <Link
140
+ to={`${path}/watch`}
141
+ className="flex items-center px-6 py-2 rounded bg-indigo-600 text-white font-semibold hover:bg-indigo-700 transition"
142
+ >
143
+ <Play className="w-5 h-5 mr-2" /> Play
144
+ </Link>
145
+ <Link
146
+ to={path}
147
+ className="flex items-center px-6 py-2 rounded bg-gray-800/60 text-white font-semibold hover:bg-gray-700/80 transition"
148
+ >
149
+ <Info className="w-5 h-5 mr-2" /> More Info
150
+ </Link>
151
+ </div>
152
+ </div>
153
+ </motion.div>
154
+ </AnimatePresence>
155
+
156
+ {/* Dots navigation */}
157
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex space-x-2">
158
+ {slides.map((_, index) => (
159
+ <button
160
+ key={index}
161
+ onClick={() => handleDotClick(index)}
162
+ className={`w-2.5 h-2.5 rounded-full transition-all ${
163
+ index === currentIndex
164
+ ? 'bg-indigo-500 w-6'
165
+ : 'bg-gray-500/50 hover:bg-gray-400/70'
166
+ }`}
167
+ aria-label={`Go to slide ${index + 1}`}
168
+ />
169
+ ))}
170
+ </div>
171
+ </div>
172
+ );
173
+ };
174
+
175
+ interface HeroSectionProps {
176
+ // These props are still available if you need to override the API data,
177
+ // but the API data will be used as the primary slides.
178
+ type?: 'movie' | 'tvshow';
179
+ title?: string;
180
+ description?: string;
181
+ backdrop?: string;
182
+ genre?: string[];
183
+ year?: string | number;
184
+ }
185
+
186
+ const HeroSection: React.FC<HeroSectionProps> = (props) => {
187
+ const [slides, setSlides] = useState<any[]>([]);
188
+ const [isLoaded, setIsLoaded] = useState(false);
189
+
190
+ useEffect(() => {
191
+ const fetchSlides = async () => {
192
+ try {
193
+ // Fetch recent items from the API (change the limit as needed)
194
+ const recentItems = await getRecentItems(5);
195
+ // Map recent items to the slide format expected by DynamicHeroSlideshow
196
+ const formattedSlides = recentItems.map((item: any, index: number) => ({
197
+ id: item.id || index.toString(),
198
+ type: item.type,
199
+ title: item.title,
200
+ description: item.description,
201
+ backdrop: item.image, // assuming the API returns "image" to be used as backdrop
202
+ genre: item.genre || [],
203
+ year: item.year,
204
+ }));
205
+ setSlides(formattedSlides);
206
+ } catch (error) {
207
+ console.error('Error fetching recent items:', error);
208
+ } finally {
209
+ setIsLoaded(true);
210
+ }
211
+ };
212
+
213
+ fetchSlides();
214
+ }, []);
215
+
216
+ if (!isLoaded) {
217
+ return (
218
+ <div className="relative w-full min-h-[450px] sm:min-h-[550px] md:min-h-[650px] animate-pulse bg-cinema-medium/30"></div>
219
+ );
220
+ }
221
+
222
+ return <DynamicHeroSlideshow slides={slides} autoplaySpeed={8000} />;
223
+ };
224
+
225
+ export default HeroSection;
frontend/src/components/MoviePlayer.tsx ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useRef } from 'react';
2
+ import { getMovieLinkByTitle, getMovieCard } from '../lib/api';
3
+ import { useToast } from '@/hooks/use-toast';
4
+ import VideoPlayer from './VideoPlayer';
5
+ import VideoPlayerControls from './VideoPlayerControls';
6
+ import { Loader2, Play } from 'lucide-react';
7
+
8
+ interface ProgressData {
9
+ status: string;
10
+ progress: number;
11
+ downloaded: number;
12
+ total: number;
13
+ }
14
+
15
+ interface MoviePlayerProps {
16
+ movieTitle: string;
17
+ videoUrl?: string;
18
+ contentRatings?: any[];
19
+ thumbnail?: string;
20
+ poster?: string;
21
+ startTime?: number;
22
+ onClosePlayer?: () => void;
23
+ onProgressUpdate?: (currentTime: number, duration: number) => void;
24
+ onVideoEnded?: () => void;
25
+ showNextButton?: boolean;
26
+ }
27
+
28
+ const MoviePlayer: React.FC<MoviePlayerProps> = ({
29
+ movieTitle,
30
+ videoUrl,
31
+ contentRatings,
32
+ thumbnail,
33
+ poster,
34
+ startTime = 0,
35
+ onClosePlayer,
36
+ onProgressUpdate,
37
+ onVideoEnded,
38
+ showNextButton = false
39
+ }) => {
40
+ const [videoUrlState, setVideoUrlState] = useState<string | null>(videoUrl || null);
41
+ const [loading, setLoading] = useState(!videoUrl);
42
+ const [error, setError] = useState<string | null>(null);
43
+ const [progress, setProgress] = useState<ProgressData | null>(null);
44
+ const [videoFetched, setVideoFetched] = useState(!!videoUrl);
45
+ const { toast } = useToast();
46
+
47
+ const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
48
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
49
+ const videoFetchedRef = useRef(!!videoUrl);
50
+ const [ratingInfo, setRatingInfo] = useState<{ rating: string, description: string } | null>(null);
51
+ const [currentTime, setCurrentTime] = useState(startTime);
52
+ const containerRef = useRef<HTMLDivElement>(null);
53
+ const videoRef = useRef<HTMLVideoElement>(null);
54
+
55
+ // Update the onProgressUpdate handler to also update currentTime
56
+ const handleProgressUpdate = (time: number, duration: number) => {
57
+ setCurrentTime(time);
58
+ onProgressUpdate?.(time, duration);
59
+ };
60
+
61
+ // Handler for seeking from WatchTogether
62
+ const handleSeek = (time: number) => {
63
+ if (videoRef.current) {
64
+ videoRef.current.currentTime = time;
65
+ setCurrentTime(time);
66
+ }
67
+ };
68
+
69
+ // --- Link Fetching & Polling ---
70
+ const fetchMovieLink = async () => {
71
+ if (videoFetchedRef.current || videoUrlState) return;
72
+
73
+ try {
74
+ const response = await getMovieLinkByTitle(movieTitle);
75
+
76
+ if (response && response.url) {
77
+ // Stop any polling if running
78
+ if (pollingIntervalRef.current) {
79
+ clearInterval(pollingIntervalRef.current);
80
+ pollingIntervalRef.current = null;
81
+ }
82
+
83
+ setVideoUrlState(response.url);
84
+ setVideoFetched(true);
85
+ videoFetchedRef.current = true;
86
+ setLoading(false); // Ensure loading is set to false when URL is fetched
87
+ console.log('Video URL fetched:', response.url);
88
+ } else if (response && response.progress_url) {
89
+ startPolling(response.progress_url);
90
+ } else {
91
+ console.error('No video URL or progress URL found in response:', response);
92
+ setError('Video URL not available');
93
+ }
94
+ } catch (error) {
95
+ console.error('Error fetching movie link:', error);
96
+ setError('Failed to load video');
97
+ toast({
98
+ title: "Error",
99
+ description: "Could not load the video",
100
+ variant: "destructive"
101
+ });
102
+ } finally {
103
+ // Only set loading to false if we don't have a video yet
104
+ if (!videoFetchedRef.current && !videoUrlState) {
105
+ setLoading(false);
106
+ }
107
+ }
108
+ };
109
+
110
+ // Fetch content ratings if not provided
111
+ useEffect(() => {
112
+ const fetchRatingInfo = async () => {
113
+ if (contentRatings && contentRatings.length > 0) {
114
+ const usRating = contentRatings.find(r => r.country === 'usa') || contentRatings[0];
115
+ setRatingInfo({
116
+ rating: usRating.name || 'NR',
117
+ description: usRating.description || ''
118
+ });
119
+ return;
120
+ }
121
+
122
+ try {
123
+ const movieData = await getMovieCard(movieTitle);
124
+ if (movieData && movieData.content_ratings) {
125
+ const ratings = movieData.content_ratings;
126
+ const usRating = ratings.find((r: any) => r.country === 'US') || ratings[0];
127
+ setRatingInfo({
128
+ rating: usRating?.name || 'NR',
129
+ description: usRating?.description || ''
130
+ });
131
+ }
132
+ } catch (error) {
133
+ console.error('Failed to fetch movie ratings:', error);
134
+ }
135
+ };
136
+
137
+ fetchRatingInfo();
138
+ }, [movieTitle, contentRatings]);
139
+
140
+ const pollProgress = async (progressUrl: string) => {
141
+ try {
142
+ const res = await fetch(progressUrl);
143
+ const data = await res.json();
144
+
145
+ setProgress(data.progress);
146
+
147
+ if (data.progress.progress >= 100) {
148
+ if (pollingIntervalRef.current) {
149
+ clearInterval(pollingIntervalRef.current);
150
+ pollingIntervalRef.current = null;
151
+ }
152
+
153
+ if (!videoFetchedRef.current) {
154
+ timeoutRef.current = setTimeout(fetchMovieLink, 5000);
155
+ }
156
+ }
157
+ } catch (error) {
158
+ console.error('Error polling progress:', error);
159
+ }
160
+ };
161
+
162
+ const startPolling = (progressUrl: string) => {
163
+ if (!pollingIntervalRef.current) {
164
+ const interval = setInterval(() => pollProgress(progressUrl), 2000);
165
+ pollingIntervalRef.current = interval;
166
+ }
167
+ };
168
+
169
+ // Cleanup on unmount and when dependencies change
170
+ useEffect(() => {
171
+ if (!videoUrlState) {
172
+ fetchMovieLink();
173
+ } else {
174
+ setVideoFetched(true);
175
+ videoFetchedRef.current = true;
176
+ setLoading(false); // Make sure loading is false when we have a URL
177
+ }
178
+
179
+ return () => {
180
+ if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current);
181
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
182
+ };
183
+ }, [movieTitle, videoUrl]);
184
+
185
+ // Add effect to update loading state when videoUrlState changes
186
+ useEffect(() => {
187
+ if (videoUrlState) {
188
+ setLoading(false);
189
+ }
190
+ }, [videoUrlState]);
191
+
192
+ if (error) {
193
+ return (
194
+ <div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center">
195
+ <div className="text-4xl mb-4 text-theme-error">😢</div>
196
+ <h2 className="text-2xl font-bold mb-2 text-white">Error Playing Movie</h2>
197
+ <p className="text-gray-400 mb-6">{error}</p>
198
+ <button
199
+ onClick={onClosePlayer}
200
+ className="px-6 py-2 bg-theme-primary hover:bg-theme-primary-hover rounded font-medium transition-colors text-white"
201
+ >
202
+ Back to Browse
203
+ </button>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ if (loading || !videoFetched || !videoUrlState) {
209
+ return (
210
+ <div className="fixed inset-0 z-50 bg-gradient-to-br from-theme-background-dark to-black flex flex-col items-center justify-center">
211
+ <div className="text-center max-w-md px-6">
212
+ <div className="mb-6 flex justify-center">
213
+ {poster ? (
214
+ <img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" />
215
+ ) : (
216
+ <div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg">
217
+ <Play className="h-12 w-12 text-theme-primary" />
218
+ </div>
219
+ )}
220
+ </div>
221
+
222
+ <h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
223
+ {progress && progress.progress < 100
224
+ ? `Preparing "${movieTitle}"`
225
+ : `Loading "${movieTitle}"`
226
+ }
227
+ </h2>
228
+
229
+ {progress ? (
230
+ <>
231
+ <p className="text-gray-300 mb-4">
232
+ {progress.progress < 5
233
+ ? 'Initializing your stream...'
234
+ : progress.progress < 100
235
+ ? 'Your stream is being prepared.'
236
+ : 'Almost ready! Starting playback soon...'}
237
+ </p>
238
+ <div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
239
+ <div
240
+ className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300"
241
+ style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }}
242
+ />
243
+ </div>
244
+ <p className="text-sm text-gray-400">
245
+ {Math.round(progress.progress)}% complete
246
+ </p>
247
+ </>
248
+ ) : (
249
+ <div className="flex justify-center">
250
+ <Loader2 className="h-8 w-8 animate-spin text-theme-primary" />
251
+ </div>
252
+ )}
253
+ </div>
254
+ </div>
255
+ );
256
+ }
257
+
258
+ return (
259
+ <div ref={containerRef} className="fixed inset-0 h-screen w-screen overflow-hidden">
260
+ <VideoPlayer
261
+ url={videoUrlState}
262
+ title={movieTitle}
263
+ poster={poster || thumbnail}
264
+ startTime={startTime}
265
+ onClose={onClosePlayer}
266
+ onProgressUpdate={handleProgressUpdate}
267
+ onVideoEnded={onVideoEnded}
268
+ showNextButton={showNextButton}
269
+ contentRating={ratingInfo}
270
+ containerRef={containerRef}
271
+ videoRef={videoRef}
272
+ />
273
+
274
+ <VideoPlayerControls
275
+ title={movieTitle}
276
+ currentTime={currentTime}
277
+ duration={videoRef.current?.duration || 0}
278
+ onSeek={handleSeek}
279
+ />
280
+ </div>
281
+ );
282
+ };
283
+
284
+ export default MoviePlayer;
frontend/src/components/Navbar.tsx ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect, useRef } from 'react';
3
+ import { Link, useNavigate } from 'react-router-dom';
4
+ import { Search, Bell, User, Menu, X } from 'lucide-react';
5
+ import { motion, AnimatePresence } from 'framer-motion';
6
+
7
+ const Navbar = () => {
8
+ const [isScrolled, setIsScrolled] = useState(false);
9
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
10
+ const [searchVisible, setSearchVisible] = useState(false);
11
+ const [searchTerm, setSearchTerm] = useState('');
12
+ const searchInputRef = useRef<HTMLInputElement>(null);
13
+ const navigate = useNavigate();
14
+
15
+ useEffect(() => {
16
+ const handleScroll = () => {
17
+ if (window.scrollY > 50) {
18
+ setIsScrolled(true);
19
+ } else {
20
+ setIsScrolled(false);
21
+ }
22
+ };
23
+
24
+ window.addEventListener('scroll', handleScroll);
25
+ return () => {
26
+ window.removeEventListener('scroll', handleScroll);
27
+ };
28
+ }, []);
29
+
30
+ // Focus the search input when it becomes visible
31
+ useEffect(() => {
32
+ if (searchVisible && searchInputRef.current) {
33
+ setTimeout(() => {
34
+ searchInputRef.current?.focus();
35
+ }, 200);
36
+ }
37
+ }, [searchVisible]);
38
+
39
+ const toggleMenu = () => {
40
+ setIsMenuOpen(!isMenuOpen);
41
+ };
42
+
43
+ const toggleSearch = () => {
44
+ setSearchVisible(!searchVisible);
45
+ };
46
+
47
+ const handleSearchSubmit = (e: React.FormEvent) => {
48
+ e.preventDefault();
49
+ if (searchTerm.trim()) {
50
+ navigate(`/search?q=${encodeURIComponent(searchTerm)}`);
51
+ setSearchVisible(false);
52
+ setSearchTerm('');
53
+ }
54
+ };
55
+
56
+ return (
57
+ <nav
58
+ className={`fixed top-0 left-0 w-full z-50 transition-all duration-300 px-4 sm:px-6 ${
59
+ isScrolled ? 'bg-theme-background-dark shadow-lg py-2' : 'bg-gradient-to-b from-black/80 to-transparent py-4'
60
+ }`}
61
+ >
62
+ <div className="flex items-center justify-between">
63
+ {/* Logo */}
64
+ <Link to="/" className="flex items-center">
65
+ <h1 className="text-theme-primary text-2xl sm:text-3xl font-bold">NEXORA</h1>
66
+ </Link>
67
+
68
+ {/* Desktop Nav Links */}
69
+ <div className="hidden md:flex items-center space-x-6">
70
+ <Link to="/" className="text-white hover:text-theme-primary transition-colors">Home</Link>
71
+ <Link to="/movies" className="text-white hover:text-theme-primary transition-colors">Movies</Link>
72
+ <Link to="/tv-shows" className="text-white hover:text-theme-primary transition-colors">TV Shows</Link>
73
+ <Link to="/my-list" className="text-white hover:text-theme-primary transition-colors">My List</Link>
74
+ </div>
75
+
76
+ {/* Right side icons */}
77
+ <div className="flex items-center space-x-4">
78
+ {/* Search bar with animation */}
79
+ <div className="relative flex items-center">
80
+ <AnimatePresence>
81
+ {searchVisible ? (
82
+ <motion.form
83
+ onSubmit={handleSearchSubmit}
84
+ className="flex items-center"
85
+ initial={{ width: 0, opacity: 0 }}
86
+ animate={{ width: 200, opacity: 1 }}
87
+ exit={{ width: 0, opacity: 0 }}
88
+ transition={{ duration: 0.2 }}
89
+ >
90
+ <input
91
+ ref={searchInputRef}
92
+ type="text"
93
+ placeholder="Titles, people, genres"
94
+ className="bg-theme-background-dark border border-theme-border rounded px-3 py-1
95
+ focus:outline-none focus:border-theme-primary text-white w-full"
96
+ value={searchTerm}
97
+ onChange={(e) => setSearchTerm(e.target.value)}
98
+ />
99
+ <button
100
+ type="button"
101
+ onClick={toggleSearch}
102
+ className="ml-2 text-white hover:text-theme-primary"
103
+ >
104
+ <X className="w-5 h-5" />
105
+ </button>
106
+ </motion.form>
107
+ ) : (
108
+ <button
109
+ onClick={toggleSearch}
110
+ className="hover:text-theme-primary transition-colors"
111
+ >
112
+ <Search className="w-5 h-5" />
113
+ </button>
114
+ )}
115
+ </AnimatePresence>
116
+ </div>
117
+
118
+ <button className="hover:text-theme-primary transition-colors hidden sm:block">
119
+ <Bell className="w-5 h-5" />
120
+ </button>
121
+
122
+ <Link to="/profile" className="hover:text-theme-primary transition-colors hidden sm:block">
123
+ <User className="w-5 h-5" />
124
+ </Link>
125
+
126
+ {/* Mobile menu button */}
127
+ <button onClick={toggleMenu} className="md:hidden hover:text-theme-primary">
128
+ <Menu className="w-6 h-6" />
129
+ </button>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Mobile menu */}
134
+ <AnimatePresence>
135
+ {isMenuOpen && (
136
+ <motion.div
137
+ className="md:hidden absolute top-full left-0 right-0 bg-theme-background-dark/95 border-t border-theme-border"
138
+ initial={{ opacity: 0, height: 0 }}
139
+ animate={{ opacity: 1, height: 'auto' }}
140
+ exit={{ opacity: 0, height: 0 }}
141
+ transition={{ duration: 0.3 }}
142
+ >
143
+ <div className="flex flex-col p-4 space-y-3">
144
+ <Link to="/" className="text-white py-2 hover:text-theme-primary">Home</Link>
145
+ <Link to="/movies" className="text-white py-2 hover:text-theme-primary">Movies</Link>
146
+ <Link to="/tv-shows" className="text-white py-2 hover:text-theme-primary">TV Shows</Link>
147
+ <Link to="/my-list" className="text-white py-2 hover:text-theme-primary">My List</Link>
148
+ <Link to="/profile" className="text-white py-2 hover:text-theme-primary">Profile</Link>
149
+ </div>
150
+ </motion.div>
151
+ )}
152
+ </AnimatePresence>
153
+ </nav>
154
+ );
155
+ };
156
+
157
+ export default Navbar;
frontend/src/components/PageHeader.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+
4
+ interface PageHeaderProps {
5
+ title: string;
6
+ subtitle?: string;
7
+ }
8
+
9
+ const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle }) => {
10
+ return (
11
+ <div className="pt-24 pb-8 px-4 md:px-8">
12
+ <h1 className="text-3xl md:text-4xl font-bold">{title}</h1>
13
+ {subtitle && <p className="text-netflix-gray mt-2 text-lg">{subtitle}</p>}
14
+ </div>
15
+ );
16
+ };
17
+
18
+ export default PageHeader;
frontend/src/components/TVShowPlayer.tsx ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState, useRef } from 'react';
2
+ import { getEpisodeLinkByTitle, getTvShowCard } from '../lib/api';
3
+ import { useToast } from '@/hooks/use-toast';
4
+ import { Film, GalleryVerticalEnd, Loader2, Play } from 'lucide-react';
5
+ import VideoPlayer from './VideoPlayer';
6
+
7
+ interface ProgressData {
8
+ status: string;
9
+ progress: number;
10
+ downloaded: number;
11
+ total: number;
12
+ }
13
+
14
+ interface ContentRating {
15
+ country: string;
16
+ name: string;
17
+ description: string;
18
+ }
19
+
20
+ interface TVShowPlayerProps {
21
+ videoTitle: string;
22
+ season: string;
23
+ episode: string;
24
+ movieTitle: string;
25
+ contentRatings?: ContentRating[];
26
+ thumbnail?: string;
27
+ poster?: string;
28
+ startTime?: number;
29
+ onClosePlayer?: () => void;
30
+ onProgressUpdate?: (currentTime: number, duration: number) => void;
31
+ onVideoEnded?: () => void;
32
+ onShowEpisodes?: () => void;
33
+ }
34
+
35
+ const TVShowPlayer: React.FC<TVShowPlayerProps> = ({
36
+ videoTitle,
37
+ season,
38
+ episode,
39
+ movieTitle,
40
+ contentRatings,
41
+ thumbnail,
42
+ poster,
43
+ startTime = 0,
44
+ onClosePlayer,
45
+ onProgressUpdate,
46
+ onVideoEnded,
47
+ onShowEpisodes
48
+ }) => {
49
+ const [videoUrl, setVideoUrl] = useState<string | null>(null);
50
+ const [loading, setLoading] = useState(true);
51
+ const [error, setError] = useState<string | null>(null);
52
+ const [progress, setProgress] = useState<ProgressData | null>(null);
53
+ const [videoFetched, setVideoFetched] = useState(false);
54
+ const { toast } = useToast();
55
+
56
+ const pollingInterval = useRef<NodeJS.Timeout | null>(null);
57
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
58
+ const videoFetchedRef = useRef(false);
59
+ const [ratingInfo, setRatingInfo] = useState<{ rating: string, description: string } | null>(null);
60
+ const containerRef = useRef<HTMLDivElement>(null);
61
+ const [isFullscreen, setIsFullscreen] = useState(false);
62
+ const videoRef = useRef<HTMLVideoElement>(null);
63
+
64
+ // Parse episode info
65
+ const getEpisodeInfo = () => {
66
+ if (!episode) return { number: '1', title: 'Unknown Episode' };
67
+
68
+ const episodeMatch = episode.match(/E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i);
69
+ const number = episodeMatch ? episodeMatch[1] : '1';
70
+ const title = episodeMatch ? episodeMatch[2].trim() : 'Unknown Episode';
71
+
72
+ return { number, title };
73
+ };
74
+
75
+ const { number: episodeNumber, title: episodeTitle } = getEpisodeInfo();
76
+
77
+ // --- Link Fetching & Polling ---
78
+ const fetchMovieLink = async () => {
79
+ if (videoFetchedRef.current) return;
80
+
81
+ try {
82
+ const response = await getEpisodeLinkByTitle(videoTitle, season, episode);
83
+
84
+ if (response && response.url) {
85
+ // Stop any polling if running
86
+ if (pollingInterval.current) {
87
+ clearInterval(pollingInterval.current);
88
+ pollingInterval.current = null;
89
+ }
90
+
91
+ setVideoUrl(response.url);
92
+ setVideoFetched(true);
93
+ videoFetchedRef.current = true;
94
+ setLoading(false);
95
+ console.log('Video URL fetched:', response.url);
96
+ } else if (response && response.progress_url) {
97
+ startPolling(response.progress_url);
98
+ } else {
99
+ console.error('No video URL or progress URL found in response:', response);
100
+ setError('Video URL not available');
101
+ }
102
+ } catch (error) {
103
+ console.error('Error fetching episode link:', error);
104
+ setError('Failed to load episode');
105
+ toast({
106
+ title: "Error",
107
+ description: "Could not load the episode",
108
+ variant: "destructive"
109
+ });
110
+ } finally {
111
+ if (!videoFetchedRef.current && !videoUrl) {
112
+ setLoading(false);
113
+ }
114
+ }
115
+ };
116
+
117
+ // Fetch content ratings if not provided
118
+ useEffect(() => {
119
+ const fetchRatingInfo = async () => {
120
+ if (contentRatings && contentRatings.length > 0) {
121
+ const usRating = contentRatings.find(r => r.country === 'usa') || contentRatings[0];
122
+ setRatingInfo({
123
+ rating: usRating.name || 'NR',
124
+ description: usRating.description || ''
125
+ });
126
+ return;
127
+ }
128
+
129
+ try {
130
+ const showData = await getTvShowCard(videoTitle);
131
+ if (showData && showData.data && showData.data.contentRatings) {
132
+ const ratings = showData.data.contentRatings;
133
+ const usRating = ratings.find((r: any) => r.country === 'US') || ratings[0];
134
+ setRatingInfo({
135
+ rating: usRating?.name || 'TV-14',
136
+ description: usRating?.description || ''
137
+ });
138
+ }
139
+ } catch (error) {
140
+ console.error('Failed to fetch show ratings:', error);
141
+ }
142
+ };
143
+
144
+ fetchRatingInfo();
145
+ }, [videoTitle, contentRatings]);
146
+
147
+ const pollProgress = async (progressUrl: string) => {
148
+ try {
149
+ const res = await fetch(progressUrl);
150
+ const data = await res.json();
151
+
152
+ setProgress(data.progress);
153
+
154
+ if (data.progress.progress >= 100) {
155
+ if (pollingInterval.current) {
156
+ clearInterval(pollingInterval.current);
157
+ pollingInterval.current = null;
158
+ }
159
+
160
+ if (!videoFetchedRef.current) {
161
+ timeoutRef.current = setTimeout(fetchMovieLink, 5000);
162
+ }
163
+ }
164
+ } catch (error) {
165
+ console.error('Error polling progress:', error);
166
+ }
167
+ };
168
+
169
+ const startPolling = (progressUrl: string) => {
170
+ if (!pollingInterval.current) {
171
+ const interval = setInterval(() => pollProgress(progressUrl), 2000);
172
+ pollingInterval.current = interval;
173
+ }
174
+ };
175
+
176
+ useEffect(() => {
177
+ if (!videoTitle || !season || !episode) {
178
+ setError('Missing required video information');
179
+ setLoading(false);
180
+ return;
181
+ }
182
+
183
+ // Reset state for new episode
184
+ setVideoUrl(null);
185
+ setVideoFetched(false);
186
+ videoFetchedRef.current = false;
187
+ setLoading(true);
188
+ setProgress(null);
189
+ setError(null);
190
+
191
+ // Start fetching
192
+ fetchMovieLink();
193
+
194
+ return () => {
195
+ if (pollingInterval.current) clearInterval(pollingInterval.current);
196
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
197
+ };
198
+ }, [videoTitle, season, episode]);
199
+
200
+ // Add effect to update loading state when videoUrl changes
201
+ useEffect(() => {
202
+ if (videoUrl) {
203
+ setLoading(false);
204
+ }
205
+ }, [videoUrl]);
206
+
207
+ if (error) {
208
+ return (
209
+ <div className="flex flex-col items-center justify-center min-h-screen bg-black text-white">
210
+ <div className="text-4xl mb-4 text-theme-error">😢</div>
211
+ <h2 className="text-2xl font-bold mb-2">Error Playing Episode</h2>
212
+ <p className="text-gray-400 mb-6">{error}</p>
213
+ <button
214
+ onClick={onClosePlayer}
215
+ className="px-6 py-2 bg-theme-primary hover:bg-theme-primary-hover rounded font-medium transition-colors"
216
+ >
217
+ Back to Show
218
+ </button>
219
+ </div>
220
+ );
221
+ }
222
+
223
+ if (loading || !videoFetched || !videoUrl) {
224
+ return (
225
+ <div className="fixed inset-0 z-50 bg-gradient-to-br from-theme-background-dark to-black flex flex-col items-center justify-center">
226
+ <div className="text-center max-w-md px-6">
227
+ <div className="mb-6 flex justify-center">
228
+ {poster ? (
229
+ <img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" />
230
+ ) : (
231
+ <div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg">
232
+ <Play className="h-12 w-12 text-theme-primary" />
233
+ </div>
234
+ )}
235
+ </div>
236
+
237
+ <h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
238
+ {progress && progress.progress < 100
239
+ ? `Preparing "${episodeTitle}"`
240
+ : `Loading "${episodeTitle}"`
241
+ }
242
+ </h2>
243
+
244
+ {progress ? (
245
+ <>
246
+ <p className="text-gray-300 mb-4">
247
+ {progress.progress < 5
248
+ ? 'Initializing your stream...'
249
+ : progress.progress < 100
250
+ ? 'Your stream is being prepared.'
251
+ : 'Almost ready! Starting playback soon...'}
252
+ </p>
253
+ <div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
254
+ <div
255
+ className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300"
256
+ style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }}
257
+ />
258
+ </div>
259
+ <p className="text-sm text-gray-400">
260
+ {Math.round(progress.progress)}% complete
261
+ </p>
262
+ </>
263
+ ) : (
264
+ <div className="flex justify-center">
265
+ <Loader2 className="h-8 w-8 animate-spin text-theme-primary" />
266
+ </div>
267
+ )}
268
+ </div>
269
+ </div>
270
+ );
271
+ }
272
+
273
+ // TV Show specific overlay elements that will be passed to VideoPlayer
274
+ const tvShowOverlay = (
275
+ <>
276
+ {/* Top info bar */}
277
+ <div className="absolute top-0 left-0 right-0 z-10 flex items-center p-4 bg-gradient-to-b from-black/80 to-transparent">
278
+ <div>
279
+ <div className="flex items-center">
280
+ <Film className="text-primary mr-2" size={20} />
281
+ <span className="text-white text-sm font-medium truncate">
282
+ {videoTitle}
283
+ </span>
284
+ <span className="mx-2 text-gray-400">•</span>
285
+ <span className="text-white text-sm">
286
+ {season} • Episode {episodeNumber}
287
+ </span>
288
+ </div>
289
+ <h1 className="text-white text-lg font-bold">{episodeTitle}</h1>
290
+ </div>
291
+ </div>
292
+
293
+ {/* Episodes button */}
294
+ <div className="absolute top-4 right-16 z-20">
295
+ <button
296
+ onClick={onShowEpisodes}
297
+ className="bg-gray-800/80 hover:bg-gray-700/80 p-2 rounded-full transition-colors"
298
+ title="Show Episodes"
299
+ >
300
+ <GalleryVerticalEnd className="text-white" size={20} />
301
+ </button>
302
+ </div>
303
+ </>
304
+ );
305
+
306
+ return (
307
+ <div ref={containerRef} className="fixed inset-0 w-screen h-screen overflow-hidden">
308
+ <VideoPlayer
309
+ url={videoUrl}
310
+ title={`${videoTitle} - ${season}E${episodeNumber}`}
311
+ poster={poster || thumbnail}
312
+ startTime={startTime}
313
+ onClose={onClosePlayer}
314
+ onProgressUpdate={onProgressUpdate}
315
+ onVideoEnded={onVideoEnded}
316
+ showNextButton={true}
317
+ contentRating={ratingInfo}
318
+ hideTitleInPlayer={true}
319
+ customOverlay={tvShowOverlay}
320
+ containerRef={containerRef}
321
+ videoRef={videoRef}
322
+ />
323
+ </div>
324
+ );
325
+ };
326
+
327
+ export default TVShowPlayer;
frontend/src/components/VideoPlayer.tsx ADDED
@@ -0,0 +1,582 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { ArrowLeft, FastForward, Keyboard, Maximize, Minimize, Pause, Play, Rewind, SkipBack, SkipForward, Volume2, VolumeX, X } from 'lucide-react';
3
+ import { formatTime } from '../lib/utils';
4
+
5
+ interface VideoPlayerProps {
6
+ url: string;
7
+ title?: string;
8
+ poster?: string;
9
+ startTime?: number;
10
+ onClose?: () => void;
11
+ onProgressUpdate?: (currentTime: number, duration: number) => void;
12
+ onVideoEnded?: () => void;
13
+ showNextButton?: boolean;
14
+ contentRating?: { rating: string, description: string } | null;
15
+ hideTitleInPlayer?: boolean;
16
+ showControls?: boolean;
17
+ containerRef?: React.RefObject<HTMLDivElement>;
18
+ videoRef?: React.RefObject<HTMLVideoElement>;
19
+ customOverlay?: React.ReactNode;
20
+ }
21
+
22
+ const VideoPlayer: React.FC<VideoPlayerProps> = ({
23
+ url,
24
+ title,
25
+ poster,
26
+ startTime = 0,
27
+ onClose,
28
+ onProgressUpdate,
29
+ onVideoEnded,
30
+ showNextButton = false,
31
+ contentRating,
32
+ hideTitleInPlayer = false,
33
+ showControls: initialShowControls = true,
34
+ containerRef,
35
+ videoRef: externalVideoRef,
36
+ customOverlay
37
+ }) => {
38
+ const internalVideoRef = useRef<HTMLVideoElement>(null);
39
+ const videoRef = externalVideoRef || internalVideoRef;
40
+
41
+ const [isPlaying, setIsPlaying] = useState(false);
42
+ const [volume, setVolume] = useState(1);
43
+ const [isMuted, setIsMuted] = useState(false);
44
+ const [progress, setProgress] = useState(startTime);
45
+ const [duration, setDuration] = useState(0);
46
+ const [showControls, setShowControls] = useState(initialShowControls);
47
+ const [isFullscreen, setIsFullscreen] = useState(false);
48
+ const [buffered, setBuffered] = useState(0);
49
+ const [showRating, setShowRating] = useState(true);
50
+ const [hoverTime, setHoverTime] = useState<number | null>(null);
51
+ const [hoverPosition, setHoverPosition] = useState<{ x: number, y: number } | null>(null);
52
+ const [showKeyboardControls, setShowKeyboardControls] = useState(false);
53
+ const controlsTimerRef = useRef<NodeJS.Timeout | null>(null);
54
+ const playerContainerRef = useRef<HTMLDivElement>(null);
55
+ const progressBarRef = useRef<HTMLDivElement>(null);
56
+ const ratingTimerRef = useRef<NodeJS.Timeout | null>(null);
57
+
58
+ // Format time manually (in case utils import fails)
59
+ const formatTimeBackup = (time: number): string => {
60
+ const hours = Math.floor(time / 3600);
61
+ const minutes = Math.floor((time % 3600) / 60);
62
+ const seconds = Math.floor(time % 60);
63
+ const minutesStr = minutes.toString().padStart(2, '0');
64
+ const secondsStr = seconds.toString().padStart(2, '0');
65
+ return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`;
66
+ };
67
+ // Hide content rating after a few seconds
68
+ useEffect(() => {
69
+ if (showRating && contentRating) {
70
+ ratingTimerRef.current = setTimeout(() => {
71
+ setShowRating(false);
72
+ }, 8000);
73
+ }
74
+
75
+ return () => {
76
+ if (ratingTimerRef.current) {
77
+ clearTimeout(ratingTimerRef.current);
78
+ }
79
+ };
80
+ }, [showRating, contentRating]);
81
+
82
+ useEffect(() => {
83
+ const videoElement = videoRef.current;
84
+
85
+ if (videoElement) {
86
+ const handleLoadedMetadata = () => {
87
+ setDuration(videoElement.duration);
88
+ videoElement.currentTime = startTime;
89
+ setProgress(startTime);
90
+ };
91
+
92
+ const handleTimeUpdate = () => {
93
+ setProgress(videoElement.currentTime);
94
+ onProgressUpdate?.(videoElement.currentTime, videoElement.duration);
95
+ };
96
+
97
+ const handleEnded = () => {
98
+ setIsPlaying(false);
99
+ onVideoEnded?.();
100
+ };
101
+
102
+ const handleBufferUpdate = () => {
103
+ if (videoElement.buffered.length > 0) {
104
+ setBuffered(videoElement.buffered.end(videoElement.buffered.length - 1));
105
+ }
106
+ };
107
+
108
+ videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
109
+ videoElement.addEventListener('timeupdate', handleTimeUpdate);
110
+ videoElement.addEventListener('ended', handleEnded);
111
+ videoElement.addEventListener('progress', handleBufferUpdate);
112
+
113
+ return () => {
114
+ videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
115
+ videoElement.removeEventListener('timeupdate', handleTimeUpdate);
116
+ videoElement.removeEventListener('ended', handleEnded);
117
+ videoElement.removeEventListener('progress', handleBufferUpdate);
118
+ };
119
+ }
120
+ }, [url, startTime, onProgressUpdate, onVideoEnded, videoRef]);
121
+
122
+ useEffect(() => {
123
+ if (isPlaying) {
124
+ videoRef.current?.play();
125
+ } else {
126
+ videoRef.current?.pause();
127
+ }
128
+ }, [isPlaying, videoRef]);
129
+
130
+ useEffect(() => {
131
+ if (videoRef.current) {
132
+ videoRef.current.volume = isMuted ? 0 : volume;
133
+ }
134
+ }, [volume, isMuted, videoRef]);
135
+
136
+ const hideControlsTimer = () => {
137
+ if (controlsTimerRef.current) {
138
+ clearTimeout(controlsTimerRef.current);
139
+ }
140
+
141
+ controlsTimerRef.current = setTimeout(() => {
142
+ if (isPlaying && !showKeyboardControls) {
143
+ setShowControls(false);
144
+ }
145
+ }, 3000);
146
+ };
147
+
148
+ const handleMouseMove = () => {
149
+ setShowControls(true);
150
+ hideControlsTimer();
151
+ };
152
+
153
+ useEffect(() => {
154
+ hideControlsTimer();
155
+ return () => {
156
+ if (controlsTimerRef.current) {
157
+ clearTimeout(controlsTimerRef.current);
158
+ }
159
+ };
160
+ }, [isPlaying, showKeyboardControls]);
161
+
162
+ // Keyboard controls
163
+ useEffect(() => {
164
+ const handleKeyDown = (e: KeyboardEvent) => {
165
+ switch (e.key) {
166
+ case ' ':
167
+ case 'k':
168
+ e.preventDefault();
169
+ setIsPlaying(prev => !prev);
170
+ setShowControls(true);
171
+ break;
172
+ case 'ArrowRight':
173
+ e.preventDefault();
174
+ skipForward();
175
+ setShowControls(true);
176
+ break;
177
+ case 'ArrowLeft':
178
+ e.preventDefault();
179
+ skipBackward();
180
+ setShowControls(true);
181
+ break;
182
+ case 'f':
183
+ e.preventDefault();
184
+ toggleFullscreen();
185
+ break;
186
+ case 'm':
187
+ e.preventDefault();
188
+ setIsMuted(prev => !prev);
189
+ setShowControls(true);
190
+ break;
191
+ case '?':
192
+ e.preventDefault();
193
+ setShowKeyboardControls(prev => !prev);
194
+ setShowControls(true);
195
+ break;
196
+ case 'Escape':
197
+ if (showKeyboardControls) {
198
+ setShowKeyboardControls(false);
199
+ } else if (isFullscreen) {
200
+ document.exitFullscreen();
201
+ } else if (onClose) {
202
+ onClose();
203
+ }
204
+ break;
205
+ }
206
+ };
207
+
208
+ document.addEventListener('keydown', handleKeyDown);
209
+ return () => document.removeEventListener('keydown', handleKeyDown);
210
+ }, [isFullscreen, onClose, showKeyboardControls]);
211
+
212
+ // Fullscreen handlers
213
+ useEffect(() => {
214
+ const handleFullScreenChange = () => {
215
+ setIsFullscreen(document.fullscreenElement === (containerRef?.current || playerContainerRef.current));
216
+ };
217
+
218
+ document.addEventListener('fullscreenchange', handleFullScreenChange);
219
+ return () => document.removeEventListener('fullscreenchange', handleFullScreenChange);
220
+ }, [containerRef]);
221
+
222
+ const toggleFullscreen = async () => {
223
+ const fullscreenElement = containerRef?.current || playerContainerRef.current;
224
+ if (!fullscreenElement) return;
225
+
226
+ if (!isFullscreen) {
227
+ await fullscreenElement.requestFullscreen();
228
+ } else {
229
+ await document.exitFullscreen();
230
+ }
231
+ };
232
+
233
+ // Player control handlers
234
+ const handlePlayPause = () => {
235
+ setIsPlaying(!isPlaying);
236
+ };
237
+
238
+ const handleMute = () => {
239
+ setIsMuted(!isMuted);
240
+ };
241
+
242
+ const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
243
+ const newVolume = parseFloat(e.target.value);
244
+ setVolume(newVolume);
245
+ if (newVolume === 0) {
246
+ setIsMuted(true);
247
+ } else if (isMuted) {
248
+ setIsMuted(false);
249
+ }
250
+ };
251
+
252
+ const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
253
+ const newTime = parseFloat(e.target.value);
254
+ setProgress(newTime);
255
+ if (videoRef.current) {
256
+ videoRef.current.currentTime = newTime;
257
+ }
258
+ };
259
+
260
+ // Direct progress bar click handler
261
+ const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
262
+ if (!progressBarRef.current || !duration) return;
263
+
264
+ const rect = progressBarRef.current.getBoundingClientRect();
265
+ const clickPosition = (e.clientX - rect.left) / rect.width;
266
+ const newTime = duration * clickPosition;
267
+
268
+ if (videoRef.current) {
269
+ videoRef.current.currentTime = newTime;
270
+ setProgress(newTime);
271
+ }
272
+ };
273
+
274
+ // Progress bar hover handler for time preview
275
+ const handleProgressBarHover = (e: React.MouseEvent<HTMLDivElement>) => {
276
+ if (!progressBarRef.current || !duration) return;
277
+
278
+ const rect = progressBarRef.current.getBoundingClientRect();
279
+ const hoverPosition = (e.clientX - rect.left) / rect.width;
280
+ const hoverTimeValue = duration * hoverPosition;
281
+
282
+ setHoverTime(hoverTimeValue);
283
+ setHoverPosition({ x: e.clientX, y: rect.top });
284
+ };
285
+
286
+ const handleProgressBarLeave = () => {
287
+ setHoverTime(null);
288
+ setHoverPosition(null);
289
+ };
290
+
291
+ // Use the imported formatTime function with a fallback
292
+ const formatTimeDisplay = formatTime || formatTimeBackup;
293
+
294
+ const skipForward = () => {
295
+ if (videoRef.current) {
296
+ videoRef.current.currentTime = Math.min(
297
+ videoRef.current.duration,
298
+ videoRef.current.currentTime + 10
299
+ );
300
+ }
301
+ };
302
+
303
+ const skipBackward = () => {
304
+ if (videoRef.current) {
305
+ videoRef.current.currentTime = Math.max(
306
+ 0,
307
+ videoRef.current.currentTime - 10
308
+ );
309
+ }
310
+ };
311
+
312
+ const toggleKeyboardControls = () => {
313
+ setShowKeyboardControls(prev => !prev);
314
+ setShowControls(true);
315
+ };
316
+
317
+ return (
318
+ <div
319
+ className="w-full h-full overflow-hidden bg-black"
320
+ ref={playerContainerRef}
321
+ onMouseMove={handleMouseMove}
322
+ >
323
+ {/* Content rating overlay - only shown briefly */}
324
+ {contentRating && showRating && (
325
+ <div className="absolute top-16 left-6 z-40 bg-black/60 backdrop-blur-sm px-4 py-2 rounded text-white flex items-center gap-2 animate-fade-in">
326
+ <div className="text-lg font-bold border px-2 py-0.5">
327
+ {contentRating.rating}
328
+ </div>
329
+ <span className='font-extrabold text-2xl text-primary'>|</span>
330
+ <div className="text-sm">
331
+ {contentRating.description}
332
+ </div>
333
+ </div>
334
+ )}
335
+
336
+ <video
337
+ ref={videoRef}
338
+ src={url}
339
+ className="w-full h-full object-contain"
340
+ poster={poster}
341
+ onClick={handlePlayPause}
342
+ playsInline
343
+ />
344
+
345
+ {/* Custom overlay from parent components */}
346
+ {customOverlay}
347
+
348
+ {/* Controls overlay - visible based on state */}
349
+ <div
350
+ className={`absolute inset-0 transition-opacity duration-300 ${showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
351
+ }`}
352
+ >
353
+ {/* Top bar */}
354
+ <div className="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/80 to-transparent z-10">
355
+ <div className="flex justify-between items-center">
356
+ <button
357
+ onClick={onClose}
358
+ className="text-white hover:text-gray-300 transition-colors"
359
+ >
360
+ <ArrowLeft size={24} />
361
+ </button>
362
+ {!hideTitleInPlayer && (
363
+ <h2 className="text-white font-medium text-lg hidden sm:block">
364
+ {title}
365
+ </h2>
366
+ )}
367
+ <button
368
+ onClick={onClose}
369
+ className="text-white hover:text-gray-300 transition-colors"
370
+ >
371
+ <X size={24} />
372
+ </button>
373
+ </div>
374
+ </div>
375
+
376
+ {/* Center controls */}
377
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
378
+ {/* Skip backward 10s */}
379
+ <button
380
+ onClick={skipBackward}
381
+ className="z-10 relative pointer-events-auto text-white hover:text-gray-300 transition-colors p-2 rounded-full hover:bg-black/30 mx-4"
382
+ >
383
+ <Rewind size={32} />
384
+ </button>
385
+
386
+ {/* Play/Pause button */}
387
+ <button
388
+ onClick={handlePlayPause}
389
+ className="text-white bg-black/30 backdrop-blur-sm p-4 rounded-full hover:bg-white/20 transition-all pointer-events-auto relative w-20 h-20 flex items-center justify-center"
390
+ >
391
+ {isPlaying ? (
392
+ <Pause size={40} />
393
+ ) : (
394
+ <Play size={40} />
395
+ )}
396
+ </button>
397
+
398
+ {/* Skip forward 10s */}
399
+ <button
400
+ onClick={skipForward}
401
+ className="z-10 relative pointer-events-auto text-white hover:text-gray-300 transition-colors p-2 rounded-full hover:bg-black/30 mx-4"
402
+ >
403
+ <FastForward size={32} />
404
+ </button>
405
+ </div>
406
+
407
+ {/* Bottom controls */}
408
+ <div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 to-transparent">
409
+ {/* Progress bar */}
410
+ <div className="mb-3 relative">
411
+ <div
412
+ ref={progressBarRef}
413
+ className="relative w-full h-2 bg-white/30 rounded-full group cursor-pointer"
414
+ onClick={handleProgressBarClick}
415
+ onMouseMove={handleProgressBarHover}
416
+ onMouseLeave={handleProgressBarLeave}
417
+ >
418
+ {/* Buffered progress */}
419
+ <div
420
+ className="absolute h-full bg-white/50 rounded-full"
421
+ style={{ width: `${(buffered / duration) * 100}%` }}
422
+ ></div>
423
+
424
+ {/* Played progress */}
425
+ <div
426
+ className="absolute h-full bg-primary rounded-full"
427
+ style={{ width: `${(progress / duration) * 100}%` }}
428
+ >
429
+ {/* Thumb */}
430
+ <div className="absolute right-0 top-1/2 transform -translate-y-1/2 w-4 h-4 bg-primary rounded-full scale-0 group-hover:scale-100 transition-transform"></div>
431
+ </div>
432
+
433
+ {/* Time preview tooltip */}
434
+ {hoverTime !== null && hoverPosition && (
435
+ <div
436
+ className="absolute -top-8 bg-black/80 px-2 py-1 rounded text-white text-xs transform -translate-x-1/2 pointer-events-none"
437
+ style={{ left: `${(hoverTime / duration) * 100}%` }}
438
+ >
439
+ {formatTimeDisplay(hoverTime)}
440
+ </div>
441
+ )}
442
+
443
+ {/* Invisible range input for seeking */}
444
+ <input
445
+ type="range"
446
+ min={0}
447
+ max={duration || 100}
448
+ value={progress}
449
+ onChange={handleProgressChange}
450
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
451
+ style={{ padding: 0, margin: 0 }}
452
+ />
453
+ </div>
454
+
455
+ <div className="flex justify-between text-xs text-white mt-1">
456
+ <span>{formatTimeDisplay(progress)}</span>
457
+ <span>{formatTimeDisplay(duration)}</span>
458
+ </div>
459
+ </div>
460
+
461
+ {/* Controls row */}
462
+ <div className="flex justify-between items-center">
463
+ <div className="flex items-center space-x-4">
464
+ <button
465
+ onClick={handlePlayPause}
466
+ className="text-white hover:text-gray-300 transition-colors"
467
+ >
468
+ {isPlaying ? <Pause size={24} /> : <Play size={24} />}
469
+ </button>
470
+
471
+ <div className="flex items-center relative group">
472
+ <button
473
+ onClick={handleMute}
474
+ className="text-white hover:text-gray-300 transition-colors"
475
+ >
476
+ {isMuted || volume === 0 ? <VolumeX size={24} /> : <Volume2 size={24} />}
477
+ </button>
478
+
479
+ <div className="hidden group-hover:block w-20 ml-2">
480
+ <input
481
+ type="range"
482
+ min={0}
483
+ max={1}
484
+ step={0.1}
485
+ value={volume}
486
+ onChange={handleVolumeChange}
487
+ className="w-full h-1 bg-gray-700/50 appearance-none rounded cursor-pointer accent-primary"
488
+ />
489
+ </div>
490
+ </div>
491
+
492
+ <button
493
+ onClick={toggleKeyboardControls}
494
+ className="text-white hover:text-gray-300 transition-colors"
495
+ title="Show keyboard shortcuts"
496
+ >
497
+ <Keyboard size={20} />
498
+ </button>
499
+ </div>
500
+
501
+ <div className="flex items-center space-x-4">
502
+ {!hideTitleInPlayer && title && (
503
+ <div className="text-white text-sm hidden sm:block">
504
+ <span>{title}</span>
505
+ </div>
506
+ )}
507
+
508
+ <button
509
+ onClick={toggleFullscreen}
510
+ className="text-white hover:text-gray-300 transition-colors"
511
+ >
512
+ {isFullscreen ? <Minimize size={24} /> : <Maximize size={24} />}
513
+ </button>
514
+
515
+ {showNextButton && (
516
+ <button
517
+ onClick={onVideoEnded}
518
+ className="text-white hover:text-gray-300 transition-colors"
519
+ >
520
+ <SkipForward size={24} />
521
+ </button>
522
+ )}
523
+ </div>
524
+ </div>
525
+ </div>
526
+ </div>
527
+
528
+ {/* Keyboard controls dialog - shown only when requested */}
529
+ {showKeyboardControls && (
530
+ <div className="absolute inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50" onClick={() => setShowKeyboardControls(false)}>
531
+ <div className="bg-gray-900/90 border border-gray-700 rounded-lg max-w-md w-full p-6" onClick={e => e.stopPropagation()}>
532
+ <div className="flex justify-between items-center mb-4">
533
+ <h3 className="text-xl font-bold text-white">Keyboard Controls</h3>
534
+ <button onClick={() => setShowKeyboardControls(false)} className="text-gray-400 hover:text-white">
535
+ <X size={20} />
536
+ </button>
537
+ </div>
538
+
539
+ <div className="grid grid-cols-2 gap-y-3 text-sm">
540
+ <div className="flex items-center">
541
+ <kbd className="px-2 py-1 bg-gray-800 rounded mr-2">Space</kbd> or <kbd className="px-2 py-1 bg-gray-800 rounded mx-2">K</kbd>
542
+ </div>
543
+ <div>Play/Pause</div>
544
+
545
+ <div className="flex items-center">
546
+ <kbd className="px-2 py-1 bg-gray-800 rounded mr-2">←</kbd>
547
+ </div>
548
+ <div>Rewind 10 seconds</div>
549
+
550
+ <div className="flex items-center">
551
+ <kbd className="px-2 py-1 bg-gray-800 rounded mr-2">→</kbd>
552
+ </div>
553
+ <div>Forward 10 seconds</div>
554
+
555
+ <div className="flex items-center">
556
+ <kbd className="px-2 py-1 bg-gray-800 rounded mr-2">M</kbd>
557
+ </div>
558
+ <div>Mute/Unmute</div>
559
+
560
+ <div className="flex items-center">
561
+ <kbd className="px-2 py-1 bg-gray-800 rounded mr-2">F</kbd>
562
+ </div>
563
+ <div>Fullscreen</div>
564
+
565
+ <div className="flex items-center">
566
+ <kbd className="px-2 py-1 bg-gray-800 rounded mr-2">Esc</kbd>
567
+ </div>
568
+ <div>Exit fullscreen/Close player</div>
569
+
570
+ <div className="flex items-center">
571
+ <kbd className="px-2 py-1 bg-gray-800 rounded mr-2">?</kbd>
572
+ </div>
573
+ <div>Show/hide this menu</div>
574
+ </div>
575
+ </div>
576
+ </div>
577
+ )}
578
+ </div>
579
+ );
580
+ };
581
+
582
+ export default VideoPlayer;
frontend/src/components/VideoPlayerControls.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState } from 'react';
3
+ import WatchTogether from './WatchTogether';
4
+
5
+ interface VideoPlayerControlsProps {
6
+ title: string;
7
+ currentTime: number;
8
+ duration: number;
9
+ onSeek?: (time: number) => void;
10
+ showWatchTogether?: boolean;
11
+ }
12
+
13
+ const VideoPlayerControls: React.FC<VideoPlayerControlsProps> = ({
14
+ title,
15
+ currentTime,
16
+ duration,
17
+ onSeek,
18
+ showWatchTogether = true
19
+ }) => {
20
+ return (
21
+ <>
22
+ {showWatchTogether && (
23
+ <WatchTogether
24
+ title={title}
25
+ currentTime={currentTime}
26
+ duration={duration}
27
+ onSeek={onSeek}
28
+ />
29
+ )}
30
+ </>
31
+ );
32
+ };
33
+
34
+ export default VideoPlayerControls;
frontend/src/components/WatchTogether.tsx ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useEffect } from 'react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Users, Link, Copy, CheckCircle, Send } from 'lucide-react';
7
+ import { useToast } from '@/hooks/use-toast';
8
+
9
+ interface WatchTogetherProps {
10
+ title: string;
11
+ currentTime: number;
12
+ duration: number;
13
+ onSeek?: (time: number) => void;
14
+ }
15
+
16
+ interface Message {
17
+ id: string;
18
+ name: string;
19
+ text: string;
20
+ timestamp: number;
21
+ type: 'chat' | 'system' | 'timestamp';
22
+ }
23
+
24
+ const WatchTogether: React.FC<WatchTogetherProps> = ({ title, currentTime, duration, onSeek }) => {
25
+ const [isOpen, setIsOpen] = useState(false);
26
+ const [roomId, setRoomId] = useState<string>('');
27
+ const [userName, setUserName] = useState<string>('');
28
+ const [message, setMessage] = useState<string>('');
29
+ const [messages, setMessages] = useState<Message[]>([]);
30
+ const [userCount, setUserCount] = useState(1);
31
+ const [isHost, setIsHost] = useState(true);
32
+ const [linkCopied, setLinkCopied] = useState(false);
33
+
34
+ const { toast } = useToast();
35
+
36
+ // Generate room ID on mount
37
+ useEffect(() => {
38
+ const id = `room-${Math.random().toString(36).substring(2, 8)}`;
39
+ setRoomId(id);
40
+
41
+ // If no username set, use a default
42
+ if (!userName) {
43
+ setUserName(`User${Math.floor(Math.random() * 10000)}`);
44
+ }
45
+
46
+ // Initial system message
47
+ addSystemMessage(`Watch Party started for "${title}"`);
48
+ }, [title]);
49
+
50
+ // Function to add system message
51
+ const addSystemMessage = (text: string) => {
52
+ const newMessage: Message = {
53
+ id: `sys-${Date.now()}`,
54
+ name: 'System',
55
+ text,
56
+ timestamp: Date.now(),
57
+ type: 'system'
58
+ };
59
+ setMessages(prev => [...prev, newMessage]);
60
+ };
61
+
62
+ // Function to add user message
63
+ const addUserMessage = () => {
64
+ if (!message.trim()) return;
65
+
66
+ // If message starts with '/seek ', treat as seek command
67
+ if (message.startsWith('/seek ')) {
68
+ const seekTime = parseInt(message.replace('/seek ', ''));
69
+ if (!isNaN(seekTime) && seekTime >= 0 && seekTime <= duration) {
70
+ handleSeek(seekTime);
71
+ setMessage('');
72
+ return;
73
+ }
74
+ }
75
+
76
+ // Regular message
77
+ const newMessage: Message = {
78
+ id: `msg-${Date.now()}`,
79
+ name: userName,
80
+ text: message,
81
+ timestamp: Date.now(),
82
+ type: 'chat'
83
+ };
84
+
85
+ setMessages(prev => [...prev, newMessage]);
86
+ setMessage('');
87
+ };
88
+
89
+ // Function to handle seeking
90
+ const handleSeek = (time: number) => {
91
+ if (onSeek) {
92
+ onSeek(time);
93
+
94
+ // Add timestamp message
95
+ const newMessage: Message = {
96
+ id: `time-${Date.now()}`,
97
+ name: userName,
98
+ text: `Seeked to ${formatTime(time)}`,
99
+ timestamp: Date.now(),
100
+ type: 'timestamp'
101
+ };
102
+ setMessages(prev => [...prev, newMessage]);
103
+ }
104
+ };
105
+
106
+ // Function to share current timestamp
107
+ const shareCurrentTime = () => {
108
+ const newMessage: Message = {
109
+ id: `time-${Date.now()}`,
110
+ name: userName,
111
+ text: `Current position: ${formatTime(currentTime)}`,
112
+ timestamp: Date.now(),
113
+ type: 'timestamp'
114
+ };
115
+ setMessages(prev => [...prev, newMessage]);
116
+ };
117
+
118
+ // Function to format time
119
+ const formatTime = (timeInSeconds: number) => {
120
+ const minutes = Math.floor(timeInSeconds / 60);
121
+ const seconds = Math.floor(timeInSeconds % 60);
122
+ return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
123
+ };
124
+
125
+ // Function to copy invite link
126
+ const copyInviteLink = () => {
127
+ const inviteLink = `${window.location.href}?room=${roomId}&host=false`;
128
+ navigator.clipboard.writeText(inviteLink);
129
+ setLinkCopied(true);
130
+
131
+ toast({
132
+ title: "Link Copied",
133
+ description: "Share this link with friends to watch together",
134
+ });
135
+
136
+ setTimeout(() => setLinkCopied(false), 2000);
137
+ };
138
+
139
+ // Simulate someone joining after a delay
140
+ useEffect(() => {
141
+ if (isOpen && isHost) {
142
+ const timer = setTimeout(() => {
143
+ setUserCount(2);
144
+ addSystemMessage("Alice has joined the watch party");
145
+ }, 5000);
146
+
147
+ return () => clearTimeout(timer);
148
+ }
149
+ }, [isOpen, isHost]);
150
+
151
+ // Add mock message after some delays
152
+ useEffect(() => {
153
+ if (isOpen && userCount > 1) {
154
+ const timer1 = setTimeout(() => {
155
+ setMessages(prev => [
156
+ ...prev,
157
+ {
158
+ id: `msg-alice-1`,
159
+ name: "Alice",
160
+ text: "Hey, thanks for inviting me!",
161
+ timestamp: Date.now(),
162
+ type: 'chat'
163
+ }
164
+ ]);
165
+ }, 3000);
166
+
167
+ const timer2 = setTimeout(() => {
168
+ setMessages(prev => [
169
+ ...prev,
170
+ {
171
+ id: `msg-alice-2`,
172
+ name: "Alice",
173
+ text: "I love this part coming up!",
174
+ timestamp: Date.now(),
175
+ type: 'chat'
176
+ }
177
+ ]);
178
+ }, 15000);
179
+
180
+ return () => {
181
+ clearTimeout(timer1);
182
+ clearTimeout(timer2);
183
+ };
184
+ }
185
+ }, [isOpen, userCount]);
186
+
187
+ return (
188
+ <Dialog open={isOpen} onOpenChange={setIsOpen}>
189
+ <DialogTrigger asChild>
190
+ <Button
191
+ variant="outline"
192
+ size="sm"
193
+ className="fixed top-4 right-36 z-50 bg-gray-800/80 hover:bg-gray-700/80 text-white border-gray-600"
194
+ onClick={() => setIsOpen(true)}
195
+ >
196
+ <Users className="mr-2 h-4 w-4" />
197
+ Watch Together
198
+ </Button>
199
+ </DialogTrigger>
200
+
201
+ <DialogContent className="sm:max-w-[425px] bg-gray-900 text-white border-gray-700">
202
+ <DialogHeader>
203
+ <DialogTitle>Watch Together</DialogTitle>
204
+ </DialogHeader>
205
+
206
+ <div className="flex items-center justify-between py-2 px-4 bg-gray-800 rounded-lg">
207
+ <div className="flex items-center">
208
+ <Users className="h-5 w-5 mr-2 text-theme-primary" />
209
+ <span>{userCount} {userCount === 1 ? 'viewer' : 'viewers'}</span>
210
+ </div>
211
+
212
+ <div className="flex items-center space-x-2">
213
+ <Link className="h-4 w-4 text-gray-400" />
214
+ <button
215
+ onClick={copyInviteLink}
216
+ className="text-sm text-theme-primary hover:text-theme-primary-light flex items-center"
217
+ >
218
+ {linkCopied ? (
219
+ <><CheckCircle className="h-4 w-4 mr-1" /> Copied</>
220
+ ) : (
221
+ <><Copy className="h-4 w-4 mr-1" /> Copy Invite Link</>
222
+ )}
223
+ </button>
224
+ </div>
225
+ </div>
226
+
227
+ {/* Chat messages */}
228
+ <div className="flex flex-col space-y-4 h-[250px] overflow-y-auto py-2 px-1">
229
+ {messages.map((msg) => (
230
+ <div
231
+ key={msg.id}
232
+ className={`flex flex-col ${msg.name === userName ? 'items-end' : 'items-start'}`}
233
+ >
234
+ {msg.type === 'system' ? (
235
+ <div className="bg-gray-800/50 text-gray-300 py-1 px-3 rounded-md text-xs w-full text-center">
236
+ {msg.text}
237
+ </div>
238
+ ) : msg.type === 'timestamp' ? (
239
+ <div
240
+ className={`bg-theme-primary/20 text-theme-primary py-1 px-3 rounded-md text-xs cursor-pointer hover:bg-theme-primary/30 ${
241
+ msg.name === userName ? 'self-end' : 'self-start'
242
+ }`}
243
+ onClick={() => {
244
+ const timeMatch = msg.text.match(/(\d+):(\d+)/);
245
+ if (timeMatch) {
246
+ const minutes = parseInt(timeMatch[1]);
247
+ const seconds = parseInt(timeMatch[2]);
248
+ const totalSeconds = minutes * 60 + seconds;
249
+ onSeek?.(totalSeconds);
250
+ }
251
+ }}
252
+ >
253
+ {msg.text}
254
+ </div>
255
+ ) : (
256
+ <>
257
+ <span className="text-xs text-gray-400 mb-1">
258
+ {msg.name === userName ? 'You' : msg.name}
259
+ </span>
260
+ <div
261
+ className={`py-2 px-3 rounded-lg max-w-[80%] ${
262
+ msg.name === userName
263
+ ? 'bg-theme-primary text-white'
264
+ : 'bg-gray-800 text-gray-200'
265
+ }`}
266
+ >
267
+ <p className="text-sm">{msg.text}</p>
268
+ </div>
269
+ </>
270
+ )}
271
+ </div>
272
+ ))}
273
+ </div>
274
+
275
+ {/* Share current timestamp button */}
276
+ <button
277
+ onClick={shareCurrentTime}
278
+ className="text-sm text-theme-primary hover:text-theme-primary-light flex items-center self-center"
279
+ >
280
+ Share current timestamp ({formatTime(currentTime)})
281
+ </button>
282
+
283
+ {/* Chat input */}
284
+ <div className="flex space-x-2 mt-2">
285
+ <Input
286
+ placeholder="Type a message..."
287
+ value={message}
288
+ onChange={(e) => setMessage(e.target.value)}
289
+ className="bg-gray-800 border-gray-700 text-white"
290
+ onKeyDown={(e) => {
291
+ if (e.key === 'Enter' && !e.shiftKey) {
292
+ e.preventDefault();
293
+ addUserMessage();
294
+ }
295
+ }}
296
+ />
297
+ <Button
298
+ size="icon"
299
+ onClick={addUserMessage}
300
+ className="bg-theme-primary hover:bg-theme-primary-hover"
301
+ >
302
+ <Send className="h-4 w-4" />
303
+ </Button>
304
+ </div>
305
+
306
+ <p className="text-xs text-gray-400 mt-2">
307
+ Pro tip: Type '/seek 10' to jump to 10 seconds, or click on any shared timestamp to seek.
308
+ </p>
309
+ </DialogContent>
310
+ </Dialog>
311
+ );
312
+ };
313
+
314
+ export default WatchTogether;
frontend/src/components/ui/accordion.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
3
+ import { ChevronDown } from "lucide-react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const Accordion = AccordionPrimitive.Root
8
+
9
+ const AccordionItem = React.forwardRef<
10
+ React.ElementRef<typeof AccordionPrimitive.Item>,
11
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
12
+ >(({ className, ...props }, ref) => (
13
+ <AccordionPrimitive.Item
14
+ ref={ref}
15
+ className={cn("border-b", className)}
16
+ {...props}
17
+ />
18
+ ))
19
+ AccordionItem.displayName = "AccordionItem"
20
+
21
+ const AccordionTrigger = React.forwardRef<
22
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
23
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
24
+ >(({ className, children, ...props }, ref) => (
25
+ <AccordionPrimitive.Header className="flex">
26
+ <AccordionPrimitive.Trigger
27
+ ref={ref}
28
+ className={cn(
29
+ "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
30
+ className
31
+ )}
32
+ {...props}
33
+ >
34
+ {children}
35
+ <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
36
+ </AccordionPrimitive.Trigger>
37
+ </AccordionPrimitive.Header>
38
+ ))
39
+ AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
40
+
41
+ const AccordionContent = React.forwardRef<
42
+ React.ElementRef<typeof AccordionPrimitive.Content>,
43
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
44
+ >(({ className, children, ...props }, ref) => (
45
+ <AccordionPrimitive.Content
46
+ ref={ref}
47
+ className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
48
+ {...props}
49
+ >
50
+ <div className={cn("pb-4 pt-0", className)}>{children}</div>
51
+ </AccordionPrimitive.Content>
52
+ ))
53
+
54
+ AccordionContent.displayName = AccordionPrimitive.Content.displayName
55
+
56
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
frontend/src/components/ui/alert-dialog.tsx ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3
+
4
+ import { cn } from "@/lib/utils"
5
+ import { buttonVariants } from "@/components/ui/button"
6
+
7
+ const AlertDialog = AlertDialogPrimitive.Root
8
+
9
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
10
+
11
+ const AlertDialogPortal = AlertDialogPrimitive.Portal
12
+
13
+ const AlertDialogOverlay = React.forwardRef<
14
+ React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
15
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
16
+ >(({ className, ...props }, ref) => (
17
+ <AlertDialogPrimitive.Overlay
18
+ className={cn(
19
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
20
+ className
21
+ )}
22
+ {...props}
23
+ ref={ref}
24
+ />
25
+ ))
26
+ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
27
+
28
+ const AlertDialogContent = React.forwardRef<
29
+ React.ElementRef<typeof AlertDialogPrimitive.Content>,
30
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
31
+ >(({ className, ...props }, ref) => (
32
+ <AlertDialogPortal>
33
+ <AlertDialogOverlay />
34
+ <AlertDialogPrimitive.Content
35
+ ref={ref}
36
+ className={cn(
37
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
38
+ className
39
+ )}
40
+ {...props}
41
+ />
42
+ </AlertDialogPortal>
43
+ ))
44
+ AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
45
+
46
+ const AlertDialogHeader = ({
47
+ className,
48
+ ...props
49
+ }: React.HTMLAttributes<HTMLDivElement>) => (
50
+ <div
51
+ className={cn(
52
+ "flex flex-col space-y-2 text-center sm:text-left",
53
+ className
54
+ )}
55
+ {...props}
56
+ />
57
+ )
58
+ AlertDialogHeader.displayName = "AlertDialogHeader"
59
+
60
+ const AlertDialogFooter = ({
61
+ className,
62
+ ...props
63
+ }: React.HTMLAttributes<HTMLDivElement>) => (
64
+ <div
65
+ className={cn(
66
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
67
+ className
68
+ )}
69
+ {...props}
70
+ />
71
+ )
72
+ AlertDialogFooter.displayName = "AlertDialogFooter"
73
+
74
+ const AlertDialogTitle = React.forwardRef<
75
+ React.ElementRef<typeof AlertDialogPrimitive.Title>,
76
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
77
+ >(({ className, ...props }, ref) => (
78
+ <AlertDialogPrimitive.Title
79
+ ref={ref}
80
+ className={cn("text-lg font-semibold", className)}
81
+ {...props}
82
+ />
83
+ ))
84
+ AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
85
+
86
+ const AlertDialogDescription = React.forwardRef<
87
+ React.ElementRef<typeof AlertDialogPrimitive.Description>,
88
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
89
+ >(({ className, ...props }, ref) => (
90
+ <AlertDialogPrimitive.Description
91
+ ref={ref}
92
+ className={cn("text-sm text-muted-foreground", className)}
93
+ {...props}
94
+ />
95
+ ))
96
+ AlertDialogDescription.displayName =
97
+ AlertDialogPrimitive.Description.displayName
98
+
99
+ const AlertDialogAction = React.forwardRef<
100
+ React.ElementRef<typeof AlertDialogPrimitive.Action>,
101
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
102
+ >(({ className, ...props }, ref) => (
103
+ <AlertDialogPrimitive.Action
104
+ ref={ref}
105
+ className={cn(buttonVariants(), className)}
106
+ {...props}
107
+ />
108
+ ))
109
+ AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
110
+
111
+ const AlertDialogCancel = React.forwardRef<
112
+ React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
113
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
114
+ >(({ className, ...props }, ref) => (
115
+ <AlertDialogPrimitive.Cancel
116
+ ref={ref}
117
+ className={cn(
118
+ buttonVariants({ variant: "outline" }),
119
+ "mt-2 sm:mt-0",
120
+ className
121
+ )}
122
+ {...props}
123
+ />
124
+ ))
125
+ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
126
+
127
+ export {
128
+ AlertDialog,
129
+ AlertDialogPortal,
130
+ AlertDialogOverlay,
131
+ AlertDialogTrigger,
132
+ AlertDialogContent,
133
+ AlertDialogHeader,
134
+ AlertDialogFooter,
135
+ AlertDialogTitle,
136
+ AlertDialogDescription,
137
+ AlertDialogAction,
138
+ AlertDialogCancel,
139
+ }
frontend/src/components/ui/alert.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const alertVariants = cva(
7
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-background text-foreground",
12
+ destructive:
13
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ }
20
+ )
21
+
22
+ const Alert = React.forwardRef<
23
+ HTMLDivElement,
24
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
25
+ >(({ className, variant, ...props }, ref) => (
26
+ <div
27
+ ref={ref}
28
+ role="alert"
29
+ className={cn(alertVariants({ variant }), className)}
30
+ {...props}
31
+ />
32
+ ))
33
+ Alert.displayName = "Alert"
34
+
35
+ const AlertTitle = React.forwardRef<
36
+ HTMLParagraphElement,
37
+ React.HTMLAttributes<HTMLHeadingElement>
38
+ >(({ className, ...props }, ref) => (
39
+ <h5
40
+ ref={ref}
41
+ className={cn("mb-1 font-medium leading-none tracking-tight", className)}
42
+ {...props}
43
+ />
44
+ ))
45
+ AlertTitle.displayName = "AlertTitle"
46
+
47
+ const AlertDescription = React.forwardRef<
48
+ HTMLParagraphElement,
49
+ React.HTMLAttributes<HTMLParagraphElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <div
52
+ ref={ref}
53
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
54
+ {...props}
55
+ />
56
+ ))
57
+ AlertDescription.displayName = "AlertDescription"
58
+
59
+ export { Alert, AlertTitle, AlertDescription }
frontend/src/components/ui/aspect-ratio.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
2
+
3
+ const AspectRatio = AspectRatioPrimitive.Root
4
+
5
+ export { AspectRatio }
frontend/src/components/ui/avatar.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as AvatarPrimitive from "@radix-ui/react-avatar"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const Avatar = React.forwardRef<
7
+ React.ElementRef<typeof AvatarPrimitive.Root>,
8
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
9
+ >(({ className, ...props }, ref) => (
10
+ <AvatarPrimitive.Root
11
+ ref={ref}
12
+ className={cn(
13
+ "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ ))
19
+ Avatar.displayName = AvatarPrimitive.Root.displayName
20
+
21
+ const AvatarImage = React.forwardRef<
22
+ React.ElementRef<typeof AvatarPrimitive.Image>,
23
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
24
+ >(({ className, ...props }, ref) => (
25
+ <AvatarPrimitive.Image
26
+ ref={ref}
27
+ className={cn("aspect-square h-full w-full", className)}
28
+ {...props}
29
+ />
30
+ ))
31
+ AvatarImage.displayName = AvatarPrimitive.Image.displayName
32
+
33
+ const AvatarFallback = React.forwardRef<
34
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
35
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
36
+ >(({ className, ...props }, ref) => (
37
+ <AvatarPrimitive.Fallback
38
+ ref={ref}
39
+ className={cn(
40
+ "flex h-full w-full items-center justify-center rounded-full bg-muted",
41
+ className
42
+ )}
43
+ {...props}
44
+ />
45
+ ))
46
+ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
47
+
48
+ export { Avatar, AvatarImage, AvatarFallback }
frontend/src/components/ui/badge.tsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const badgeVariants = cva(
7
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13
+ secondary:
14
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15
+ destructive:
16
+ "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17
+ outline: "text-foreground",
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: "default",
22
+ },
23
+ }
24
+ )
25
+
26
+ export interface BadgeProps
27
+ extends React.HTMLAttributes<HTMLDivElement>,
28
+ VariantProps<typeof badgeVariants> {}
29
+
30
+ function Badge({ className, variant, ...props }: BadgeProps) {
31
+ return (
32
+ <div className={cn(badgeVariants({ variant }), className)} {...props} />
33
+ )
34
+ }
35
+
36
+ export { Badge, badgeVariants }
frontend/src/components/ui/breadcrumb.tsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { ChevronRight, MoreHorizontal } from "lucide-react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const Breadcrumb = React.forwardRef<
8
+ HTMLElement,
9
+ React.ComponentPropsWithoutRef<"nav"> & {
10
+ separator?: React.ReactNode
11
+ }
12
+ >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
13
+ Breadcrumb.displayName = "Breadcrumb"
14
+
15
+ const BreadcrumbList = React.forwardRef<
16
+ HTMLOListElement,
17
+ React.ComponentPropsWithoutRef<"ol">
18
+ >(({ className, ...props }, ref) => (
19
+ <ol
20
+ ref={ref}
21
+ className={cn(
22
+ "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
23
+ className
24
+ )}
25
+ {...props}
26
+ />
27
+ ))
28
+ BreadcrumbList.displayName = "BreadcrumbList"
29
+
30
+ const BreadcrumbItem = React.forwardRef<
31
+ HTMLLIElement,
32
+ React.ComponentPropsWithoutRef<"li">
33
+ >(({ className, ...props }, ref) => (
34
+ <li
35
+ ref={ref}
36
+ className={cn("inline-flex items-center gap-1.5", className)}
37
+ {...props}
38
+ />
39
+ ))
40
+ BreadcrumbItem.displayName = "BreadcrumbItem"
41
+
42
+ const BreadcrumbLink = React.forwardRef<
43
+ HTMLAnchorElement,
44
+ React.ComponentPropsWithoutRef<"a"> & {
45
+ asChild?: boolean
46
+ }
47
+ >(({ asChild, className, ...props }, ref) => {
48
+ const Comp = asChild ? Slot : "a"
49
+
50
+ return (
51
+ <Comp
52
+ ref={ref}
53
+ className={cn("transition-colors hover:text-foreground", className)}
54
+ {...props}
55
+ />
56
+ )
57
+ })
58
+ BreadcrumbLink.displayName = "BreadcrumbLink"
59
+
60
+ const BreadcrumbPage = React.forwardRef<
61
+ HTMLSpanElement,
62
+ React.ComponentPropsWithoutRef<"span">
63
+ >(({ className, ...props }, ref) => (
64
+ <span
65
+ ref={ref}
66
+ role="link"
67
+ aria-disabled="true"
68
+ aria-current="page"
69
+ className={cn("font-normal text-foreground", className)}
70
+ {...props}
71
+ />
72
+ ))
73
+ BreadcrumbPage.displayName = "BreadcrumbPage"
74
+
75
+ const BreadcrumbSeparator = ({
76
+ children,
77
+ className,
78
+ ...props
79
+ }: React.ComponentProps<"li">) => (
80
+ <li
81
+ role="presentation"
82
+ aria-hidden="true"
83
+ className={cn("[&>svg]:size-3.5", className)}
84
+ {...props}
85
+ >
86
+ {children ?? <ChevronRight />}
87
+ </li>
88
+ )
89
+ BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
90
+
91
+ const BreadcrumbEllipsis = ({
92
+ className,
93
+ ...props
94
+ }: React.ComponentProps<"span">) => (
95
+ <span
96
+ role="presentation"
97
+ aria-hidden="true"
98
+ className={cn("flex h-9 w-9 items-center justify-center", className)}
99
+ {...props}
100
+ >
101
+ <MoreHorizontal className="h-4 w-4" />
102
+ <span className="sr-only">More</span>
103
+ </span>
104
+ )
105
+ BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
106
+
107
+ export {
108
+ Breadcrumb,
109
+ BreadcrumbList,
110
+ BreadcrumbItem,
111
+ BreadcrumbLink,
112
+ BreadcrumbPage,
113
+ BreadcrumbSeparator,
114
+ BreadcrumbEllipsis,
115
+ }
frontend/src/components/ui/button.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Slot } from "@radix-ui/react-slot"
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15
+ outline:
16
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost: "hover:bg-accent hover:text-accent-foreground",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default: "h-10 px-4 py-2",
24
+ sm: "h-9 rounded-md px-3",
25
+ lg: "h-11 rounded-md px-8",
26
+ icon: "h-10 w-10",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ }
34
+ )
35
+
36
+ export interface ButtonProps
37
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
38
+ VariantProps<typeof buttonVariants> {
39
+ asChild?: boolean
40
+ }
41
+
42
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
43
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
44
+ const Comp = asChild ? Slot : "button"
45
+ return (
46
+ <Comp
47
+ className={cn(buttonVariants({ variant, size, className }))}
48
+ ref={ref}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+ )
54
+ Button.displayName = "Button"
55
+
56
+ export { Button, buttonVariants }
frontend/src/components/ui/calendar.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { DayPicker } from "react-day-picker";
4
+
5
+ import { cn } from "@/lib/utils";
6
+ import { buttonVariants } from "@/components/ui/button";
7
+
8
+ export type CalendarProps = React.ComponentProps<typeof DayPicker>;
9
+
10
+ function Calendar({
11
+ className,
12
+ classNames,
13
+ showOutsideDays = true,
14
+ ...props
15
+ }: CalendarProps) {
16
+ return (
17
+ <DayPicker
18
+ showOutsideDays={showOutsideDays}
19
+ className={cn("p-3", className)}
20
+ classNames={{
21
+ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
22
+ month: "space-y-4",
23
+ caption: "flex justify-center pt-1 relative items-center",
24
+ caption_label: "text-sm font-medium",
25
+ nav: "space-x-1 flex items-center",
26
+ nav_button: cn(
27
+ buttonVariants({ variant: "outline" }),
28
+ "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
29
+ ),
30
+ nav_button_previous: "absolute left-1",
31
+ nav_button_next: "absolute right-1",
32
+ table: "w-full border-collapse space-y-1",
33
+ head_row: "flex",
34
+ head_cell:
35
+ "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
36
+ row: "flex w-full mt-2",
37
+ cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
38
+ day: cn(
39
+ buttonVariants({ variant: "ghost" }),
40
+ "h-9 w-9 p-0 font-normal aria-selected:opacity-100"
41
+ ),
42
+ day_range_end: "day-range-end",
43
+ day_selected:
44
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
45
+ day_today: "bg-accent text-accent-foreground",
46
+ day_outside:
47
+ "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
48
+ day_disabled: "text-muted-foreground opacity-50",
49
+ day_range_middle:
50
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
51
+ day_hidden: "invisible",
52
+ ...classNames,
53
+ }}
54
+ components={{
55
+ IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
56
+ IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
57
+ }}
58
+ {...props}
59
+ />
60
+ );
61
+ }
62
+ Calendar.displayName = "Calendar";
63
+
64
+ export { Calendar };
frontend/src/components/ui/card.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ const Card = React.forwardRef<
6
+ HTMLDivElement,
7
+ React.HTMLAttributes<HTMLDivElement>
8
+ >(({ className, ...props }, ref) => (
9
+ <div
10
+ ref={ref}
11
+ className={cn(
12
+ "rounded-lg border bg-card text-card-foreground shadow-sm",
13
+ className
14
+ )}
15
+ {...props}
16
+ />
17
+ ))
18
+ Card.displayName = "Card"
19
+
20
+ const CardHeader = React.forwardRef<
21
+ HTMLDivElement,
22
+ React.HTMLAttributes<HTMLDivElement>
23
+ >(({ className, ...props }, ref) => (
24
+ <div
25
+ ref={ref}
26
+ className={cn("flex flex-col space-y-1.5 p-6", className)}
27
+ {...props}
28
+ />
29
+ ))
30
+ CardHeader.displayName = "CardHeader"
31
+
32
+ const CardTitle = React.forwardRef<
33
+ HTMLParagraphElement,
34
+ React.HTMLAttributes<HTMLHeadingElement>
35
+ >(({ className, ...props }, ref) => (
36
+ <h3
37
+ ref={ref}
38
+ className={cn(
39
+ "text-2xl font-semibold leading-none tracking-tight",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ ))
45
+ CardTitle.displayName = "CardTitle"
46
+
47
+ const CardDescription = React.forwardRef<
48
+ HTMLParagraphElement,
49
+ React.HTMLAttributes<HTMLParagraphElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <p
52
+ ref={ref}
53
+ className={cn("text-sm text-muted-foreground", className)}
54
+ {...props}
55
+ />
56
+ ))
57
+ CardDescription.displayName = "CardDescription"
58
+
59
+ const CardContent = React.forwardRef<
60
+ HTMLDivElement,
61
+ React.HTMLAttributes<HTMLDivElement>
62
+ >(({ className, ...props }, ref) => (
63
+ <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
64
+ ))
65
+ CardContent.displayName = "CardContent"
66
+
67
+ const CardFooter = React.forwardRef<
68
+ HTMLDivElement,
69
+ React.HTMLAttributes<HTMLDivElement>
70
+ >(({ className, ...props }, ref) => (
71
+ <div
72
+ ref={ref}
73
+ className={cn("flex items-center p-6 pt-0", className)}
74
+ {...props}
75
+ />
76
+ ))
77
+ CardFooter.displayName = "CardFooter"
78
+
79
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
frontend/src/components/ui/carousel.tsx ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import useEmblaCarousel, {
3
+ type UseEmblaCarouselType,
4
+ } from "embla-carousel-react"
5
+ import { ArrowLeft, ArrowRight } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+ import { Button } from "@/components/ui/button"
9
+
10
+ type CarouselApi = UseEmblaCarouselType[1]
11
+ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
12
+ type CarouselOptions = UseCarouselParameters[0]
13
+ type CarouselPlugin = UseCarouselParameters[1]
14
+
15
+ type CarouselProps = {
16
+ opts?: CarouselOptions
17
+ plugins?: CarouselPlugin
18
+ orientation?: "horizontal" | "vertical"
19
+ setApi?: (api: CarouselApi) => void
20
+ }
21
+
22
+ type CarouselContextProps = {
23
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0]
24
+ api: ReturnType<typeof useEmblaCarousel>[1]
25
+ scrollPrev: () => void
26
+ scrollNext: () => void
27
+ canScrollPrev: boolean
28
+ canScrollNext: boolean
29
+ } & CarouselProps
30
+
31
+ const CarouselContext = React.createContext<CarouselContextProps | null>(null)
32
+
33
+ function useCarousel() {
34
+ const context = React.useContext(CarouselContext)
35
+
36
+ if (!context) {
37
+ throw new Error("useCarousel must be used within a <Carousel />")
38
+ }
39
+
40
+ return context
41
+ }
42
+
43
+ const Carousel = React.forwardRef<
44
+ HTMLDivElement,
45
+ React.HTMLAttributes<HTMLDivElement> & CarouselProps
46
+ >(
47
+ (
48
+ {
49
+ orientation = "horizontal",
50
+ opts,
51
+ setApi,
52
+ plugins,
53
+ className,
54
+ children,
55
+ ...props
56
+ },
57
+ ref
58
+ ) => {
59
+ const [carouselRef, api] = useEmblaCarousel(
60
+ {
61
+ ...opts,
62
+ axis: orientation === "horizontal" ? "x" : "y",
63
+ },
64
+ plugins
65
+ )
66
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
67
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
68
+
69
+ const onSelect = React.useCallback((api: CarouselApi) => {
70
+ if (!api) {
71
+ return
72
+ }
73
+
74
+ setCanScrollPrev(api.canScrollPrev())
75
+ setCanScrollNext(api.canScrollNext())
76
+ }, [])
77
+
78
+ const scrollPrev = React.useCallback(() => {
79
+ api?.scrollPrev()
80
+ }, [api])
81
+
82
+ const scrollNext = React.useCallback(() => {
83
+ api?.scrollNext()
84
+ }, [api])
85
+
86
+ const handleKeyDown = React.useCallback(
87
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
88
+ if (event.key === "ArrowLeft") {
89
+ event.preventDefault()
90
+ scrollPrev()
91
+ } else if (event.key === "ArrowRight") {
92
+ event.preventDefault()
93
+ scrollNext()
94
+ }
95
+ },
96
+ [scrollPrev, scrollNext]
97
+ )
98
+
99
+ React.useEffect(() => {
100
+ if (!api || !setApi) {
101
+ return
102
+ }
103
+
104
+ setApi(api)
105
+ }, [api, setApi])
106
+
107
+ React.useEffect(() => {
108
+ if (!api) {
109
+ return
110
+ }
111
+
112
+ onSelect(api)
113
+ api.on("reInit", onSelect)
114
+ api.on("select", onSelect)
115
+
116
+ return () => {
117
+ api?.off("select", onSelect)
118
+ }
119
+ }, [api, onSelect])
120
+
121
+ return (
122
+ <CarouselContext.Provider
123
+ value={{
124
+ carouselRef,
125
+ api: api,
126
+ opts,
127
+ orientation:
128
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
129
+ scrollPrev,
130
+ scrollNext,
131
+ canScrollPrev,
132
+ canScrollNext,
133
+ }}
134
+ >
135
+ <div
136
+ ref={ref}
137
+ onKeyDownCapture={handleKeyDown}
138
+ className={cn("relative", className)}
139
+ role="region"
140
+ aria-roledescription="carousel"
141
+ {...props}
142
+ >
143
+ {children}
144
+ </div>
145
+ </CarouselContext.Provider>
146
+ )
147
+ }
148
+ )
149
+ Carousel.displayName = "Carousel"
150
+
151
+ const CarouselContent = React.forwardRef<
152
+ HTMLDivElement,
153
+ React.HTMLAttributes<HTMLDivElement>
154
+ >(({ className, ...props }, ref) => {
155
+ const { carouselRef, orientation } = useCarousel()
156
+
157
+ return (
158
+ <div ref={carouselRef} className="overflow-hidden">
159
+ <div
160
+ ref={ref}
161
+ className={cn(
162
+ "flex",
163
+ orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
164
+ className
165
+ )}
166
+ {...props}
167
+ />
168
+ </div>
169
+ )
170
+ })
171
+ CarouselContent.displayName = "CarouselContent"
172
+
173
+ const CarouselItem = React.forwardRef<
174
+ HTMLDivElement,
175
+ React.HTMLAttributes<HTMLDivElement>
176
+ >(({ className, ...props }, ref) => {
177
+ const { orientation } = useCarousel()
178
+
179
+ return (
180
+ <div
181
+ ref={ref}
182
+ role="group"
183
+ aria-roledescription="slide"
184
+ className={cn(
185
+ "min-w-0 shrink-0 grow-0 basis-full",
186
+ orientation === "horizontal" ? "pl-4" : "pt-4",
187
+ className
188
+ )}
189
+ {...props}
190
+ />
191
+ )
192
+ })
193
+ CarouselItem.displayName = "CarouselItem"
194
+
195
+ const CarouselPrevious = React.forwardRef<
196
+ HTMLButtonElement,
197
+ React.ComponentProps<typeof Button>
198
+ >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
199
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
200
+
201
+ return (
202
+ <Button
203
+ ref={ref}
204
+ variant={variant}
205
+ size={size}
206
+ className={cn(
207
+ "absolute h-8 w-8 rounded-full",
208
+ orientation === "horizontal"
209
+ ? "-left-12 top-1/2 -translate-y-1/2"
210
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
211
+ className
212
+ )}
213
+ disabled={!canScrollPrev}
214
+ onClick={scrollPrev}
215
+ {...props}
216
+ >
217
+ <ArrowLeft className="h-4 w-4" />
218
+ <span className="sr-only">Previous slide</span>
219
+ </Button>
220
+ )
221
+ })
222
+ CarouselPrevious.displayName = "CarouselPrevious"
223
+
224
+ const CarouselNext = React.forwardRef<
225
+ HTMLButtonElement,
226
+ React.ComponentProps<typeof Button>
227
+ >(({ className, variant = "outline", size = "icon", ...props }, ref) => {
228
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
229
+
230
+ return (
231
+ <Button
232
+ ref={ref}
233
+ variant={variant}
234
+ size={size}
235
+ className={cn(
236
+ "absolute h-8 w-8 rounded-full",
237
+ orientation === "horizontal"
238
+ ? "-right-12 top-1/2 -translate-y-1/2"
239
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
240
+ className
241
+ )}
242
+ disabled={!canScrollNext}
243
+ onClick={scrollNext}
244
+ {...props}
245
+ >
246
+ <ArrowRight className="h-4 w-4" />
247
+ <span className="sr-only">Next slide</span>
248
+ </Button>
249
+ )
250
+ })
251
+ CarouselNext.displayName = "CarouselNext"
252
+
253
+ export {
254
+ type CarouselApi,
255
+ Carousel,
256
+ CarouselContent,
257
+ CarouselItem,
258
+ CarouselPrevious,
259
+ CarouselNext,
260
+ }
frontend/src/components/ui/chart.tsx ADDED
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as RechartsPrimitive from "recharts"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ // Format: { THEME_NAME: CSS_SELECTOR }
7
+ const THEMES = { light: "", dark: ".dark" } as const
8
+
9
+ export type ChartConfig = {
10
+ [k in string]: {
11
+ label?: React.ReactNode
12
+ icon?: React.ComponentType
13
+ } & (
14
+ | { color?: string; theme?: never }
15
+ | { color?: never; theme: Record<keyof typeof THEMES, string> }
16
+ )
17
+ }
18
+
19
+ type ChartContextProps = {
20
+ config: ChartConfig
21
+ }
22
+
23
+ const ChartContext = React.createContext<ChartContextProps | null>(null)
24
+
25
+ function useChart() {
26
+ const context = React.useContext(ChartContext)
27
+
28
+ if (!context) {
29
+ throw new Error("useChart must be used within a <ChartContainer />")
30
+ }
31
+
32
+ return context
33
+ }
34
+
35
+ const ChartContainer = React.forwardRef<
36
+ HTMLDivElement,
37
+ React.ComponentProps<"div"> & {
38
+ config: ChartConfig
39
+ children: React.ComponentProps<
40
+ typeof RechartsPrimitive.ResponsiveContainer
41
+ >["children"]
42
+ }
43
+ >(({ id, className, children, config, ...props }, ref) => {
44
+ const uniqueId = React.useId()
45
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
46
+
47
+ return (
48
+ <ChartContext.Provider value={{ config }}>
49
+ <div
50
+ data-chart={chartId}
51
+ ref={ref}
52
+ className={cn(
53
+ "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
54
+ className
55
+ )}
56
+ {...props}
57
+ >
58
+ <ChartStyle id={chartId} config={config} />
59
+ <RechartsPrimitive.ResponsiveContainer>
60
+ {children}
61
+ </RechartsPrimitive.ResponsiveContainer>
62
+ </div>
63
+ </ChartContext.Provider>
64
+ )
65
+ })
66
+ ChartContainer.displayName = "Chart"
67
+
68
+ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
69
+ const colorConfig = Object.entries(config).filter(
70
+ ([_, config]) => config.theme || config.color
71
+ )
72
+
73
+ if (!colorConfig.length) {
74
+ return null
75
+ }
76
+
77
+ return (
78
+ <style
79
+ dangerouslySetInnerHTML={{
80
+ __html: Object.entries(THEMES)
81
+ .map(
82
+ ([theme, prefix]) => `
83
+ ${prefix} [data-chart=${id}] {
84
+ ${colorConfig
85
+ .map(([key, itemConfig]) => {
86
+ const color =
87
+ itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
88
+ itemConfig.color
89
+ return color ? ` --color-${key}: ${color};` : null
90
+ })
91
+ .join("\n")}
92
+ }
93
+ `
94
+ )
95
+ .join("\n"),
96
+ }}
97
+ />
98
+ )
99
+ }
100
+
101
+ const ChartTooltip = RechartsPrimitive.Tooltip
102
+
103
+ const ChartTooltipContent = React.forwardRef<
104
+ HTMLDivElement,
105
+ React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
106
+ React.ComponentProps<"div"> & {
107
+ hideLabel?: boolean
108
+ hideIndicator?: boolean
109
+ indicator?: "line" | "dot" | "dashed"
110
+ nameKey?: string
111
+ labelKey?: string
112
+ }
113
+ >(
114
+ (
115
+ {
116
+ active,
117
+ payload,
118
+ className,
119
+ indicator = "dot",
120
+ hideLabel = false,
121
+ hideIndicator = false,
122
+ label,
123
+ labelFormatter,
124
+ labelClassName,
125
+ formatter,
126
+ color,
127
+ nameKey,
128
+ labelKey,
129
+ },
130
+ ref
131
+ ) => {
132
+ const { config } = useChart()
133
+
134
+ const tooltipLabel = React.useMemo(() => {
135
+ if (hideLabel || !payload?.length) {
136
+ return null
137
+ }
138
+
139
+ const [item] = payload
140
+ const key = `${labelKey || item.dataKey || item.name || "value"}`
141
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
142
+ const value =
143
+ !labelKey && typeof label === "string"
144
+ ? config[label as keyof typeof config]?.label || label
145
+ : itemConfig?.label
146
+
147
+ if (labelFormatter) {
148
+ return (
149
+ <div className={cn("font-medium", labelClassName)}>
150
+ {labelFormatter(value, payload)}
151
+ </div>
152
+ )
153
+ }
154
+
155
+ if (!value) {
156
+ return null
157
+ }
158
+
159
+ return <div className={cn("font-medium", labelClassName)}>{value}</div>
160
+ }, [
161
+ label,
162
+ labelFormatter,
163
+ payload,
164
+ hideLabel,
165
+ labelClassName,
166
+ config,
167
+ labelKey,
168
+ ])
169
+
170
+ if (!active || !payload?.length) {
171
+ return null
172
+ }
173
+
174
+ const nestLabel = payload.length === 1 && indicator !== "dot"
175
+
176
+ return (
177
+ <div
178
+ ref={ref}
179
+ className={cn(
180
+ "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
181
+ className
182
+ )}
183
+ >
184
+ {!nestLabel ? tooltipLabel : null}
185
+ <div className="grid gap-1.5">
186
+ {payload.map((item, index) => {
187
+ const key = `${nameKey || item.name || item.dataKey || "value"}`
188
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
189
+ const indicatorColor = color || item.payload.fill || item.color
190
+
191
+ return (
192
+ <div
193
+ key={item.dataKey}
194
+ className={cn(
195
+ "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
196
+ indicator === "dot" && "items-center"
197
+ )}
198
+ >
199
+ {formatter && item?.value !== undefined && item.name ? (
200
+ formatter(item.value, item.name, item, index, item.payload)
201
+ ) : (
202
+ <>
203
+ {itemConfig?.icon ? (
204
+ <itemConfig.icon />
205
+ ) : (
206
+ !hideIndicator && (
207
+ <div
208
+ className={cn(
209
+ "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
210
+ {
211
+ "h-2.5 w-2.5": indicator === "dot",
212
+ "w-1": indicator === "line",
213
+ "w-0 border-[1.5px] border-dashed bg-transparent":
214
+ indicator === "dashed",
215
+ "my-0.5": nestLabel && indicator === "dashed",
216
+ }
217
+ )}
218
+ style={
219
+ {
220
+ "--color-bg": indicatorColor,
221
+ "--color-border": indicatorColor,
222
+ } as React.CSSProperties
223
+ }
224
+ />
225
+ )
226
+ )}
227
+ <div
228
+ className={cn(
229
+ "flex flex-1 justify-between leading-none",
230
+ nestLabel ? "items-end" : "items-center"
231
+ )}
232
+ >
233
+ <div className="grid gap-1.5">
234
+ {nestLabel ? tooltipLabel : null}
235
+ <span className="text-muted-foreground">
236
+ {itemConfig?.label || item.name}
237
+ </span>
238
+ </div>
239
+ {item.value && (
240
+ <span className="font-mono font-medium tabular-nums text-foreground">
241
+ {item.value.toLocaleString()}
242
+ </span>
243
+ )}
244
+ </div>
245
+ </>
246
+ )}
247
+ </div>
248
+ )
249
+ })}
250
+ </div>
251
+ </div>
252
+ )
253
+ }
254
+ )
255
+ ChartTooltipContent.displayName = "ChartTooltip"
256
+
257
+ const ChartLegend = RechartsPrimitive.Legend
258
+
259
+ const ChartLegendContent = React.forwardRef<
260
+ HTMLDivElement,
261
+ React.ComponentProps<"div"> &
262
+ Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
263
+ hideIcon?: boolean
264
+ nameKey?: string
265
+ }
266
+ >(
267
+ (
268
+ { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
269
+ ref
270
+ ) => {
271
+ const { config } = useChart()
272
+
273
+ if (!payload?.length) {
274
+ return null
275
+ }
276
+
277
+ return (
278
+ <div
279
+ ref={ref}
280
+ className={cn(
281
+ "flex items-center justify-center gap-4",
282
+ verticalAlign === "top" ? "pb-3" : "pt-3",
283
+ className
284
+ )}
285
+ >
286
+ {payload.map((item) => {
287
+ const key = `${nameKey || item.dataKey || "value"}`
288
+ const itemConfig = getPayloadConfigFromPayload(config, item, key)
289
+
290
+ return (
291
+ <div
292
+ key={item.value}
293
+ className={cn(
294
+ "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
295
+ )}
296
+ >
297
+ {itemConfig?.icon && !hideIcon ? (
298
+ <itemConfig.icon />
299
+ ) : (
300
+ <div
301
+ className="h-2 w-2 shrink-0 rounded-[2px]"
302
+ style={{
303
+ backgroundColor: item.color,
304
+ }}
305
+ />
306
+ )}
307
+ {itemConfig?.label}
308
+ </div>
309
+ )
310
+ })}
311
+ </div>
312
+ )
313
+ }
314
+ )
315
+ ChartLegendContent.displayName = "ChartLegend"
316
+
317
+ // Helper to extract item config from a payload.
318
+ function getPayloadConfigFromPayload(
319
+ config: ChartConfig,
320
+ payload: unknown,
321
+ key: string
322
+ ) {
323
+ if (typeof payload !== "object" || payload === null) {
324
+ return undefined
325
+ }
326
+
327
+ const payloadPayload =
328
+ "payload" in payload &&
329
+ typeof payload.payload === "object" &&
330
+ payload.payload !== null
331
+ ? payload.payload
332
+ : undefined
333
+
334
+ let configLabelKey: string = key
335
+
336
+ if (
337
+ key in payload &&
338
+ typeof payload[key as keyof typeof payload] === "string"
339
+ ) {
340
+ configLabelKey = payload[key as keyof typeof payload] as string
341
+ } else if (
342
+ payloadPayload &&
343
+ key in payloadPayload &&
344
+ typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
345
+ ) {
346
+ configLabelKey = payloadPayload[
347
+ key as keyof typeof payloadPayload
348
+ ] as string
349
+ }
350
+
351
+ return configLabelKey in config
352
+ ? config[configLabelKey]
353
+ : config[key as keyof typeof config]
354
+ }
355
+
356
+ export {
357
+ ChartContainer,
358
+ ChartTooltip,
359
+ ChartTooltipContent,
360
+ ChartLegend,
361
+ ChartLegendContent,
362
+ ChartStyle,
363
+ }
frontend/src/components/ui/checkbox.tsx ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3
+ import { Check } from "lucide-react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const Checkbox = React.forwardRef<
8
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
9
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
10
+ >(({ className, ...props }, ref) => (
11
+ <CheckboxPrimitive.Root
12
+ ref={ref}
13
+ className={cn(
14
+ "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
15
+ className
16
+ )}
17
+ {...props}
18
+ >
19
+ <CheckboxPrimitive.Indicator
20
+ className={cn("flex items-center justify-center text-current")}
21
+ >
22
+ <Check className="h-4 w-4" />
23
+ </CheckboxPrimitive.Indicator>
24
+ </CheckboxPrimitive.Root>
25
+ ))
26
+ Checkbox.displayName = CheckboxPrimitive.Root.displayName
27
+
28
+ export { Checkbox }
frontend/src/components/ui/collapsible.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
2
+
3
+ const Collapsible = CollapsiblePrimitive.Root
4
+
5
+ const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
6
+
7
+ const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
8
+
9
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent }
frontend/src/components/ui/command.tsx ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { type DialogProps } from "@radix-ui/react-dialog"
3
+ import { Command as CommandPrimitive } from "cmdk"
4
+ import { Search } from "lucide-react"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import { Dialog, DialogContent } from "@/components/ui/dialog"
8
+
9
+ const Command = React.forwardRef<
10
+ React.ElementRef<typeof CommandPrimitive>,
11
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
12
+ >(({ className, ...props }, ref) => (
13
+ <CommandPrimitive
14
+ ref={ref}
15
+ className={cn(
16
+ "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
17
+ className
18
+ )}
19
+ {...props}
20
+ />
21
+ ))
22
+ Command.displayName = CommandPrimitive.displayName
23
+
24
+ interface CommandDialogProps extends DialogProps {}
25
+
26
+ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
27
+ return (
28
+ <Dialog {...props}>
29
+ <DialogContent className="overflow-hidden p-0 shadow-lg">
30
+ <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
31
+ {children}
32
+ </Command>
33
+ </DialogContent>
34
+ </Dialog>
35
+ )
36
+ }
37
+
38
+ const CommandInput = React.forwardRef<
39
+ React.ElementRef<typeof CommandPrimitive.Input>,
40
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
41
+ >(({ className, ...props }, ref) => (
42
+ <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
43
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
44
+ <CommandPrimitive.Input
45
+ ref={ref}
46
+ className={cn(
47
+ "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
48
+ className
49
+ )}
50
+ {...props}
51
+ />
52
+ </div>
53
+ ))
54
+
55
+ CommandInput.displayName = CommandPrimitive.Input.displayName
56
+
57
+ const CommandList = React.forwardRef<
58
+ React.ElementRef<typeof CommandPrimitive.List>,
59
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
60
+ >(({ className, ...props }, ref) => (
61
+ <CommandPrimitive.List
62
+ ref={ref}
63
+ className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
64
+ {...props}
65
+ />
66
+ ))
67
+
68
+ CommandList.displayName = CommandPrimitive.List.displayName
69
+
70
+ const CommandEmpty = React.forwardRef<
71
+ React.ElementRef<typeof CommandPrimitive.Empty>,
72
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
73
+ >((props, ref) => (
74
+ <CommandPrimitive.Empty
75
+ ref={ref}
76
+ className="py-6 text-center text-sm"
77
+ {...props}
78
+ />
79
+ ))
80
+
81
+ CommandEmpty.displayName = CommandPrimitive.Empty.displayName
82
+
83
+ const CommandGroup = React.forwardRef<
84
+ React.ElementRef<typeof CommandPrimitive.Group>,
85
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
86
+ >(({ className, ...props }, ref) => (
87
+ <CommandPrimitive.Group
88
+ ref={ref}
89
+ className={cn(
90
+ "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
91
+ className
92
+ )}
93
+ {...props}
94
+ />
95
+ ))
96
+
97
+ CommandGroup.displayName = CommandPrimitive.Group.displayName
98
+
99
+ const CommandSeparator = React.forwardRef<
100
+ React.ElementRef<typeof CommandPrimitive.Separator>,
101
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
102
+ >(({ className, ...props }, ref) => (
103
+ <CommandPrimitive.Separator
104
+ ref={ref}
105
+ className={cn("-mx-1 h-px bg-border", className)}
106
+ {...props}
107
+ />
108
+ ))
109
+ CommandSeparator.displayName = CommandPrimitive.Separator.displayName
110
+
111
+ const CommandItem = React.forwardRef<
112
+ React.ElementRef<typeof CommandPrimitive.Item>,
113
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
114
+ >(({ className, ...props }, ref) => (
115
+ <CommandPrimitive.Item
116
+ ref={ref}
117
+ className={cn(
118
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
119
+ className
120
+ )}
121
+ {...props}
122
+ />
123
+ ))
124
+
125
+ CommandItem.displayName = CommandPrimitive.Item.displayName
126
+
127
+ const CommandShortcut = ({
128
+ className,
129
+ ...props
130
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
131
+ return (
132
+ <span
133
+ className={cn(
134
+ "ml-auto text-xs tracking-widest text-muted-foreground",
135
+ className
136
+ )}
137
+ {...props}
138
+ />
139
+ )
140
+ }
141
+ CommandShortcut.displayName = "CommandShortcut"
142
+
143
+ export {
144
+ Command,
145
+ CommandDialog,
146
+ CommandInput,
147
+ CommandList,
148
+ CommandEmpty,
149
+ CommandGroup,
150
+ CommandItem,
151
+ CommandShortcut,
152
+ CommandSeparator,
153
+ }
frontend/src/components/ui/context-menu.tsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
3
+ import { Check, ChevronRight, Circle } from "lucide-react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const ContextMenu = ContextMenuPrimitive.Root
8
+
9
+ const ContextMenuTrigger = ContextMenuPrimitive.Trigger
10
+
11
+ const ContextMenuGroup = ContextMenuPrimitive.Group
12
+
13
+ const ContextMenuPortal = ContextMenuPrimitive.Portal
14
+
15
+ const ContextMenuSub = ContextMenuPrimitive.Sub
16
+
17
+ const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
18
+
19
+ const ContextMenuSubTrigger = React.forwardRef<
20
+ React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
21
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
22
+ inset?: boolean
23
+ }
24
+ >(({ className, inset, children, ...props }, ref) => (
25
+ <ContextMenuPrimitive.SubTrigger
26
+ ref={ref}
27
+ className={cn(
28
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
29
+ inset && "pl-8",
30
+ className
31
+ )}
32
+ {...props}
33
+ >
34
+ {children}
35
+ <ChevronRight className="ml-auto h-4 w-4" />
36
+ </ContextMenuPrimitive.SubTrigger>
37
+ ))
38
+ ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
39
+
40
+ const ContextMenuSubContent = React.forwardRef<
41
+ React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
42
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
43
+ >(({ className, ...props }, ref) => (
44
+ <ContextMenuPrimitive.SubContent
45
+ ref={ref}
46
+ className={cn(
47
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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",
48
+ className
49
+ )}
50
+ {...props}
51
+ />
52
+ ))
53
+ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
54
+
55
+ const ContextMenuContent = React.forwardRef<
56
+ React.ElementRef<typeof ContextMenuPrimitive.Content>,
57
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
58
+ >(({ className, ...props }, ref) => (
59
+ <ContextMenuPrimitive.Portal>
60
+ <ContextMenuPrimitive.Content
61
+ ref={ref}
62
+ className={cn(
63
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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",
64
+ className
65
+ )}
66
+ {...props}
67
+ />
68
+ </ContextMenuPrimitive.Portal>
69
+ ))
70
+ ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
71
+
72
+ const ContextMenuItem = React.forwardRef<
73
+ React.ElementRef<typeof ContextMenuPrimitive.Item>,
74
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
75
+ inset?: boolean
76
+ }
77
+ >(({ className, inset, ...props }, ref) => (
78
+ <ContextMenuPrimitive.Item
79
+ ref={ref}
80
+ className={cn(
81
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
82
+ inset && "pl-8",
83
+ className
84
+ )}
85
+ {...props}
86
+ />
87
+ ))
88
+ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
89
+
90
+ const ContextMenuCheckboxItem = React.forwardRef<
91
+ React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
92
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
93
+ >(({ className, children, checked, ...props }, ref) => (
94
+ <ContextMenuPrimitive.CheckboxItem
95
+ ref={ref}
96
+ className={cn(
97
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
98
+ className
99
+ )}
100
+ checked={checked}
101
+ {...props}
102
+ >
103
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
104
+ <ContextMenuPrimitive.ItemIndicator>
105
+ <Check className="h-4 w-4" />
106
+ </ContextMenuPrimitive.ItemIndicator>
107
+ </span>
108
+ {children}
109
+ </ContextMenuPrimitive.CheckboxItem>
110
+ ))
111
+ ContextMenuCheckboxItem.displayName =
112
+ ContextMenuPrimitive.CheckboxItem.displayName
113
+
114
+ const ContextMenuRadioItem = React.forwardRef<
115
+ React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
116
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
117
+ >(({ className, children, ...props }, ref) => (
118
+ <ContextMenuPrimitive.RadioItem
119
+ ref={ref}
120
+ className={cn(
121
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
122
+ className
123
+ )}
124
+ {...props}
125
+ >
126
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
127
+ <ContextMenuPrimitive.ItemIndicator>
128
+ <Circle className="h-2 w-2 fill-current" />
129
+ </ContextMenuPrimitive.ItemIndicator>
130
+ </span>
131
+ {children}
132
+ </ContextMenuPrimitive.RadioItem>
133
+ ))
134
+ ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
135
+
136
+ const ContextMenuLabel = React.forwardRef<
137
+ React.ElementRef<typeof ContextMenuPrimitive.Label>,
138
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
139
+ inset?: boolean
140
+ }
141
+ >(({ className, inset, ...props }, ref) => (
142
+ <ContextMenuPrimitive.Label
143
+ ref={ref}
144
+ className={cn(
145
+ "px-2 py-1.5 text-sm font-semibold text-foreground",
146
+ inset && "pl-8",
147
+ className
148
+ )}
149
+ {...props}
150
+ />
151
+ ))
152
+ ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
153
+
154
+ const ContextMenuSeparator = React.forwardRef<
155
+ React.ElementRef<typeof ContextMenuPrimitive.Separator>,
156
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
157
+ >(({ className, ...props }, ref) => (
158
+ <ContextMenuPrimitive.Separator
159
+ ref={ref}
160
+ className={cn("-mx-1 my-1 h-px bg-border", className)}
161
+ {...props}
162
+ />
163
+ ))
164
+ ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
165
+
166
+ const ContextMenuShortcut = ({
167
+ className,
168
+ ...props
169
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
170
+ return (
171
+ <span
172
+ className={cn(
173
+ "ml-auto text-xs tracking-widest text-muted-foreground",
174
+ className
175
+ )}
176
+ {...props}
177
+ />
178
+ )
179
+ }
180
+ ContextMenuShortcut.displayName = "ContextMenuShortcut"
181
+
182
+ export {
183
+ ContextMenu,
184
+ ContextMenuTrigger,
185
+ ContextMenuContent,
186
+ ContextMenuItem,
187
+ ContextMenuCheckboxItem,
188
+ ContextMenuRadioItem,
189
+ ContextMenuLabel,
190
+ ContextMenuSeparator,
191
+ ContextMenuShortcut,
192
+ ContextMenuGroup,
193
+ ContextMenuPortal,
194
+ ContextMenuSub,
195
+ ContextMenuSubContent,
196
+ ContextMenuSubTrigger,
197
+ ContextMenuRadioGroup,
198
+ }
frontend/src/components/ui/dialog.tsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as DialogPrimitive from "@radix-ui/react-dialog"
3
+ import { X } from "lucide-react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const Dialog = DialogPrimitive.Root
8
+
9
+ const DialogTrigger = DialogPrimitive.Trigger
10
+
11
+ const DialogPortal = DialogPrimitive.Portal
12
+
13
+ const DialogClose = DialogPrimitive.Close
14
+
15
+ const DialogOverlay = React.forwardRef<
16
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
17
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
18
+ >(({ className, ...props }, ref) => (
19
+ <DialogPrimitive.Overlay
20
+ ref={ref}
21
+ className={cn(
22
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
23
+ className
24
+ )}
25
+ {...props}
26
+ />
27
+ ))
28
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29
+
30
+ const DialogContent = React.forwardRef<
31
+ React.ElementRef<typeof DialogPrimitive.Content>,
32
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
33
+ >(({ className, children, ...props }, ref) => (
34
+ <DialogPortal>
35
+ <DialogOverlay />
36
+ <DialogPrimitive.Content
37
+ ref={ref}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
+ className
41
+ )}
42
+ {...props}
43
+ >
44
+ {children}
45
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
46
+ <X className="h-4 w-4" />
47
+ <span className="sr-only">Close</span>
48
+ </DialogPrimitive.Close>
49
+ </DialogPrimitive.Content>
50
+ </DialogPortal>
51
+ ))
52
+ DialogContent.displayName = DialogPrimitive.Content.displayName
53
+
54
+ const DialogHeader = ({
55
+ className,
56
+ ...props
57
+ }: React.HTMLAttributes<HTMLDivElement>) => (
58
+ <div
59
+ className={cn(
60
+ "flex flex-col space-y-1.5 text-center sm:text-left",
61
+ className
62
+ )}
63
+ {...props}
64
+ />
65
+ )
66
+ DialogHeader.displayName = "DialogHeader"
67
+
68
+ const DialogFooter = ({
69
+ className,
70
+ ...props
71
+ }: React.HTMLAttributes<HTMLDivElement>) => (
72
+ <div
73
+ className={cn(
74
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
75
+ className
76
+ )}
77
+ {...props}
78
+ />
79
+ )
80
+ DialogFooter.displayName = "DialogFooter"
81
+
82
+ const DialogTitle = React.forwardRef<
83
+ React.ElementRef<typeof DialogPrimitive.Title>,
84
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
85
+ >(({ className, ...props }, ref) => (
86
+ <DialogPrimitive.Title
87
+ ref={ref}
88
+ className={cn(
89
+ "text-lg font-semibold leading-none tracking-tight",
90
+ className
91
+ )}
92
+ {...props}
93
+ />
94
+ ))
95
+ DialogTitle.displayName = DialogPrimitive.Title.displayName
96
+
97
+ const DialogDescription = React.forwardRef<
98
+ React.ElementRef<typeof DialogPrimitive.Description>,
99
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
100
+ >(({ className, ...props }, ref) => (
101
+ <DialogPrimitive.Description
102
+ ref={ref}
103
+ className={cn("text-sm text-muted-foreground", className)}
104
+ {...props}
105
+ />
106
+ ))
107
+ DialogDescription.displayName = DialogPrimitive.Description.displayName
108
+
109
+ export {
110
+ Dialog,
111
+ DialogPortal,
112
+ DialogOverlay,
113
+ DialogClose,
114
+ DialogTrigger,
115
+ DialogContent,
116
+ DialogHeader,
117
+ DialogFooter,
118
+ DialogTitle,
119
+ DialogDescription,
120
+ }
frontend/src/components/ui/drawer.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { Drawer as DrawerPrimitive } from "vaul"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const Drawer = ({
7
+ shouldScaleBackground = true,
8
+ ...props
9
+ }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
10
+ <DrawerPrimitive.Root
11
+ shouldScaleBackground={shouldScaleBackground}
12
+ {...props}
13
+ />
14
+ )
15
+ Drawer.displayName = "Drawer"
16
+
17
+ const DrawerTrigger = DrawerPrimitive.Trigger
18
+
19
+ const DrawerPortal = DrawerPrimitive.Portal
20
+
21
+ const DrawerClose = DrawerPrimitive.Close
22
+
23
+ const DrawerOverlay = React.forwardRef<
24
+ React.ElementRef<typeof DrawerPrimitive.Overlay>,
25
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
26
+ >(({ className, ...props }, ref) => (
27
+ <DrawerPrimitive.Overlay
28
+ ref={ref}
29
+ className={cn("fixed inset-0 z-50 bg-black/80", className)}
30
+ {...props}
31
+ />
32
+ ))
33
+ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
34
+
35
+ const DrawerContent = React.forwardRef<
36
+ React.ElementRef<typeof DrawerPrimitive.Content>,
37
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
38
+ >(({ className, children, ...props }, ref) => (
39
+ <DrawerPortal>
40
+ <DrawerOverlay />
41
+ <DrawerPrimitive.Content
42
+ ref={ref}
43
+ className={cn(
44
+ "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
45
+ className
46
+ )}
47
+ {...props}
48
+ >
49
+ <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
50
+ {children}
51
+ </DrawerPrimitive.Content>
52
+ </DrawerPortal>
53
+ ))
54
+ DrawerContent.displayName = "DrawerContent"
55
+
56
+ const DrawerHeader = ({
57
+ className,
58
+ ...props
59
+ }: React.HTMLAttributes<HTMLDivElement>) => (
60
+ <div
61
+ className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
62
+ {...props}
63
+ />
64
+ )
65
+ DrawerHeader.displayName = "DrawerHeader"
66
+
67
+ const DrawerFooter = ({
68
+ className,
69
+ ...props
70
+ }: React.HTMLAttributes<HTMLDivElement>) => (
71
+ <div
72
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
73
+ {...props}
74
+ />
75
+ )
76
+ DrawerFooter.displayName = "DrawerFooter"
77
+
78
+ const DrawerTitle = React.forwardRef<
79
+ React.ElementRef<typeof DrawerPrimitive.Title>,
80
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
81
+ >(({ className, ...props }, ref) => (
82
+ <DrawerPrimitive.Title
83
+ ref={ref}
84
+ className={cn(
85
+ "text-lg font-semibold leading-none tracking-tight",
86
+ className
87
+ )}
88
+ {...props}
89
+ />
90
+ ))
91
+ DrawerTitle.displayName = DrawerPrimitive.Title.displayName
92
+
93
+ const DrawerDescription = React.forwardRef<
94
+ React.ElementRef<typeof DrawerPrimitive.Description>,
95
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
96
+ >(({ className, ...props }, ref) => (
97
+ <DrawerPrimitive.Description
98
+ ref={ref}
99
+ className={cn("text-sm text-muted-foreground", className)}
100
+ {...props}
101
+ />
102
+ ))
103
+ DrawerDescription.displayName = DrawerPrimitive.Description.displayName
104
+
105
+ export {
106
+ Drawer,
107
+ DrawerPortal,
108
+ DrawerOverlay,
109
+ DrawerTrigger,
110
+ DrawerClose,
111
+ DrawerContent,
112
+ DrawerHeader,
113
+ DrawerFooter,
114
+ DrawerTitle,
115
+ DrawerDescription,
116
+ }
frontend/src/components/ui/dropdown-menu.tsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
3
+ import { Check, ChevronRight, Circle } from "lucide-react"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ const DropdownMenu = DropdownMenuPrimitive.Root
8
+
9
+ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
10
+
11
+ const DropdownMenuGroup = DropdownMenuPrimitive.Group
12
+
13
+ const DropdownMenuPortal = DropdownMenuPrimitive.Portal
14
+
15
+ const DropdownMenuSub = DropdownMenuPrimitive.Sub
16
+
17
+ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
18
+
19
+ const DropdownMenuSubTrigger = React.forwardRef<
20
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
21
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
22
+ inset?: boolean
23
+ }
24
+ >(({ className, inset, children, ...props }, ref) => (
25
+ <DropdownMenuPrimitive.SubTrigger
26
+ ref={ref}
27
+ className={cn(
28
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
29
+ inset && "pl-8",
30
+ className
31
+ )}
32
+ {...props}
33
+ >
34
+ {children}
35
+ <ChevronRight className="ml-auto h-4 w-4" />
36
+ </DropdownMenuPrimitive.SubTrigger>
37
+ ))
38
+ DropdownMenuSubTrigger.displayName =
39
+ DropdownMenuPrimitive.SubTrigger.displayName
40
+
41
+ const DropdownMenuSubContent = React.forwardRef<
42
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
43
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
44
+ >(({ className, ...props }, ref) => (
45
+ <DropdownMenuPrimitive.SubContent
46
+ ref={ref}
47
+ className={cn(
48
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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",
49
+ className
50
+ )}
51
+ {...props}
52
+ />
53
+ ))
54
+ DropdownMenuSubContent.displayName =
55
+ DropdownMenuPrimitive.SubContent.displayName
56
+
57
+ const DropdownMenuContent = React.forwardRef<
58
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
59
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
60
+ >(({ className, sideOffset = 4, ...props }, ref) => (
61
+ <DropdownMenuPrimitive.Portal>
62
+ <DropdownMenuPrimitive.Content
63
+ ref={ref}
64
+ sideOffset={sideOffset}
65
+ className={cn(
66
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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",
67
+ className
68
+ )}
69
+ {...props}
70
+ />
71
+ </DropdownMenuPrimitive.Portal>
72
+ ))
73
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
74
+
75
+ const DropdownMenuItem = React.forwardRef<
76
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
77
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
78
+ inset?: boolean
79
+ }
80
+ >(({ className, inset, ...props }, ref) => (
81
+ <DropdownMenuPrimitive.Item
82
+ ref={ref}
83
+ className={cn(
84
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
85
+ inset && "pl-8",
86
+ className
87
+ )}
88
+ {...props}
89
+ />
90
+ ))
91
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
92
+
93
+ const DropdownMenuCheckboxItem = React.forwardRef<
94
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
95
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
96
+ >(({ className, children, checked, ...props }, ref) => (
97
+ <DropdownMenuPrimitive.CheckboxItem
98
+ ref={ref}
99
+ className={cn(
100
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
101
+ className
102
+ )}
103
+ checked={checked}
104
+ {...props}
105
+ >
106
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
107
+ <DropdownMenuPrimitive.ItemIndicator>
108
+ <Check className="h-4 w-4" />
109
+ </DropdownMenuPrimitive.ItemIndicator>
110
+ </span>
111
+ {children}
112
+ </DropdownMenuPrimitive.CheckboxItem>
113
+ ))
114
+ DropdownMenuCheckboxItem.displayName =
115
+ DropdownMenuPrimitive.CheckboxItem.displayName
116
+
117
+ const DropdownMenuRadioItem = React.forwardRef<
118
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
119
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
120
+ >(({ className, children, ...props }, ref) => (
121
+ <DropdownMenuPrimitive.RadioItem
122
+ ref={ref}
123
+ className={cn(
124
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
125
+ className
126
+ )}
127
+ {...props}
128
+ >
129
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
130
+ <DropdownMenuPrimitive.ItemIndicator>
131
+ <Circle className="h-2 w-2 fill-current" />
132
+ </DropdownMenuPrimitive.ItemIndicator>
133
+ </span>
134
+ {children}
135
+ </DropdownMenuPrimitive.RadioItem>
136
+ ))
137
+ DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
138
+
139
+ const DropdownMenuLabel = React.forwardRef<
140
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
141
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
142
+ inset?: boolean
143
+ }
144
+ >(({ className, inset, ...props }, ref) => (
145
+ <DropdownMenuPrimitive.Label
146
+ ref={ref}
147
+ className={cn(
148
+ "px-2 py-1.5 text-sm font-semibold",
149
+ inset && "pl-8",
150
+ className
151
+ )}
152
+ {...props}
153
+ />
154
+ ))
155
+ DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
156
+
157
+ const DropdownMenuSeparator = React.forwardRef<
158
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
159
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
160
+ >(({ className, ...props }, ref) => (
161
+ <DropdownMenuPrimitive.Separator
162
+ ref={ref}
163
+ className={cn("-mx-1 my-1 h-px bg-muted", className)}
164
+ {...props}
165
+ />
166
+ ))
167
+ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
168
+
169
+ const DropdownMenuShortcut = ({
170
+ className,
171
+ ...props
172
+ }: React.HTMLAttributes<HTMLSpanElement>) => {
173
+ return (
174
+ <span
175
+ className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
176
+ {...props}
177
+ />
178
+ )
179
+ }
180
+ DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
181
+
182
+ export {
183
+ DropdownMenu,
184
+ DropdownMenuTrigger,
185
+ DropdownMenuContent,
186
+ DropdownMenuItem,
187
+ DropdownMenuCheckboxItem,
188
+ DropdownMenuRadioItem,
189
+ DropdownMenuLabel,
190
+ DropdownMenuSeparator,
191
+ DropdownMenuShortcut,
192
+ DropdownMenuGroup,
193
+ DropdownMenuPortal,
194
+ DropdownMenuSub,
195
+ DropdownMenuSubContent,
196
+ DropdownMenuSubTrigger,
197
+ DropdownMenuRadioGroup,
198
+ }
frontend/src/components/ui/form.tsx ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as LabelPrimitive from "@radix-ui/react-label"
3
+ import { Slot } from "@radix-ui/react-slot"
4
+ import {
5
+ Controller,
6
+ ControllerProps,
7
+ FieldPath,
8
+ FieldValues,
9
+ FormProvider,
10
+ useFormContext,
11
+ } from "react-hook-form"
12
+
13
+ import { cn } from "@/lib/utils"
14
+ import { Label } from "@/components/ui/label"
15
+
16
+ const Form = FormProvider
17
+
18
+ type FormFieldContextValue<
19
+ TFieldValues extends FieldValues = FieldValues,
20
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
21
+ > = {
22
+ name: TName
23
+ }
24
+
25
+ const FormFieldContext = React.createContext<FormFieldContextValue>(
26
+ {} as FormFieldContextValue
27
+ )
28
+
29
+ const FormField = <
30
+ TFieldValues extends FieldValues = FieldValues,
31
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
32
+ >({
33
+ ...props
34
+ }: ControllerProps<TFieldValues, TName>) => {
35
+ return (
36
+ <FormFieldContext.Provider value={{ name: props.name }}>
37
+ <Controller {...props} />
38
+ </FormFieldContext.Provider>
39
+ )
40
+ }
41
+
42
+ const useFormField = () => {
43
+ const fieldContext = React.useContext(FormFieldContext)
44
+ const itemContext = React.useContext(FormItemContext)
45
+ const { getFieldState, formState } = useFormContext()
46
+
47
+ const fieldState = getFieldState(fieldContext.name, formState)
48
+
49
+ if (!fieldContext) {
50
+ throw new Error("useFormField should be used within <FormField>")
51
+ }
52
+
53
+ const { id } = itemContext
54
+
55
+ return {
56
+ id,
57
+ name: fieldContext.name,
58
+ formItemId: `${id}-form-item`,
59
+ formDescriptionId: `${id}-form-item-description`,
60
+ formMessageId: `${id}-form-item-message`,
61
+ ...fieldState,
62
+ }
63
+ }
64
+
65
+ type FormItemContextValue = {
66
+ id: string
67
+ }
68
+
69
+ const FormItemContext = React.createContext<FormItemContextValue>(
70
+ {} as FormItemContextValue
71
+ )
72
+
73
+ const FormItem = React.forwardRef<
74
+ HTMLDivElement,
75
+ React.HTMLAttributes<HTMLDivElement>
76
+ >(({ className, ...props }, ref) => {
77
+ const id = React.useId()
78
+
79
+ return (
80
+ <FormItemContext.Provider value={{ id }}>
81
+ <div ref={ref} className={cn("space-y-2", className)} {...props} />
82
+ </FormItemContext.Provider>
83
+ )
84
+ })
85
+ FormItem.displayName = "FormItem"
86
+
87
+ const FormLabel = React.forwardRef<
88
+ React.ElementRef<typeof LabelPrimitive.Root>,
89
+ React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
90
+ >(({ className, ...props }, ref) => {
91
+ const { error, formItemId } = useFormField()
92
+
93
+ return (
94
+ <Label
95
+ ref={ref}
96
+ className={cn(error && "text-destructive", className)}
97
+ htmlFor={formItemId}
98
+ {...props}
99
+ />
100
+ )
101
+ })
102
+ FormLabel.displayName = "FormLabel"
103
+
104
+ const FormControl = React.forwardRef<
105
+ React.ElementRef<typeof Slot>,
106
+ React.ComponentPropsWithoutRef<typeof Slot>
107
+ >(({ ...props }, ref) => {
108
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109
+
110
+ return (
111
+ <Slot
112
+ ref={ref}
113
+ id={formItemId}
114
+ aria-describedby={
115
+ !error
116
+ ? `${formDescriptionId}`
117
+ : `${formDescriptionId} ${formMessageId}`
118
+ }
119
+ aria-invalid={!!error}
120
+ {...props}
121
+ />
122
+ )
123
+ })
124
+ FormControl.displayName = "FormControl"
125
+
126
+ const FormDescription = React.forwardRef<
127
+ HTMLParagraphElement,
128
+ React.HTMLAttributes<HTMLParagraphElement>
129
+ >(({ className, ...props }, ref) => {
130
+ const { formDescriptionId } = useFormField()
131
+
132
+ return (
133
+ <p
134
+ ref={ref}
135
+ id={formDescriptionId}
136
+ className={cn("text-sm text-muted-foreground", className)}
137
+ {...props}
138
+ />
139
+ )
140
+ })
141
+ FormDescription.displayName = "FormDescription"
142
+
143
+ const FormMessage = React.forwardRef<
144
+ HTMLParagraphElement,
145
+ React.HTMLAttributes<HTMLParagraphElement>
146
+ >(({ className, children, ...props }, ref) => {
147
+ const { error, formMessageId } = useFormField()
148
+ const body = error ? String(error?.message) : children
149
+
150
+ if (!body) {
151
+ return null
152
+ }
153
+
154
+ return (
155
+ <p
156
+ ref={ref}
157
+ id={formMessageId}
158
+ className={cn("text-sm font-medium text-destructive", className)}
159
+ {...props}
160
+ >
161
+ {body}
162
+ </p>
163
+ )
164
+ })
165
+ FormMessage.displayName = "FormMessage"
166
+
167
+ export {
168
+ useFormField,
169
+ Form,
170
+ FormItem,
171
+ FormLabel,
172
+ FormControl,
173
+ FormDescription,
174
+ FormMessage,
175
+ FormField,
176
+ }
frontend/src/components/ui/hover-card.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const HoverCard = HoverCardPrimitive.Root
7
+
8
+ const HoverCardTrigger = HoverCardPrimitive.Trigger
9
+
10
+ const HoverCardContent = React.forwardRef<
11
+ React.ElementRef<typeof HoverCardPrimitive.Content>,
12
+ React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
13
+ >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14
+ <HoverCardPrimitive.Content
15
+ ref={ref}
16
+ align={align}
17
+ sideOffset={sideOffset}
18
+ className={cn(
19
+ "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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",
20
+ className
21
+ )}
22
+ {...props}
23
+ />
24
+ ))
25
+ HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
26
+
27
+ export { HoverCard, HoverCardTrigger, HoverCardContent }