Spaces:
Sleeping
Sleeping
Chandima Prabhath
commited on
Commit
·
22b1735
1
Parent(s):
beeb302
init
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- frontend/.gitignore +1 -0
- frontend/README.md +4 -4
- frontend/bun.lockb +2 -2
- frontend/index.html +2 -2
- frontend/netlify.toml +0 -6
- frontend/package-lock.json +21 -53
- frontend/package.json +7 -2
- frontend/src/App.css +11 -0
- frontend/src/App.tsx +41 -38
- frontend/src/components/ContentCard.tsx +0 -435
- frontend/src/components/ContentGrid.tsx +0 -45
- frontend/src/components/ContentRow.tsx +0 -107
- frontend/src/components/EpisodesPanel.tsx +0 -117
- frontend/src/components/Footer.tsx +0 -58
- frontend/src/components/HeroSection.tsx +0 -225
- frontend/src/components/MoviePlayer.tsx +0 -284
- frontend/src/components/Navbar.tsx +0 -157
- frontend/src/components/PageHeader.tsx +0 -18
- frontend/src/components/TVShowPlayer.tsx +0 -314
- frontend/src/components/VideoPlayer.tsx +0 -582
- frontend/src/components/VideoPlayerControls.tsx +0 -34
- frontend/src/components/WatchTogether.tsx +0 -314
- frontend/src/components/chat/ChatBubble.tsx +121 -0
- frontend/src/components/chat/ChatMessage.tsx +72 -0
- frontend/src/components/layout/MainLayout.tsx +24 -0
- frontend/src/components/layout/ModeToggle.tsx +38 -0
- frontend/src/components/ui/avatar.tsx +1 -0
- frontend/src/components/ui/sonner.tsx +2 -2
- frontend/src/index.css +264 -85
- frontend/src/lib/LoadBalancerAPI.js +0 -181
- frontend/src/lib/api.js +0 -159
- frontend/src/lib/remarkSource.ts +21 -0
- frontend/src/lib/search-api.ts +0 -64
- frontend/src/lib/storage.ts +111 -223
- frontend/src/lib/utils.ts +0 -10
- frontend/src/main.tsx +3 -4
- frontend/src/pages/HomePage.tsx +713 -156
- frontend/src/pages/Index.tsx +3 -12
- frontend/src/pages/MainLayout.tsx +0 -18
- frontend/src/pages/MovieDetailPage.tsx +0 -226
- frontend/src/pages/MoviePlayerPage.tsx +0 -66
- frontend/src/pages/MoviesPage.tsx +0 -92
- frontend/src/pages/MyListPage.tsx +0 -168
- frontend/src/pages/NotFound.tsx +29 -8
- frontend/src/pages/ProfilePage.tsx +0 -296
- frontend/src/pages/SearchPage.tsx +0 -199
- frontend/src/pages/SettingsPage.tsx +272 -0
- frontend/src/pages/SourcesPage.tsx +286 -0
- frontend/src/pages/TvShowDetailPage.tsx +0 -469
- frontend/src/pages/TvShowPlayerPage.tsx +0 -423
frontend/.gitignore
CHANGED
@@ -22,3 +22,4 @@ dist-ssr
|
|
22 |
*.njsproj
|
23 |
*.sln
|
24 |
*.sw?
|
|
|
|
22 |
*.njsproj
|
23 |
*.sln
|
24 |
*.sw?
|
25 |
+
yarn.lock
|
frontend/README.md
CHANGED
@@ -2,7 +2,7 @@
|
|
2 |
|
3 |
## Project info
|
4 |
|
5 |
-
**URL**: https://lovable.dev/projects/
|
6 |
|
7 |
## How can I edit this code?
|
8 |
|
@@ -10,7 +10,7 @@ There are several ways of editing your application.
|
|
10 |
|
11 |
**Use Lovable**
|
12 |
|
13 |
-
Simply visit the [Lovable Project](https://lovable.dev/projects/
|
14 |
|
15 |
Changes made via Lovable will be committed automatically to this repo.
|
16 |
|
@@ -62,11 +62,11 @@ This project is built with:
|
|
62 |
|
63 |
## How can I deploy this project?
|
64 |
|
65 |
-
Simply open [Lovable](https://lovable.dev/projects/
|
66 |
|
67 |
## Can I connect a custom domain to my Lovable project?
|
68 |
|
69 |
-
Yes
|
70 |
|
71 |
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
72 |
|
|
|
2 |
|
3 |
## Project info
|
4 |
|
5 |
+
**URL**: https://lovable.dev/projects/00b88f68-b44c-4b47-af08-79dc9c075da6
|
6 |
|
7 |
## How can I edit this code?
|
8 |
|
|
|
10 |
|
11 |
**Use Lovable**
|
12 |
|
13 |
+
Simply visit the [Lovable Project](https://lovable.dev/projects/00b88f68-b44c-4b47-af08-79dc9c075da6) and start prompting.
|
14 |
|
15 |
Changes made via Lovable will be committed automatically to this repo.
|
16 |
|
|
|
62 |
|
63 |
## How can I deploy this project?
|
64 |
|
65 |
+
Simply open [Lovable](https://lovable.dev/projects/00b88f68-b44c-4b47-af08-79dc9c075da6) and click on Share -> Publish.
|
66 |
|
67 |
## Can I connect a custom domain to my Lovable project?
|
68 |
|
69 |
+
Yes, you can!
|
70 |
|
71 |
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
72 |
|
frontend/bun.lockb
CHANGED
@@ -1,3 +1,3 @@
|
|
1 |
version https://git-lfs.github.com/spec/v1
|
2 |
-
oid sha256:
|
3 |
-
size
|
|
|
1 |
version https://git-lfs.github.com/spec/v1
|
2 |
+
oid sha256:a3c575fd4a99dc9d1d74a4a7a31b979d577c15f379bc5cb0dd7e823586e98c23
|
3 |
+
size 198351
|
frontend/index.html
CHANGED
@@ -3,11 +3,11 @@
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8" />
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
6 |
-
<title>
|
7 |
<meta name="description" content="Lovable Generated Project" />
|
8 |
<meta name="author" content="Lovable" />
|
9 |
|
10 |
-
<meta property="og:title" content="
|
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" />
|
|
|
3 |
<head>
|
4 |
<meta charset="UTF-8" />
|
5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
6 |
+
<title>ruling-insight-navigator</title>
|
7 |
<meta name="description" content="Lovable Generated Project" />
|
8 |
<meta name="author" content="Lovable" />
|
9 |
|
10 |
+
<meta property="og:title" content="ruling-insight-navigator" />
|
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" />
|
frontend/netlify.toml
DELETED
@@ -1,6 +0,0 @@
|
|
1 |
-
[build]
|
2 |
-
command = "npm run build"
|
3 |
-
publish = "dist"
|
4 |
-
|
5 |
-
[dev]
|
6 |
-
command = "npm run dev"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/package-lock.json
CHANGED
@@ -8,7 +8,6 @@
|
|
8 |
"name": "vite_react_shadcn_ts",
|
9 |
"version": "0.0.0",
|
10 |
"dependencies": {
|
11 |
-
"@heroicons/react": "^2.2.0",
|
12 |
"@hookform/resolvers": "^3.9.0",
|
13 |
"@radix-ui/react-accordion": "^1.2.0",
|
14 |
"@radix-ui/react-alert-dialog": "^1.1.1",
|
@@ -38,12 +37,12 @@
|
|
38 |
"@radix-ui/react-toggle-group": "^1.1.0",
|
39 |
"@radix-ui/react-tooltip": "^1.1.4",
|
40 |
"@tanstack/react-query": "^5.56.2",
|
|
|
41 |
"class-variance-authority": "^0.7.1",
|
42 |
"clsx": "^2.1.1",
|
43 |
"cmdk": "^1.0.0",
|
44 |
"date-fns": "^3.6.0",
|
45 |
"embla-carousel-react": "^8.3.0",
|
46 |
-
"framer-motion": "^12.6.3",
|
47 |
"input-otp": "^1.2.4",
|
48 |
"lucide-react": "^0.462.0",
|
49 |
"next-themes": "^0.3.0",
|
@@ -57,6 +56,7 @@
|
|
57 |
"sonner": "^1.5.0",
|
58 |
"tailwind-merge": "^2.5.2",
|
59 |
"tailwindcss-animate": "^1.0.7",
|
|
|
60 |
"vaul": "^0.9.3",
|
61 |
"zod": "^3.23.8"
|
62 |
},
|
@@ -751,15 +751,6 @@
|
|
751 |
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
|
752 |
"license": "MIT"
|
753 |
},
|
754 |
-
"node_modules/@heroicons/react": {
|
755 |
-
"version": "2.2.0",
|
756 |
-
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
|
757 |
-
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
|
758 |
-
"license": "MIT",
|
759 |
-
"peerDependencies": {
|
760 |
-
"react": ">= 16 || ^19.0.0-rc"
|
761 |
-
}
|
762 |
-
},
|
763 |
"node_modules/@hookform/resolvers": {
|
764 |
"version": "3.9.0",
|
765 |
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz",
|
@@ -2959,6 +2950,12 @@
|
|
2959 |
"@types/react": "*"
|
2960 |
}
|
2961 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
2962 |
"node_modules/@typescript-eslint/eslint-plugin": {
|
2963 |
"version": "8.11.0",
|
2964 |
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz",
|
@@ -4658,33 +4655,6 @@
|
|
4658 |
"url": "https://github.com/sponsors/rawify"
|
4659 |
}
|
4660 |
},
|
4661 |
-
"node_modules/framer-motion": {
|
4662 |
-
"version": "12.6.3",
|
4663 |
-
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.3.tgz",
|
4664 |
-
"integrity": "sha512-2hsqknz23aloK85bzMc9nSR2/JP+fValQ459ZTVElFQ0xgwR2YqNjYSuDZdFBPOwVCt4Q9jgyTt6hg6sVOALzw==",
|
4665 |
-
"license": "MIT",
|
4666 |
-
"dependencies": {
|
4667 |
-
"motion-dom": "^12.6.3",
|
4668 |
-
"motion-utils": "^12.6.3",
|
4669 |
-
"tslib": "^2.4.0"
|
4670 |
-
},
|
4671 |
-
"peerDependencies": {
|
4672 |
-
"@emotion/is-prop-valid": "*",
|
4673 |
-
"react": "^18.0.0 || ^19.0.0",
|
4674 |
-
"react-dom": "^18.0.0 || ^19.0.0"
|
4675 |
-
},
|
4676 |
-
"peerDependenciesMeta": {
|
4677 |
-
"@emotion/is-prop-valid": {
|
4678 |
-
"optional": true
|
4679 |
-
},
|
4680 |
-
"react": {
|
4681 |
-
"optional": true
|
4682 |
-
},
|
4683 |
-
"react-dom": {
|
4684 |
-
"optional": true
|
4685 |
-
}
|
4686 |
-
}
|
4687 |
-
},
|
4688 |
"node_modules/fsevents": {
|
4689 |
"version": "2.3.3",
|
4690 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
@@ -5651,21 +5621,6 @@
|
|
5651 |
"node": ">=16 || 14 >=14.17"
|
5652 |
}
|
5653 |
},
|
5654 |
-
"node_modules/motion-dom": {
|
5655 |
-
"version": "12.6.3",
|
5656 |
-
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.6.3.tgz",
|
5657 |
-
"integrity": "sha512-gRY08RjcnzgFYLemUZ1lo/e9RkBxR+6d4BRvoeZDSeArG4XQXERSPapKl3LNQRu22Sndjf1h+iavgY0O4NrYqA==",
|
5658 |
-
"license": "MIT",
|
5659 |
-
"dependencies": {
|
5660 |
-
"motion-utils": "^12.6.3"
|
5661 |
-
}
|
5662 |
-
},
|
5663 |
-
"node_modules/motion-utils": {
|
5664 |
-
"version": "12.6.3",
|
5665 |
-
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.6.3.tgz",
|
5666 |
-
"integrity": "sha512-R/b3Ia2VxtTNZ4LTEO5pKYau1OUNHOuUfxuP0WFCTDYdHkeTBR9UtxR1cc8mDmKr8PEhmmfnTKGz3rSMjNRoRg==",
|
5667 |
-
"license": "MIT"
|
5668 |
-
},
|
5669 |
"node_modules/ms": {
|
5670 |
"version": "2.1.3",
|
5671 |
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
@@ -7018,6 +6973,19 @@
|
|
7018 |
"dev": true,
|
7019 |
"license": "MIT"
|
7020 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7021 |
"node_modules/vaul": {
|
7022 |
"version": "0.9.9",
|
7023 |
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
|
|
|
8 |
"name": "vite_react_shadcn_ts",
|
9 |
"version": "0.0.0",
|
10 |
"dependencies": {
|
|
|
11 |
"@hookform/resolvers": "^3.9.0",
|
12 |
"@radix-ui/react-accordion": "^1.2.0",
|
13 |
"@radix-ui/react-alert-dialog": "^1.1.1",
|
|
|
37 |
"@radix-ui/react-toggle-group": "^1.1.0",
|
38 |
"@radix-ui/react-tooltip": "^1.1.4",
|
39 |
"@tanstack/react-query": "^5.56.2",
|
40 |
+
"@types/uuid": "^10.0.0",
|
41 |
"class-variance-authority": "^0.7.1",
|
42 |
"clsx": "^2.1.1",
|
43 |
"cmdk": "^1.0.0",
|
44 |
"date-fns": "^3.6.0",
|
45 |
"embla-carousel-react": "^8.3.0",
|
|
|
46 |
"input-otp": "^1.2.4",
|
47 |
"lucide-react": "^0.462.0",
|
48 |
"next-themes": "^0.3.0",
|
|
|
56 |
"sonner": "^1.5.0",
|
57 |
"tailwind-merge": "^2.5.2",
|
58 |
"tailwindcss-animate": "^1.0.7",
|
59 |
+
"uuid": "^11.1.0",
|
60 |
"vaul": "^0.9.3",
|
61 |
"zod": "^3.23.8"
|
62 |
},
|
|
|
751 |
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
|
752 |
"license": "MIT"
|
753 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
754 |
"node_modules/@hookform/resolvers": {
|
755 |
"version": "3.9.0",
|
756 |
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz",
|
|
|
2950 |
"@types/react": "*"
|
2951 |
}
|
2952 |
},
|
2953 |
+
"node_modules/@types/uuid": {
|
2954 |
+
"version": "10.0.0",
|
2955 |
+
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
2956 |
+
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
2957 |
+
"license": "MIT"
|
2958 |
+
},
|
2959 |
"node_modules/@typescript-eslint/eslint-plugin": {
|
2960 |
"version": "8.11.0",
|
2961 |
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz",
|
|
|
4655 |
"url": "https://github.com/sponsors/rawify"
|
4656 |
}
|
4657 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4658 |
"node_modules/fsevents": {
|
4659 |
"version": "2.3.3",
|
4660 |
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
|
5621 |
"node": ">=16 || 14 >=14.17"
|
5622 |
}
|
5623 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5624 |
"node_modules/ms": {
|
5625 |
"version": "2.1.3",
|
5626 |
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
|
6973 |
"dev": true,
|
6974 |
"license": "MIT"
|
6975 |
},
|
6976 |
+
"node_modules/uuid": {
|
6977 |
+
"version": "11.1.0",
|
6978 |
+
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
6979 |
+
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
6980 |
+
"funding": [
|
6981 |
+
"https://github.com/sponsors/broofa",
|
6982 |
+
"https://github.com/sponsors/ctavan"
|
6983 |
+
],
|
6984 |
+
"license": "MIT",
|
6985 |
+
"bin": {
|
6986 |
+
"uuid": "dist/esm/bin/uuid"
|
6987 |
+
}
|
6988 |
+
},
|
6989 |
"node_modules/vaul": {
|
6990 |
"version": "0.9.9",
|
6991 |
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
|
frontend/package.json
CHANGED
@@ -11,7 +11,6 @@
|
|
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",
|
@@ -41,12 +40,12 @@
|
|
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",
|
@@ -54,12 +53,18 @@
|
|
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 |
},
|
|
|
11 |
"preview": "vite preview"
|
12 |
},
|
13 |
"dependencies": {
|
|
|
14 |
"@hookform/resolvers": "^3.9.0",
|
15 |
"@radix-ui/react-accordion": "^1.2.0",
|
16 |
"@radix-ui/react-alert-dialog": "^1.1.1",
|
|
|
40 |
"@radix-ui/react-toggle-group": "^1.1.0",
|
41 |
"@radix-ui/react-tooltip": "^1.1.4",
|
42 |
"@tanstack/react-query": "^5.56.2",
|
43 |
+
"@types/uuid": "^10.0.0",
|
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 |
"input-otp": "^1.2.4",
|
50 |
"lucide-react": "^0.462.0",
|
51 |
"next-themes": "^0.3.0",
|
|
|
53 |
"react-day-picker": "^8.10.1",
|
54 |
"react-dom": "^18.3.1",
|
55 |
"react-hook-form": "^7.53.0",
|
56 |
+
"react-markdown": "^10.1.0",
|
57 |
"react-resizable-panels": "^2.1.3",
|
58 |
"react-router-dom": "^6.26.2",
|
59 |
"recharts": "^2.12.7",
|
60 |
+
"rehype-raw": "^7.0.0",
|
61 |
+
"remark-directive": "^4.0.0",
|
62 |
+
"remark-gfm": "^4.0.1",
|
63 |
"sonner": "^1.5.0",
|
64 |
"tailwind-merge": "^2.5.2",
|
65 |
"tailwindcss-animate": "^1.0.7",
|
66 |
+
"unist-util-visit": "^5.0.0",
|
67 |
+
"uuid": "^11.1.0",
|
68 |
"vaul": "^0.9.3",
|
69 |
"zod": "^3.23.8"
|
70 |
},
|
frontend/src/App.css
CHANGED
@@ -1,3 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
.logo {
|
2 |
height: 6em;
|
3 |
padding: 1.5em;
|
@@ -26,6 +33,10 @@
|
|
26 |
}
|
27 |
}
|
28 |
|
|
|
|
|
|
|
|
|
29 |
.read-the-docs {
|
30 |
color: #888;
|
31 |
}
|
|
|
1 |
+
#root {
|
2 |
+
max-width: 1280px;
|
3 |
+
margin: 0 auto;
|
4 |
+
padding: 2rem;
|
5 |
+
text-align: center;
|
6 |
+
}
|
7 |
+
|
8 |
.logo {
|
9 |
height: 6em;
|
10 |
padding: 1.5em;
|
|
|
33 |
}
|
34 |
}
|
35 |
|
36 |
+
.card {
|
37 |
+
padding: 2em;
|
38 |
+
}
|
39 |
+
|
40 |
.read-the-docs {
|
41 |
color: #888;
|
42 |
}
|
frontend/src/App.tsx
CHANGED
@@ -1,44 +1,47 @@
|
|
1 |
|
2 |
-
import
|
3 |
-
import {
|
4 |
-
import
|
5 |
-
import
|
6 |
-
import
|
7 |
-
import
|
8 |
-
import
|
9 |
-
import
|
10 |
-
import
|
11 |
-
import
|
12 |
-
import
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
return (
|
20 |
-
<
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
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;
|
|
|
1 |
|
2 |
+
import { Toaster } from "@/components/ui/toaster";
|
3 |
+
import { Toaster as Sonner } from "@/components/ui/sonner";
|
4 |
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
5 |
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
6 |
+
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
7 |
+
import { useEffect } from "react";
|
8 |
+
import MainLayout from "@/components/layout/MainLayout";
|
9 |
+
import HomePage from "@/pages/HomePage";
|
10 |
+
import SourcesPage from "@/pages/SourcesPage";
|
11 |
+
import SettingsPage from "@/pages/SettingsPage";
|
12 |
+
import NotFound from "@/pages/NotFound";
|
|
|
|
|
|
|
|
|
13 |
|
14 |
+
const queryClient = new QueryClient({
|
15 |
+
defaultOptions: {
|
16 |
+
queries: {
|
17 |
+
staleTime: 1000 * 60 * 5, // 5 minutes
|
18 |
+
retry: 1,
|
19 |
+
},
|
20 |
+
},
|
21 |
+
});
|
22 |
+
|
23 |
+
const App = () => {
|
24 |
+
// Set the window title
|
25 |
+
useEffect(() => {
|
26 |
+
document.title = "Financial Insight System (FIS)";
|
27 |
+
}, []);
|
28 |
+
|
29 |
return (
|
30 |
+
<QueryClientProvider client={queryClient}>
|
31 |
+
<TooltipProvider>
|
32 |
+
<Toaster />
|
33 |
+
<Sonner />
|
34 |
+
<BrowserRouter>
|
35 |
+
<Routes>
|
36 |
+
<Route path="/" element={<MainLayout><HomePage /></MainLayout>} />
|
37 |
+
<Route path="/sources" element={<MainLayout><SourcesPage /></MainLayout>} />
|
38 |
+
<Route path="/settings" element={<MainLayout><SettingsPage /></MainLayout>} />
|
39 |
+
<Route path="*" element={<NotFound />} />
|
40 |
+
</Routes>
|
41 |
+
</BrowserRouter>
|
42 |
+
</TooltipProvider>
|
43 |
+
</QueryClientProvider>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
);
|
45 |
+
};
|
46 |
|
47 |
export default App;
|
frontend/src/components/ContentCard.tsx
DELETED
@@ -1,435 +0,0 @@
|
|
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] h-full 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-[170px] md:h-[170px]">
|
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 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/40 to-theme-background-dark
|
359 |
-
transition-all duration-300 flex flex-col justify-between p-3 w-full h-full
|
360 |
-
${isHovered ? 'opacity-100 backdrop-blur-md' : 'opacity-0 pointer-events-none backdrop-blur-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
DELETED
@@ -1,45 +0,0 @@
|
|
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
DELETED
@@ -1,107 +0,0 @@
|
|
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
DELETED
@@ -1,117 +0,0 @@
|
|
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
DELETED
@@ -1,58 +0,0 @@
|
|
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
DELETED
@@ -1,225 +0,0 @@
|
|
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-netflix-black via-netflix-black/60 to-transparent" />
|
92 |
-
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-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-theme-primary text-white font-semibold hover:bg-theme-primary-hover 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-theme-primary-light w-5'
|
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
DELETED
@@ -1,284 +0,0 @@
|
|
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 |
-
import { MovieCardData } from './ContentCard';
|
8 |
-
|
9 |
-
interface ProgressData {
|
10 |
-
status: string;
|
11 |
-
progress: number;
|
12 |
-
downloaded: number;
|
13 |
-
total: number;
|
14 |
-
}
|
15 |
-
|
16 |
-
interface MoviePlayerProps {
|
17 |
-
movieTitle: string;
|
18 |
-
videoUrl?: string;
|
19 |
-
contentRatings?: any[];
|
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 |
-
poster,
|
33 |
-
startTime = 0,
|
34 |
-
onClosePlayer,
|
35 |
-
onProgressUpdate,
|
36 |
-
onVideoEnded,
|
37 |
-
showNextButton = false
|
38 |
-
}) => {
|
39 |
-
const [videoUrlState, setVideoUrlState] = useState<string | null>(videoUrl || null);
|
40 |
-
const [loading, setLoading] = useState(!videoUrl);
|
41 |
-
const [error, setError] = useState<string | null>(null);
|
42 |
-
const [progress, setProgress] = useState<ProgressData | null>(null);
|
43 |
-
const [videoFetched, setVideoFetched] = useState(!!videoUrl);
|
44 |
-
const [cardData, setCardData] = useState<MovieCardData | null>(null);
|
45 |
-
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
46 |
-
const [imageLoaded, setImageLoaded] = useState(false);
|
47 |
-
const { toast } = useToast();
|
48 |
-
|
49 |
-
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
50 |
-
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
51 |
-
const videoFetchedRef = useRef(!!videoUrl);
|
52 |
-
const [ratingInfo, setRatingInfo] = useState<{ rating: string; description: string } | null>(null);
|
53 |
-
const [currentTime, setCurrentTime] = useState(startTime);
|
54 |
-
const containerRef = useRef<HTMLDivElement>(null);
|
55 |
-
const videoRef = useRef<HTMLVideoElement>(null);
|
56 |
-
|
57 |
-
// Reset fade state when image changes
|
58 |
-
useEffect(() => {
|
59 |
-
setImageLoaded(false);
|
60 |
-
}, [selectedImage]);
|
61 |
-
|
62 |
-
// Update progress and propagate up
|
63 |
-
const handleProgressUpdate = (time: number, duration: number) => {
|
64 |
-
setCurrentTime(time);
|
65 |
-
onProgressUpdate?.(time, duration);
|
66 |
-
};
|
67 |
-
|
68 |
-
// Seek handler
|
69 |
-
const handleSeek = (time: number) => {
|
70 |
-
if (videoRef.current) {
|
71 |
-
videoRef.current.currentTime = time;
|
72 |
-
setCurrentTime(time);
|
73 |
-
}
|
74 |
-
};
|
75 |
-
|
76 |
-
// Random image selector
|
77 |
-
const selectRandomImage = (card: MovieCardData | null) => {
|
78 |
-
if (!card) return null;
|
79 |
-
if (card.banner && card.banner.length > 0) {
|
80 |
-
return card.banner[Math.floor(Math.random() * card.banner.length)].image;
|
81 |
-
}
|
82 |
-
if (card.portrait && card.portrait.length > 0) {
|
83 |
-
return card.portrait[Math.floor(Math.random() * card.portrait.length)].image;
|
84 |
-
}
|
85 |
-
return card.image;
|
86 |
-
};
|
87 |
-
|
88 |
-
// Fetch movie link or start polling
|
89 |
-
const fetchMovieLink = async () => {
|
90 |
-
if (videoFetchedRef.current || videoUrlState) return;
|
91 |
-
|
92 |
-
try {
|
93 |
-
const response = await getMovieLinkByTitle(movieTitle);
|
94 |
-
if (response.url) {
|
95 |
-
pollingIntervalRef.current && clearInterval(pollingIntervalRef.current);
|
96 |
-
setVideoUrlState(response.url);
|
97 |
-
setVideoFetched(true);
|
98 |
-
videoFetchedRef.current = true;
|
99 |
-
setLoading(false);
|
100 |
-
} else if (response.progress_url) {
|
101 |
-
if (!pollingIntervalRef.current) {
|
102 |
-
pollingIntervalRef.current = setInterval(async () => {
|
103 |
-
try {
|
104 |
-
const res = await fetch(response.progress_url!);
|
105 |
-
const data = await res.json();
|
106 |
-
setProgress(data.progress);
|
107 |
-
if (data.progress.progress >= 100) {
|
108 |
-
clearInterval(pollingIntervalRef.current!);
|
109 |
-
timeoutRef.current = setTimeout(fetchMovieLink, 5000);
|
110 |
-
}
|
111 |
-
} catch (e) {
|
112 |
-
console.error(e);
|
113 |
-
}
|
114 |
-
}, 2000);
|
115 |
-
}
|
116 |
-
} else {
|
117 |
-
throw new Error('No URL or progress URL');
|
118 |
-
}
|
119 |
-
} catch (e) {
|
120 |
-
console.error('Error fetching movie link:', e);
|
121 |
-
setError('Failed to load video');
|
122 |
-
toast({ title: 'Error', description: 'Could not load the video', variant: 'destructive' });
|
123 |
-
setLoading(false);
|
124 |
-
}
|
125 |
-
};
|
126 |
-
|
127 |
-
// Fetch card data & ratings
|
128 |
-
useEffect(() => {
|
129 |
-
const fetchCard = async () => {
|
130 |
-
try {
|
131 |
-
const movieData = await getMovieCard(movieTitle);
|
132 |
-
setCardData(movieData);
|
133 |
-
const img = selectRandomImage(movieData);
|
134 |
-
setSelectedImage(img);
|
135 |
-
// Poster fallback
|
136 |
-
if (!poster) {
|
137 |
-
poster = movieData.image || poster;
|
138 |
-
}
|
139 |
-
// Ratings
|
140 |
-
const ratings = contentRatings && contentRatings.length > 0
|
141 |
-
? contentRatings
|
142 |
-
: movieData.content_ratings || [];
|
143 |
-
if (ratings.length) {
|
144 |
-
const us = ratings.find((r: any) => r.country === 'usa') || ratings[0];
|
145 |
-
setRatingInfo({ rating: us.name || 'NR', description: us.description || '' });
|
146 |
-
}
|
147 |
-
} catch (e) {
|
148 |
-
console.error('Failed to fetch movie card:', e);
|
149 |
-
}
|
150 |
-
};
|
151 |
-
fetchCard();
|
152 |
-
}, [movieTitle, contentRatings, poster]);
|
153 |
-
|
154 |
-
// Initial link fetch / cleanup
|
155 |
-
useEffect(() => {
|
156 |
-
if (!videoUrlState) {
|
157 |
-
fetchMovieLink();
|
158 |
-
} else {
|
159 |
-
setVideoFetched(true);
|
160 |
-
videoFetchedRef.current = true;
|
161 |
-
setLoading(false);
|
162 |
-
}
|
163 |
-
return () => {
|
164 |
-
pollingIntervalRef.current && clearInterval(pollingIntervalRef.current);
|
165 |
-
timeoutRef.current && clearTimeout(timeoutRef.current);
|
166 |
-
};
|
167 |
-
}, [movieTitle, videoUrlState]);
|
168 |
-
|
169 |
-
// Sync loading state
|
170 |
-
useEffect(() => {
|
171 |
-
if (videoUrlState) setLoading(false);
|
172 |
-
}, [videoUrlState]);
|
173 |
-
|
174 |
-
// Error UI
|
175 |
-
if (error) {
|
176 |
-
return (
|
177 |
-
<div className="fixed inset-0 z-50 bg-black flex flex-col items-center justify-center">
|
178 |
-
<div className="text-4xl mb-4 text-theme-error">😢</div>
|
179 |
-
<h2 className="text-2xl font-bold mb-2 text-white">Error Playing Movie</h2>
|
180 |
-
<p className="text-gray-400 mb-6">{error}</p>
|
181 |
-
<button
|
182 |
-
onClick={onClosePlayer}
|
183 |
-
className="px-6 py-2 bg-theme-primary hover:bg-theme-primary-hover rounded font-medium transition-colors text-white"
|
184 |
-
>
|
185 |
-
Back to Browse
|
186 |
-
</button>
|
187 |
-
</div>
|
188 |
-
);
|
189 |
-
}
|
190 |
-
|
191 |
-
// Loading / preparing UI with fade‑in backdrop
|
192 |
-
if (loading || !videoFetched || !videoUrlState) {
|
193 |
-
return (
|
194 |
-
<>
|
195 |
-
<div className="relative w-full h-full">
|
196 |
-
<div className="absolute inset-0">
|
197 |
-
<img
|
198 |
-
src={selectedImage}
|
199 |
-
onLoad={() => setImageLoaded(true)}
|
200 |
-
onError={(e) => {
|
201 |
-
(e.target as HTMLImageElement).src = '/placeholder.svg';
|
202 |
-
}}
|
203 |
-
className={`w-full h-full object-cover transition-opacity duration-700 ease-in-out ${
|
204 |
-
imageLoaded ? 'opacity-100' : 'opacity-0'
|
205 |
-
}`}
|
206 |
-
/>
|
207 |
-
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/50 to-transparent" />
|
208 |
-
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" />
|
209 |
-
</div>
|
210 |
-
</div>
|
211 |
-
<div className="fixed inset-0 z-50 flex flex-col items-center backdrop-blur-sm justify-center">
|
212 |
-
<div className="text-center max-w-md px-6">
|
213 |
-
<div className="mb-6 flex justify-center">
|
214 |
-
{poster ? (
|
215 |
-
<img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" />
|
216 |
-
) : (
|
217 |
-
<div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg">
|
218 |
-
<Play className="h-12 w-12 text-theme-primary" />
|
219 |
-
</div>
|
220 |
-
)}
|
221 |
-
</div>
|
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 |
-
{progress ? (
|
229 |
-
<>
|
230 |
-
<p className="text-gray-300 mb-4">
|
231 |
-
{progress.progress < 5
|
232 |
-
? 'Initializing your stream...'
|
233 |
-
: progress.progress < 100
|
234 |
-
? 'Your stream is being prepared.'
|
235 |
-
: 'Almost ready! Starting playback soon...'}
|
236 |
-
</p>
|
237 |
-
<div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
|
238 |
-
<div
|
239 |
-
className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300"
|
240 |
-
style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }}
|
241 |
-
/>
|
242 |
-
</div>
|
243 |
-
<p className="text-sm text-gray-400">
|
244 |
-
{Math.round(progress.progress)}% complete
|
245 |
-
</p>
|
246 |
-
</>
|
247 |
-
) : (
|
248 |
-
<div className="flex justify-center">
|
249 |
-
<Loader2 className="h-8 w-8 animate-spin text-theme-primary" />
|
250 |
-
</div>
|
251 |
-
)}
|
252 |
-
</div>
|
253 |
-
</div>
|
254 |
-
</>
|
255 |
-
);
|
256 |
-
}
|
257 |
-
|
258 |
-
// Playback UI
|
259 |
-
return (
|
260 |
-
<div ref={containerRef} className="fixed inset-0 h-screen w-screen overflow-hidden">
|
261 |
-
<VideoPlayer
|
262 |
-
url={videoUrlState}
|
263 |
-
title={movieTitle}
|
264 |
-
poster={selectedImage || undefined}
|
265 |
-
startTime={startTime}
|
266 |
-
onClose={onClosePlayer}
|
267 |
-
onProgressUpdate={handleProgressUpdate}
|
268 |
-
onVideoEnded={onVideoEnded}
|
269 |
-
showNextButton={showNextButton}
|
270 |
-
contentRating={ratingInfo}
|
271 |
-
containerRef={containerRef}
|
272 |
-
videoRef={videoRef}
|
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
DELETED
@@ -1,157 +0,0 @@
|
|
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
DELETED
@@ -1,18 +0,0 @@
|
|
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
DELETED
@@ -1,314 +0,0 @@
|
|
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 |
-
import { TvShowCardData } from './ContentCard';
|
7 |
-
|
8 |
-
interface ProgressData {
|
9 |
-
status: string;
|
10 |
-
progress: number;
|
11 |
-
downloaded: number;
|
12 |
-
total: number;
|
13 |
-
}
|
14 |
-
|
15 |
-
interface ContentRating {
|
16 |
-
country: string;
|
17 |
-
name: string;
|
18 |
-
description: string;
|
19 |
-
}
|
20 |
-
|
21 |
-
interface TVShowPlayerProps {
|
22 |
-
videoTitle: string;
|
23 |
-
season: string;
|
24 |
-
episode: string;
|
25 |
-
movieTitle: string;
|
26 |
-
contentRatings?: ContentRating[];
|
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 |
-
poster,
|
42 |
-
startTime = 0,
|
43 |
-
onClosePlayer,
|
44 |
-
onProgressUpdate,
|
45 |
-
onVideoEnded,
|
46 |
-
onShowEpisodes
|
47 |
-
}) => {
|
48 |
-
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
49 |
-
const [loading, setLoading] = useState(true);
|
50 |
-
const [error, setError] = useState<string | null>(null);
|
51 |
-
const [progress, setProgress] = useState<ProgressData | null>(null);
|
52 |
-
const [videoFetched, setVideoFetched] = useState(false);
|
53 |
-
const [showData, setShowData] = useState<TvShowCardData | null>(null);
|
54 |
-
const [selectedImage, setSelectedImage] = useState<string>();
|
55 |
-
const [imageLoaded, setImageLoaded] = useState(false);
|
56 |
-
const [ratingInfo, setRatingInfo] = useState<{ rating: string; description: string } | null>(null);
|
57 |
-
const containerRef = useRef<HTMLDivElement>(null);
|
58 |
-
const videoRef = useRef<HTMLVideoElement>(null);
|
59 |
-
|
60 |
-
const { toast } = useToast();
|
61 |
-
const pollingInterval = useRef<NodeJS.Timeout | null>(null);
|
62 |
-
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
63 |
-
const videoFetchedRef = useRef(false);
|
64 |
-
|
65 |
-
// Reset imageLoaded whenever we pick a new image
|
66 |
-
useEffect(() => {
|
67 |
-
setImageLoaded(false);
|
68 |
-
}, [selectedImage]);
|
69 |
-
|
70 |
-
// Parse episode info
|
71 |
-
const getEpisodeInfo = () => {
|
72 |
-
if (!episode) return { number: '1', title: 'Unknown Episode' };
|
73 |
-
const match = episode.match(/E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i);
|
74 |
-
return {
|
75 |
-
number: match ? match[1] : '1',
|
76 |
-
title: match ? match[2].trim() : 'Unknown Episode'
|
77 |
-
};
|
78 |
-
};
|
79 |
-
const { number: episodeNumber, title: episodeTitle } = getEpisodeInfo();
|
80 |
-
|
81 |
-
// Random image selector with fallback
|
82 |
-
const selectRandomImage = (cardData: TvShowCardData) => {
|
83 |
-
if (cardData.banner?.length) {
|
84 |
-
return cardData.banner[Math.floor(Math.random() * cardData.banner.length)].image;
|
85 |
-
}
|
86 |
-
if (cardData.portrait?.length) {
|
87 |
-
return cardData.portrait[Math.floor(Math.random() * cardData.portrait.length)].image;
|
88 |
-
}
|
89 |
-
return cardData.image;
|
90 |
-
};
|
91 |
-
|
92 |
-
// Fetch or poll for the video URL
|
93 |
-
const fetchMovieLink = async () => {
|
94 |
-
if (videoFetchedRef.current) return;
|
95 |
-
try {
|
96 |
-
const response = await getEpisodeLinkByTitle(videoTitle, season, episode);
|
97 |
-
if (response.url) {
|
98 |
-
pollingInterval.current && clearInterval(pollingInterval.current);
|
99 |
-
setVideoUrl(response.url);
|
100 |
-
setVideoFetched(true);
|
101 |
-
videoFetchedRef.current = true;
|
102 |
-
setLoading(false);
|
103 |
-
} else if (response.progress_url) {
|
104 |
-
const poll = async () => {
|
105 |
-
try {
|
106 |
-
const res = await fetch(response.progress_url);
|
107 |
-
const data = await res.json();
|
108 |
-
setProgress(data.progress);
|
109 |
-
if (data.progress.progress >= 100) {
|
110 |
-
pollingInterval.current && clearInterval(pollingInterval.current);
|
111 |
-
timeoutRef.current = setTimeout(fetchMovieLink, 5000);
|
112 |
-
}
|
113 |
-
} catch (e) {
|
114 |
-
console.error(e);
|
115 |
-
}
|
116 |
-
};
|
117 |
-
pollingInterval.current = setInterval(poll, 2000);
|
118 |
-
} else {
|
119 |
-
throw new Error('No URL or progress URL');
|
120 |
-
}
|
121 |
-
} catch (e) {
|
122 |
-
console.error(e);
|
123 |
-
setError('Failed to load episode');
|
124 |
-
toast({ title: 'Error', description: 'Could not load the episode', variant: 'destructive' });
|
125 |
-
setLoading(false);
|
126 |
-
}
|
127 |
-
};
|
128 |
-
|
129 |
-
// Main init: fetch TV show card, then fetch link
|
130 |
-
useEffect(() => {
|
131 |
-
if (!videoTitle || !season || !episode) {
|
132 |
-
setError('Missing required video information');
|
133 |
-
setLoading(false);
|
134 |
-
return;
|
135 |
-
}
|
136 |
-
|
137 |
-
setLoading(true);
|
138 |
-
setError(null);
|
139 |
-
setVideoUrl(null);
|
140 |
-
setVideoFetched(false);
|
141 |
-
videoFetchedRef.current = false;
|
142 |
-
setProgress(null);
|
143 |
-
|
144 |
-
const init = async () => {
|
145 |
-
try {
|
146 |
-
const data = await getTvShowCard(videoTitle);
|
147 |
-
setShowData(data);
|
148 |
-
const img = selectRandomImage(data);
|
149 |
-
setSelectedImage(img);
|
150 |
-
const ratings = data.data?.contentRatings || contentRatings || [];
|
151 |
-
if (ratings.length) {
|
152 |
-
const us = ratings.find(r => r.country === 'usa') || ratings[0];
|
153 |
-
setRatingInfo({ rating: us.name || 'NR', description: us.description || '' });
|
154 |
-
}
|
155 |
-
} catch (e) {
|
156 |
-
console.error('Show card fetch error:', e);
|
157 |
-
setError('Failed to load show data');
|
158 |
-
toast({ title: 'Error', description: 'Could not load show data', variant: 'destructive' });
|
159 |
-
setLoading(false);
|
160 |
-
return;
|
161 |
-
}
|
162 |
-
await fetchMovieLink();
|
163 |
-
};
|
164 |
-
|
165 |
-
init();
|
166 |
-
|
167 |
-
return () => {
|
168 |
-
pollingInterval.current && clearInterval(pollingInterval.current);
|
169 |
-
timeoutRef.current && clearTimeout(timeoutRef.current);
|
170 |
-
};
|
171 |
-
}, [videoTitle, season, episode]);
|
172 |
-
|
173 |
-
useEffect(() => {
|
174 |
-
if (videoUrl) setLoading(false);
|
175 |
-
}, [videoUrl]);
|
176 |
-
|
177 |
-
if (error) {
|
178 |
-
return (
|
179 |
-
<div className="flex flex-col items-center justify-center min-h-screen bg-black text-white">
|
180 |
-
<div className="text-4xl mb-4 text-theme-error">😢</div>
|
181 |
-
<h2 className="text-2xl font-bold mb-2">Error Playing Episode</h2>
|
182 |
-
<p className="text-gray-400 mb-6">{error}</p>
|
183 |
-
<button
|
184 |
-
onClick={onClosePlayer}
|
185 |
-
className="px-6 py-2 bg-theme-primary hover:bg-theme-primary-hover rounded font-medium"
|
186 |
-
>
|
187 |
-
Back to Show
|
188 |
-
</button>
|
189 |
-
</div>
|
190 |
-
);
|
191 |
-
}
|
192 |
-
|
193 |
-
if (loading || !videoFetched || !videoUrl) {
|
194 |
-
return (
|
195 |
-
<>
|
196 |
-
{/* Hero backdrop with fade-in */}
|
197 |
-
<div className="absolute top-0 left-0 w-full h-full z-50">
|
198 |
-
<div className="absolute inset-0">
|
199 |
-
<img
|
200 |
-
src={selectedImage}
|
201 |
-
onLoad={() => setImageLoaded(true)}
|
202 |
-
onError={(e) => {
|
203 |
-
const target = e.target as HTMLImageElement;
|
204 |
-
target.src = '/placeholder.svg';
|
205 |
-
}}
|
206 |
-
className={`w-full h-full object-cover transition-opacity duration-700 ease-in-out ${
|
207 |
-
imageLoaded ? 'opacity-100' : 'opacity-0'
|
208 |
-
}`}
|
209 |
-
/>
|
210 |
-
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/50 to-transparent" />
|
211 |
-
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" />
|
212 |
-
</div>
|
213 |
-
</div>
|
214 |
-
<div className="fixed inset-0 z-50 flex flex-col items-center backdrop-blur-sm justify-center">
|
215 |
-
<div className="text-center max-w-md px-6">
|
216 |
-
<div className="mb-6 flex justify-center">
|
217 |
-
{poster ? (
|
218 |
-
<img src={poster} alt={movieTitle} className="h-auto w-24 rounded-lg shadow-lg" />
|
219 |
-
) : (
|
220 |
-
<div className="flex items-center justify-center h-24 w-24 bg-theme-primary/20 rounded-lg">
|
221 |
-
<Play className="h-12 w-12 text-theme-primary" />
|
222 |
-
</div>
|
223 |
-
)}
|
224 |
-
</div>
|
225 |
-
|
226 |
-
<h2 className="text-2xl md:text-3xl font-bold text-white mb-4">
|
227 |
-
{progress && progress.progress < 100
|
228 |
-
? `Preparing "${episodeTitle}"`
|
229 |
-
: `Loading "${episodeTitle}"`
|
230 |
-
}
|
231 |
-
</h2>
|
232 |
-
|
233 |
-
{progress ? (
|
234 |
-
<>
|
235 |
-
<p className="text-gray-300 mb-4">
|
236 |
-
{progress.progress < 5
|
237 |
-
? 'Initializing your stream...'
|
238 |
-
: progress.progress < 100
|
239 |
-
? 'Your stream is being prepared.'
|
240 |
-
: 'Almost ready! Starting playback soon...'}
|
241 |
-
</p>
|
242 |
-
<div className="relative w-full h-2 bg-gray-800 rounded-full overflow-hidden mb-2">
|
243 |
-
<div
|
244 |
-
className="absolute top-0 left-0 h-full bg-gradient-to-r from-theme-primary to-theme-primary-light transition-all duration-300"
|
245 |
-
style={{ width: `${Math.min(100, Math.max(0, progress.progress))}%` }}
|
246 |
-
/>
|
247 |
-
</div>
|
248 |
-
<p className="text-sm text-gray-400">
|
249 |
-
{Math.round(progress.progress)}% complete
|
250 |
-
</p>
|
251 |
-
</>
|
252 |
-
) : (
|
253 |
-
<div className="flex justify-center">
|
254 |
-
<Loader2 className="h-8 w-8 animate-spin text-theme-primary" />
|
255 |
-
</div>
|
256 |
-
)}
|
257 |
-
</div>
|
258 |
-
</div>
|
259 |
-
</>
|
260 |
-
);
|
261 |
-
}
|
262 |
-
|
263 |
-
const tvShowOverlay = (
|
264 |
-
<>
|
265 |
-
<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">
|
266 |
-
<div>
|
267 |
-
<div className="flex items-center">
|
268 |
-
<Film className="text-primary mr-2" size={20} />
|
269 |
-
<span className="text-white text-sm font-medium truncate">
|
270 |
-
{videoTitle}
|
271 |
-
</span>
|
272 |
-
<span className="mx-2 text-gray-400">•</span>
|
273 |
-
<span className="text-white text-sm">
|
274 |
-
{season} • Episode {episodeNumber}
|
275 |
-
</span>
|
276 |
-
</div>
|
277 |
-
<h1 className="text-white text-lg font-bold">{episodeTitle}</h1>
|
278 |
-
</div>
|
279 |
-
</div>
|
280 |
-
|
281 |
-
<div className="absolute top-4 right-16 z-20">
|
282 |
-
<button
|
283 |
-
onClick={onShowEpisodes}
|
284 |
-
className="bg-gray-800/80 hover:bg-gray-700/80 p-2 rounded-full transition-colors"
|
285 |
-
title="Show Episodes"
|
286 |
-
>
|
287 |
-
<GalleryVerticalEnd className="text-white" size={20} />
|
288 |
-
</button>
|
289 |
-
</div>
|
290 |
-
</>
|
291 |
-
);
|
292 |
-
|
293 |
-
return (
|
294 |
-
<div ref={containerRef} className="fixed inset-0 w-screen h-screen overflow-hidden">
|
295 |
-
<VideoPlayer
|
296 |
-
url={videoUrl}
|
297 |
-
title={`${videoTitle} - ${season}E${episodeNumber}`}
|
298 |
-
poster={selectedImage}
|
299 |
-
startTime={startTime}
|
300 |
-
onClose={onClosePlayer}
|
301 |
-
onProgressUpdate={onProgressUpdate}
|
302 |
-
onVideoEnded={onVideoEnded}
|
303 |
-
showNextButton={true}
|
304 |
-
contentRating={ratingInfo}
|
305 |
-
hideTitleInPlayer={true}
|
306 |
-
customOverlay={tvShowOverlay}
|
307 |
-
containerRef={containerRef}
|
308 |
-
videoRef={videoRef}
|
309 |
-
/>
|
310 |
-
</div>
|
311 |
-
);
|
312 |
-
};
|
313 |
-
|
314 |
-
export default TVShowPlayer;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/VideoPlayer.tsx
DELETED
@@ -1,582 +0,0 @@
|
|
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
DELETED
@@ -1,34 +0,0 @@
|
|
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
DELETED
@@ -1,314 +0,0 @@
|
|
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/chat/ChatBubble.tsx
ADDED
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { useState } from "react";
|
3 |
+
import { ArrowRight, RefreshCcw, Copy, Check } from "lucide-react";
|
4 |
+
import { Button } from "@/components/ui/button";
|
5 |
+
import { cn } from "@/lib/utils";
|
6 |
+
import { format } from "date-fns";
|
7 |
+
import { Avatar, AvatarFallback } from "../ui/avatar";
|
8 |
+
import { ChatMessage } from "./ChatMessage";
|
9 |
+
import { toast } from '../ui/sonner'
|
10 |
+
|
11 |
+
interface Message {
|
12 |
+
id: string;
|
13 |
+
content: string;
|
14 |
+
sender: "user" | "system";
|
15 |
+
timestamp: Date;
|
16 |
+
isLoading?: boolean;
|
17 |
+
error?: boolean;
|
18 |
+
result?: any;
|
19 |
+
}
|
20 |
+
|
21 |
+
interface ChatBubbleProps {
|
22 |
+
message: Message;
|
23 |
+
onViewSearchResults?: (messageId: string) => void;
|
24 |
+
onRetry?: (messageId: string) => void;
|
25 |
+
}
|
26 |
+
|
27 |
+
|
28 |
+
export const ChatBubble = ({ message, onViewSearchResults, onRetry }: ChatBubbleProps) => {
|
29 |
+
const [copied, setCopied] = useState(false)
|
30 |
+
const isSystem = message.sender === "system";
|
31 |
+
const showCopyButton = true
|
32 |
+
const copyToClipboard = () => {
|
33 |
+
navigator.clipboard.writeText(message.content)
|
34 |
+
setCopied(true)
|
35 |
+
toast.success('Copied to clipboard!')
|
36 |
+
setTimeout(() => setCopied(false), 2000)
|
37 |
+
}
|
38 |
+
|
39 |
+
return (
|
40 |
+
<div
|
41 |
+
className={cn(
|
42 |
+
"group flex w-full animate-slide-in mb-4",
|
43 |
+
isSystem ? "justify-start" : "justify-end"
|
44 |
+
)}
|
45 |
+
>
|
46 |
+
<div className={cn(
|
47 |
+
"flex gap-3 max-w-[80%]",
|
48 |
+
isSystem ? "flex-row" : "flex-row-reverse"
|
49 |
+
)}>
|
50 |
+
<Avatar className={cn(
|
51 |
+
"h-8 w-8 border",
|
52 |
+
isSystem
|
53 |
+
? "bg-financial-accent text-white shadow-lg"
|
54 |
+
: "bg-muted"
|
55 |
+
)}>
|
56 |
+
<AvatarFallback className="text-xs font-semibold">
|
57 |
+
{isSystem ? "AI" : "You"}
|
58 |
+
</AvatarFallback>
|
59 |
+
</Avatar>
|
60 |
+
|
61 |
+
<div className="flex flex-col">
|
62 |
+
<div className={cn(
|
63 |
+
"rounded-2xl shadow-lg message-bubble backdrop-blur-sm",
|
64 |
+
isSystem
|
65 |
+
? "bg-white/90 dark:bg-card/90 border border-border text-foreground message-bubble-ai"
|
66 |
+
: "bg-financial-accent/30 border border-financial-accent/30 text-white message-bubble-user",
|
67 |
+
message.error && "border-destructive dark:border-red-500"
|
68 |
+
)}>
|
69 |
+
{message.isLoading ? (
|
70 |
+
<div className="typing-indicator flex items-center space-x-1 px-2">
|
71 |
+
<span></span>
|
72 |
+
<span></span>
|
73 |
+
<span></span>
|
74 |
+
</div>
|
75 |
+
) : (
|
76 |
+
<ChatMessage
|
77 |
+
content={message.content}
|
78 |
+
/>
|
79 |
+
)}
|
80 |
+
|
81 |
+
</div>
|
82 |
+
{/* Chat bubble footer */}
|
83 |
+
<div className="flex flex-row chat-bubble-footer justify-between">
|
84 |
+
{/* Time */}
|
85 |
+
<div className={cn(
|
86 |
+
"text-xs text-muted-foreground mt-1",
|
87 |
+
isSystem ? "text-left" : "text-right"
|
88 |
+
)}>
|
89 |
+
{format(message.timestamp, "h:mm a")}
|
90 |
+
</div>
|
91 |
+
{/* Controls */}
|
92 |
+
<div className="controls flex ">
|
93 |
+
{/* Retry button for failed messages */}
|
94 |
+
{message.error && onRetry && (
|
95 |
+
<div className="flex justify-end mt-1">
|
96 |
+
<Button
|
97 |
+
variant="secondary"
|
98 |
+
size="sm"
|
99 |
+
onClick={() => onRetry(message.id)}
|
100 |
+
className="text-xs flex items-center gap-1.5 text-muted-foreground hover:border border-financial-accent/30 bg-background/50 backdrop-blur-sm"
|
101 |
+
>
|
102 |
+
<RefreshCcw className="h-3 w-3" />
|
103 |
+
Retry
|
104 |
+
</Button>
|
105 |
+
</div>
|
106 |
+
)}
|
107 |
+
{/* Copy */}
|
108 |
+
{showCopyButton && (
|
109 |
+
<div className="relative top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
110 |
+
<Button variant="link" size="sm" onClick={copyToClipboard}>
|
111 |
+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
112 |
+
</Button>
|
113 |
+
</div>
|
114 |
+
)}
|
115 |
+
</div>
|
116 |
+
</div>
|
117 |
+
</div>
|
118 |
+
</div>
|
119 |
+
</div>
|
120 |
+
);
|
121 |
+
};
|
frontend/src/components/chat/ChatMessage.tsx
ADDED
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// src/components/ChatMessage.tsx
|
2 |
+
import React, { useState, useMemo } from 'react'
|
3 |
+
import ReactMarkdown from 'react-markdown'
|
4 |
+
import remarkGfm from 'remark-gfm'
|
5 |
+
import rehypeRaw from 'rehype-raw'
|
6 |
+
import { cn } from '@/lib/utils'
|
7 |
+
import { toast } from '../ui/sonner'
|
8 |
+
|
9 |
+
interface ChatMessageProps {
|
10 |
+
content: string
|
11 |
+
className?: string
|
12 |
+
}
|
13 |
+
|
14 |
+
export const ChatMessage: React.FC<ChatMessageProps> = ({
|
15 |
+
content,
|
16 |
+
className,
|
17 |
+
}) => {
|
18 |
+
|
19 |
+
// ←––– THIS MEMO does the magic replace
|
20 |
+
const mdWithBadges = useMemo(() => {
|
21 |
+
return content.replace(
|
22 |
+
/<source\s+path=["'](.+?)["']\s*\/>/g,
|
23 |
+
(_match, path) => {
|
24 |
+
const filename = path
|
25 |
+
.split('/')
|
26 |
+
.pop()!
|
27 |
+
.replace(/\.[^/.]+$/, '')
|
28 |
+
// embed your ExternalLink SVG inline so you get the icon
|
29 |
+
return `<a href="${path}" target="_blank" class="inline-flex items-center text-xs font-medium mx-0.5 rounded-sm px-1 bg-financial-accent/10 text-financial-accent border border-financial-accent/20 hover:bg-financial-accent/20 transition-colors">
|
30 |
+
${filename}
|
31 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
32 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M18 13v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6m0 0v6m0-6L10 14"/>
|
33 |
+
</svg>
|
34 |
+
</a>`
|
35 |
+
}
|
36 |
+
)
|
37 |
+
}, [content])
|
38 |
+
|
39 |
+
return (
|
40 |
+
<div
|
41 |
+
className={cn(
|
42 |
+
'group relative w-full rounded-md p-2 hover:bg-muted/30 transition-colors',
|
43 |
+
className
|
44 |
+
)}
|
45 |
+
>
|
46 |
+
<ReactMarkdown
|
47 |
+
remarkPlugins={[remarkGfm]}
|
48 |
+
rehypePlugins={[rehypeRaw]}
|
49 |
+
components={{
|
50 |
+
// style your normal links if you like
|
51 |
+
a: ({ href, children, node, ...props }) =>
|
52 |
+
href && href.endsWith('.md') ? (
|
53 |
+
<a
|
54 |
+
href={href}
|
55 |
+
target="_blank"
|
56 |
+
rel="noopener noreferrer"
|
57 |
+
{...props}
|
58 |
+
>
|
59 |
+
{children}
|
60 |
+
</a>
|
61 |
+
) : (
|
62 |
+
<a href={href} {...props}>
|
63 |
+
{children}
|
64 |
+
</a>
|
65 |
+
),
|
66 |
+
}}
|
67 |
+
>
|
68 |
+
{mdWithBadges}
|
69 |
+
</ReactMarkdown>
|
70 |
+
</div>
|
71 |
+
)
|
72 |
+
}
|
frontend/src/components/layout/MainLayout.tsx
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { ReactNode } from "react";
|
3 |
+
import { useIsMobile } from "@/hooks/use-mobile";
|
4 |
+
import { useLocation } from "react-router-dom";
|
5 |
+
|
6 |
+
interface MainLayoutProps {
|
7 |
+
children: ReactNode;
|
8 |
+
}
|
9 |
+
|
10 |
+
const MainLayout = ({ children }: MainLayoutProps) => {
|
11 |
+
const isMobile = useIsMobile();
|
12 |
+
const location = useLocation();
|
13 |
+
const isHomePage = location.pathname === "/";
|
14 |
+
|
15 |
+
return (
|
16 |
+
<div className="flex h-screen w-full">
|
17 |
+
<main className="flex-1 overflow-y-auto">
|
18 |
+
{children}
|
19 |
+
</main>
|
20 |
+
</div>
|
21 |
+
);
|
22 |
+
};
|
23 |
+
|
24 |
+
export default MainLayout;
|
frontend/src/components/layout/ModeToggle.tsx
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { Moon, Sun } from "lucide-react";
|
3 |
+
import { useEffect, useState } from "react";
|
4 |
+
import { Button } from "@/components/ui/button";
|
5 |
+
|
6 |
+
export function ModeToggle() {
|
7 |
+
const [theme, setTheme] = useState(() => {
|
8 |
+
// Check local storage or default to system preference
|
9 |
+
const storedTheme = localStorage.getItem("theme");
|
10 |
+
if (storedTheme) return storedTheme;
|
11 |
+
|
12 |
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
13 |
+
});
|
14 |
+
|
15 |
+
useEffect(() => {
|
16 |
+
const root = window.document.documentElement;
|
17 |
+
|
18 |
+
if (theme === "dark") {
|
19 |
+
root.classList.add("dark");
|
20 |
+
} else {
|
21 |
+
root.classList.remove("dark");
|
22 |
+
}
|
23 |
+
|
24 |
+
localStorage.setItem("theme", theme);
|
25 |
+
}, [theme]);
|
26 |
+
|
27 |
+
const toggleTheme = () => {
|
28 |
+
setTheme(theme === "light" ? "dark" : "light");
|
29 |
+
};
|
30 |
+
|
31 |
+
return (
|
32 |
+
<Button variant="outline" size="icon" onClick={toggleTheme} className="h-9 w-9">
|
33 |
+
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
34 |
+
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
35 |
+
<span className="sr-only">Toggle theme</span>
|
36 |
+
</Button>
|
37 |
+
);
|
38 |
+
}
|
frontend/src/components/ui/avatar.tsx
CHANGED
@@ -1,3 +1,4 @@
|
|
|
|
1 |
import * as React from "react"
|
2 |
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
3 |
|
|
|
1 |
+
|
2 |
import * as React from "react"
|
3 |
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
4 |
|
frontend/src/components/ui/sonner.tsx
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
import { useTheme } from "next-themes"
|
2 |
-
import { Toaster as Sonner } from "sonner"
|
3 |
|
4 |
type ToasterProps = React.ComponentProps<typeof Sonner>
|
5 |
|
@@ -26,4 +26,4 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|
26 |
)
|
27 |
}
|
28 |
|
29 |
-
export { Toaster }
|
|
|
1 |
import { useTheme } from "next-themes"
|
2 |
+
import { Toaster as Sonner, toast } from "sonner"
|
3 |
|
4 |
type ToasterProps = React.ComponentProps<typeof Sonner>
|
5 |
|
|
|
26 |
)
|
27 |
}
|
28 |
|
29 |
+
export { Toaster, toast }
|
frontend/src/index.css
CHANGED
@@ -1,58 +1,80 @@
|
|
1 |
|
|
|
|
|
2 |
@tailwind base;
|
3 |
@tailwind components;
|
4 |
@tailwind utilities;
|
5 |
|
6 |
@layer base {
|
7 |
:root {
|
8 |
-
|
9 |
-
--
|
10 |
-
|
11 |
-
|
12 |
-
--card:
|
13 |
-
|
14 |
-
|
15 |
-
--popover:
|
16 |
-
|
17 |
-
|
18 |
-
--primary:
|
19 |
-
|
20 |
-
|
21 |
-
--secondary:
|
22 |
-
|
23 |
-
|
24 |
-
--muted:
|
25 |
-
|
26 |
-
|
27 |
-
--accent: 0 0%
|
28 |
-
|
29 |
-
|
30 |
--destructive: 0 84% 60%;
|
31 |
-
--destructive-foreground:
|
32 |
-
|
33 |
-
--border:
|
34 |
-
--input:
|
35 |
-
--ring:
|
36 |
-
|
37 |
-
--radius: 0.
|
38 |
-
|
39 |
-
|
40 |
-
--
|
41 |
-
--
|
42 |
-
--
|
43 |
-
--
|
44 |
-
--
|
45 |
-
--
|
46 |
-
--
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
--
|
51 |
-
--
|
52 |
-
|
53 |
-
--
|
54 |
-
--
|
55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
56 |
}
|
57 |
}
|
58 |
|
@@ -60,61 +82,218 @@
|
|
60 |
* {
|
61 |
@apply border-border;
|
62 |
}
|
|
|
63 |
body {
|
64 |
-
@apply bg-background text-foreground;
|
65 |
-
|
|
|
|
|
|
|
66 |
}
|
67 |
-
}
|
68 |
|
69 |
-
|
70 |
-
|
71 |
-
@apply px-4 md:px-8 max-w-7xl mx-auto;
|
72 |
}
|
73 |
|
74 |
-
|
75 |
-
|
|
|
|
|
76 |
}
|
77 |
|
78 |
-
.
|
79 |
-
@apply bg-
|
80 |
}
|
81 |
|
82 |
-
|
83 |
-
|
|
|
84 |
}
|
|
|
85 |
|
86 |
-
|
87 |
-
|
88 |
-
|
|
|
89 |
|
90 |
-
|
91 |
-
|
92 |
-
|
|
|
|
|
93 |
|
94 |
-
|
95 |
-
|
96 |
-
|
|
|
97 |
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
|
106 |
-
|
107 |
-
|
108 |
-
|
|
|
109 |
|
110 |
-
|
111 |
-
|
112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
113 |
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
119 |
}
|
120 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
|
2 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
3 |
+
|
4 |
@tailwind base;
|
5 |
@tailwind components;
|
6 |
@tailwind utilities;
|
7 |
|
8 |
@layer base {
|
9 |
:root {
|
10 |
+
--background: 222 45% 98%;
|
11 |
+
--foreground: 222 47% 20%;
|
12 |
+
|
13 |
+
--card: 0 0% 100%;
|
14 |
+
--card-foreground: 222 47% 20%;
|
15 |
+
|
16 |
+
--popover: 0 0% 100%;
|
17 |
+
--popover-foreground: 222 47% 20%;
|
18 |
+
|
19 |
+
--primary: 252 62% 64%;
|
20 |
+
--primary-foreground: 210 40% 98%;
|
21 |
+
|
22 |
+
--secondary: 220 25% 95%;
|
23 |
+
--secondary-foreground: 222 47% 20%;
|
24 |
+
|
25 |
+
--muted: 220 25% 95%;
|
26 |
+
--muted-foreground: 222 20% 40%;
|
27 |
+
|
28 |
+
--accent: 252 62% 64%;
|
29 |
+
--accent-foreground: 0 0% 100%;
|
30 |
+
|
|
|
31 |
--destructive: 0 84% 60%;
|
32 |
+
--destructive-foreground: 210 40% 98%;
|
33 |
+
|
34 |
+
--border: 214 20% 90%;
|
35 |
+
--input: 214 20% 90%;
|
36 |
+
--ring: 252 62% 64%;
|
37 |
+
|
38 |
+
--radius: 0.75rem;
|
39 |
+
|
40 |
+
--sidebar-background: 260 73% 56%;
|
41 |
+
--sidebar-foreground: 0 0% 100%;
|
42 |
+
--sidebar-primary: 252 62% 64%;
|
43 |
+
--sidebar-primary-foreground: 0 0% 100%;
|
44 |
+
--sidebar-accent: 224 47% 20%;
|
45 |
+
--sidebar-accent-foreground: 0 0% 100%;
|
46 |
+
--sidebar-border: 224 47% 40%;
|
47 |
+
--sidebar-ring: 252 62% 64%;
|
48 |
+
}
|
49 |
+
|
50 |
+
.dark {
|
51 |
+
--background: 225 30% 8%;
|
52 |
+
--foreground: 210 40% 98%;
|
53 |
+
|
54 |
+
--card: 225 30% 12%;
|
55 |
+
--card-foreground: 210 40% 98%;
|
56 |
+
|
57 |
+
--popover: 225 30% 12%;
|
58 |
+
--popover-foreground: 210 40% 98%;
|
59 |
+
|
60 |
+
--primary: 252 62% 64%;
|
61 |
+
--primary-foreground: 222 47% 20%;
|
62 |
+
|
63 |
+
--secondary: 225 25% 16%;
|
64 |
+
--secondary-foreground: 210 40% 98%;
|
65 |
+
|
66 |
+
--muted: 225 25% 16%;
|
67 |
+
--muted-foreground: 220 20% 70%;
|
68 |
+
|
69 |
+
--accent: 252 62% 64%;
|
70 |
+
--accent-foreground: 222 47% 20%;
|
71 |
+
|
72 |
+
--destructive: 0 80% 52%;
|
73 |
+
--destructive-foreground: 210 40% 98%;
|
74 |
+
|
75 |
+
--border: 225 25% 20%;
|
76 |
+
--input: 225 25% 20%;
|
77 |
+
--ring: 252 62% 64%;
|
78 |
}
|
79 |
}
|
80 |
|
|
|
82 |
* {
|
83 |
@apply border-border;
|
84 |
}
|
85 |
+
|
86 |
body {
|
87 |
+
@apply bg-background text-foreground font-sans;
|
88 |
+
background-image:
|
89 |
+
radial-gradient(at 50% 0%, rgba(var(--accent) / 0.08) 0px, transparent 75%),
|
90 |
+
radial-gradient(at 100% 0%, rgba(var(--accent) / 0.08) 0px, transparent 50%);
|
91 |
+
background-attachment: fixed;
|
92 |
}
|
|
|
93 |
|
94 |
+
h1, h2, h3, h4 {
|
95 |
+
@apply font-heading;
|
|
|
96 |
}
|
97 |
|
98 |
+
/* Glass effect styles */
|
99 |
+
.glass-effect {
|
100 |
+
@apply bg-white/70 dark:bg-card/70 backdrop-blur-md border border-white/20 dark:border-white/10;
|
101 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
102 |
}
|
103 |
|
104 |
+
.glass-input {
|
105 |
+
@apply bg-white/50 dark:bg-card/50 backdrop-blur-md border-white/20 dark:border-white/10;
|
106 |
}
|
107 |
|
108 |
+
/* Text gradient effect */
|
109 |
+
.text-gradient {
|
110 |
+
@apply bg-gradient-to-r from-financial-accent to-financial-light-accent bg-clip-text text-transparent;
|
111 |
}
|
112 |
+
}
|
113 |
|
114 |
+
.search-container {
|
115 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
116 |
+
@apply backdrop-blur-md;
|
117 |
+
}
|
118 |
|
119 |
+
.result-card {
|
120 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
121 |
+
transition: transform 0.2s ease, box-shadow 0.25s ease;
|
122 |
+
@apply backdrop-blur-md;
|
123 |
+
}
|
124 |
|
125 |
+
.result-card:hover {
|
126 |
+
transform: translateY(-2px);
|
127 |
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1);
|
128 |
+
}
|
129 |
|
130 |
+
.source-card {
|
131 |
+
border-left: 3px solid theme('colors.financial.accent');
|
132 |
+
}
|
133 |
|
134 |
+
.dashboard-card {
|
135 |
+
transition: all 0.3s ease-in-out;
|
136 |
+
}
|
137 |
|
138 |
+
.dashboard-card:hover {
|
139 |
+
transform: translateY(-3px);
|
140 |
+
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.12);
|
141 |
+
}
|
142 |
|
143 |
+
.nav-item {
|
144 |
+
position: relative;
|
145 |
+
}
|
146 |
+
|
147 |
+
.nav-item::after {
|
148 |
+
content: '';
|
149 |
+
position: absolute;
|
150 |
+
width: 0;
|
151 |
+
height: 2px;
|
152 |
+
bottom: -4px;
|
153 |
+
left: 0;
|
154 |
+
background: linear-gradient(to right, theme('colors.financial.accent'), theme('colors.financial.light-accent'));
|
155 |
+
transition: width 0.3s ease;
|
156 |
+
border-radius: 2px;
|
157 |
+
}
|
158 |
+
|
159 |
+
.nav-item:hover::after,
|
160 |
+
.nav-item.active::after {
|
161 |
+
width: 100%;
|
162 |
+
}
|
163 |
+
|
164 |
+
.chat-container {
|
165 |
+
height: calc(100vh - 64px);
|
166 |
+
}
|
167 |
+
|
168 |
+
.message-bubble {
|
169 |
+
position: relative;
|
170 |
+
}
|
171 |
|
172 |
+
.message-bubble-ai {
|
173 |
+
border-top-left-radius: 4px;
|
174 |
+
}
|
175 |
+
|
176 |
+
.message-bubble-user {
|
177 |
+
border-top-right-radius: 4px;
|
178 |
+
}
|
179 |
+
|
180 |
+
.typing-indicator span {
|
181 |
+
@apply inline-block h-2 w-2 rounded-full bg-current;
|
182 |
+
animation: typing-bounce 1.4s infinite ease-in-out both;
|
183 |
+
}
|
184 |
+
|
185 |
+
.typing-indicator span:nth-child(1) {
|
186 |
+
animation-delay: -0.32s;
|
187 |
+
}
|
188 |
+
|
189 |
+
.typing-indicator span:nth-child(2) {
|
190 |
+
animation-delay: -0.16s;
|
191 |
+
}
|
192 |
+
|
193 |
+
@keyframes typing-bounce {
|
194 |
+
0%, 80%, 100% {
|
195 |
+
transform: scale(0.6);
|
196 |
+
}
|
197 |
+
40% {
|
198 |
+
transform: scale(1.0);
|
199 |
}
|
200 |
}
|
201 |
+
|
202 |
+
/* Enhanced glass effect */
|
203 |
+
.neo-glass {
|
204 |
+
@apply bg-white/80 dark:bg-card/80 backdrop-blur-xl;
|
205 |
+
box-shadow:
|
206 |
+
0 4px 24px -6px rgba(0, 0, 0, 0.12),
|
207 |
+
0 12px 48px -4px rgba(0, 0, 0, 0.1);
|
208 |
+
}
|
209 |
+
|
210 |
+
.input-container {
|
211 |
+
@apply border border-border dark:border-border backdrop-blur-md rounded-xl;
|
212 |
+
background: linear-gradient(to bottom right, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7));
|
213 |
+
box-shadow:
|
214 |
+
0 2px 10px rgba(0, 0, 0, 0.03),
|
215 |
+
0 0 0 1px rgba(255, 255, 255, 0.8);
|
216 |
+
}
|
217 |
+
|
218 |
+
.dark .input-container {
|
219 |
+
background: linear-gradient(to bottom right, rgba(30, 41, 59, 0.7), rgba(30, 41, 59, 0.5));
|
220 |
+
box-shadow:
|
221 |
+
0 2px 10px rgba(0, 0, 0, 0.2),
|
222 |
+
0 0 0 1px rgba(255, 255, 255, 0.1);
|
223 |
+
}
|
224 |
+
|
225 |
+
/* Enhanced message styles */
|
226 |
+
.message-container {
|
227 |
+
@apply transition-all duration-300;
|
228 |
+
}
|
229 |
+
|
230 |
+
.message-bubble {
|
231 |
+
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08);
|
232 |
+
}
|
233 |
+
|
234 |
+
/* Glow effect */
|
235 |
+
.glow-accent {
|
236 |
+
box-shadow: 0 0 15px rgba(var(--accent), 0.5);
|
237 |
+
}
|
238 |
+
|
239 |
+
/* Code block styling */
|
240 |
+
pre code {
|
241 |
+
@apply p-4 rounded-lg text-sm block overflow-x-auto;
|
242 |
+
}
|
243 |
+
|
244 |
+
.code-block {
|
245 |
+
@apply relative;
|
246 |
+
}
|
247 |
+
|
248 |
+
.code-block-header {
|
249 |
+
@apply flex items-center justify-between px-3 py-1.5 bg-muted/70 border-b border-border rounded-t-lg;
|
250 |
+
}
|
251 |
+
|
252 |
+
.retry-button {
|
253 |
+
@apply transition-transform hover:scale-105 active:scale-95;
|
254 |
+
}
|
255 |
+
|
256 |
+
/* High-tech progress bars */
|
257 |
+
.progress-bar {
|
258 |
+
@apply h-1.5 bg-muted/50 rounded-full overflow-hidden;
|
259 |
+
}
|
260 |
+
|
261 |
+
.progress-bar-fill {
|
262 |
+
height: 100%;
|
263 |
+
background: linear-gradient(90deg, theme('colors.financial.accent'), theme('colors.financial.light-accent'));
|
264 |
+
border-radius: inherit;
|
265 |
+
transition: width 0.5s ease;
|
266 |
+
}
|
267 |
+
|
268 |
+
/* Modern pulse animation */
|
269 |
+
@keyframes pulse-glow {
|
270 |
+
0% { box-shadow: 0 0 0 0 rgba(var(--accent), 0.7); }
|
271 |
+
70% { box-shadow: 0 0 0 10px rgba(var(--accent), 0); }
|
272 |
+
100% { box-shadow: 0 0 0 0 rgba(var(--accent), 0); }
|
273 |
+
}
|
274 |
+
|
275 |
+
.pulse-accent {
|
276 |
+
animation: pulse-glow 2s infinite;
|
277 |
+
}
|
278 |
+
|
279 |
+
/* High-tech button styles */
|
280 |
+
.tech-button {
|
281 |
+
@apply relative overflow-hidden;
|
282 |
+
transition: all 0.3s;
|
283 |
+
}
|
284 |
+
|
285 |
+
.tech-button::before {
|
286 |
+
content: '';
|
287 |
+
position: absolute;
|
288 |
+
top: 0;
|
289 |
+
left: 0;
|
290 |
+
width: 100%;
|
291 |
+
height: 100%;
|
292 |
+
background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
293 |
+
transform: translateX(-100%);
|
294 |
+
}
|
295 |
+
|
296 |
+
.tech-button:hover::before {
|
297 |
+
transform: translateX(100%);
|
298 |
+
transition: transform 0.5s;
|
299 |
+
}
|
frontend/src/lib/LoadBalancerAPI.js
DELETED
@@ -1,181 +0,0 @@
|
|
1 |
-
|
2 |
-
class LoadBalancerAPI {
|
3 |
-
constructor(baseURL) {
|
4 |
-
this.baseURL = baseURL;
|
5 |
-
this.cache = {
|
6 |
-
filmStore: null,
|
7 |
-
tvStore: null,
|
8 |
-
allMovies: null,
|
9 |
-
allSeries: null,
|
10 |
-
movieMetadata: new Map(),
|
11 |
-
seriesMetadata: new Map(),
|
12 |
-
};
|
13 |
-
}
|
14 |
-
|
15 |
-
async getInstances() {
|
16 |
-
return await this._get('/api/get/instances');
|
17 |
-
}
|
18 |
-
|
19 |
-
async getInstancesHealth() {
|
20 |
-
return await this._get('/api/get/instances/health');
|
21 |
-
}
|
22 |
-
|
23 |
-
async getMovieByTitle(title) {
|
24 |
-
return await this._get(`/api/get/movie/${encodeURIComponent(title)}`);
|
25 |
-
}
|
26 |
-
|
27 |
-
async getSeriesEpisode(title, season, episode) {
|
28 |
-
return await this._get(`/api/get/series/${encodeURIComponent(title)}/${season}/${episode}`);
|
29 |
-
}
|
30 |
-
|
31 |
-
async getSeriesStore() {
|
32 |
-
if (!this.cache.tvStore) {
|
33 |
-
this.cache.tvStore = await this._get('/api/get/series/store');
|
34 |
-
}
|
35 |
-
return this.cache.tvStore || {};
|
36 |
-
}
|
37 |
-
|
38 |
-
async getMovieStore() {
|
39 |
-
if (!this.cache.filmStore) {
|
40 |
-
this.cache.filmStore = await this._get('/api/get/movie/store');
|
41 |
-
}
|
42 |
-
return this.cache.filmStore || {};
|
43 |
-
}
|
44 |
-
|
45 |
-
async getMovieMetadataByTitle(title) {
|
46 |
-
if (!this.cache.movieMetadata.has(title)) {
|
47 |
-
const metadata = await this._get(`/api/get/movie/metadata/${encodeURIComponent(title)}`);
|
48 |
-
this.cache.movieMetadata.set(title, metadata);
|
49 |
-
}
|
50 |
-
return this.cache.movieMetadata.get(title);
|
51 |
-
}
|
52 |
-
|
53 |
-
async getMovieCard(title) {
|
54 |
-
return await this._get(`/api/get/movie/card/${encodeURIComponent(title)}`);
|
55 |
-
}
|
56 |
-
|
57 |
-
async getSeriesMetadataByTitle(title) {
|
58 |
-
if (!this.cache.seriesMetadata.has(title)) {
|
59 |
-
const metadata = await this._get(`/api/get/series/metadata/${encodeURIComponent(title)}`);
|
60 |
-
this.cache.seriesMetadata.set(title, metadata);
|
61 |
-
}
|
62 |
-
return this.cache.seriesMetadata.get(title);
|
63 |
-
}
|
64 |
-
|
65 |
-
async getSeriesCard(title) {
|
66 |
-
return await this._get(`/api/get/series/card/${encodeURIComponent(title)}`);
|
67 |
-
}
|
68 |
-
|
69 |
-
async getSeasonMetadataByTitleAndSeason(title, season) {
|
70 |
-
return await this._get(`/api/get/series/metadata/${encodeURIComponent(title)}/${encodeURIComponent(season)}`);
|
71 |
-
}
|
72 |
-
|
73 |
-
async getSeasonMetadataBySeriesId(series_id, season) {
|
74 |
-
return await this._get(`/api/get/series/metadata/${series_id}/${season}`);
|
75 |
-
}
|
76 |
-
|
77 |
-
async getAllMovies() {
|
78 |
-
if (!this.cache.allMovies) {
|
79 |
-
this.cache.allMovies = await this._get('/api/get/movie/all');
|
80 |
-
}
|
81 |
-
return this.cache.allMovies;
|
82 |
-
}
|
83 |
-
|
84 |
-
async getAllSeriesShows() {
|
85 |
-
if (!this.cache.allSeries) {
|
86 |
-
this.cache.allSeries = await this._get('/api/get/series/all');
|
87 |
-
}
|
88 |
-
return this.cache.allSeries;
|
89 |
-
}
|
90 |
-
|
91 |
-
async getRecent(limit = 10) {
|
92 |
-
return await this._get(`/api/get/recent?limit=${limit}`);
|
93 |
-
}
|
94 |
-
|
95 |
-
async getGenreCategories(mediaType) {
|
96 |
-
const url = mediaType
|
97 |
-
? `/api/get/genre_categories?media_type=${encodeURIComponent(mediaType)}`
|
98 |
-
: '/api/get/genre_categories';
|
99 |
-
return await this._get(url);
|
100 |
-
}
|
101 |
-
|
102 |
-
async getGenreItems(genres, mediaType, limit = 5, page = 1) {
|
103 |
-
if (!Array.isArray(genres)) {
|
104 |
-
throw new Error("The 'genres' parameter must be an array.");
|
105 |
-
}
|
106 |
-
const params = new URLSearchParams();
|
107 |
-
genres.forEach(genre => params.append('genre', genre));
|
108 |
-
params.append('limit', limit);
|
109 |
-
params.append('page', page);
|
110 |
-
if (mediaType) {
|
111 |
-
params.append('media_type', mediaType);
|
112 |
-
}
|
113 |
-
try {
|
114 |
-
const response = await this._get(`/api/get/genre?${params.toString()}`);
|
115 |
-
console.debug(response);
|
116 |
-
return response;
|
117 |
-
} catch (error) {
|
118 |
-
console.debug("Error fetching genre items:", error);
|
119 |
-
throw error;
|
120 |
-
}
|
121 |
-
}
|
122 |
-
|
123 |
-
async getDownloadProgress(url) {
|
124 |
-
return await this._getNoBase(url);
|
125 |
-
}
|
126 |
-
|
127 |
-
async _get(endpoint) {
|
128 |
-
return await this._request(`${this.baseURL}${endpoint}`, { method: 'GET' });
|
129 |
-
}
|
130 |
-
|
131 |
-
async _getNoBase(url) {
|
132 |
-
return await this._request(url, { method: 'GET' });
|
133 |
-
}
|
134 |
-
|
135 |
-
async _post(endpoint, body) {
|
136 |
-
return await this._request(`${this.baseURL}${endpoint}`, {
|
137 |
-
method: 'POST',
|
138 |
-
body: JSON.stringify(body)
|
139 |
-
});
|
140 |
-
}
|
141 |
-
|
142 |
-
async _request(url, options) {
|
143 |
-
try {
|
144 |
-
const response = await fetch(url, {
|
145 |
-
headers: { 'Content-Type': 'application/json' },
|
146 |
-
...options,
|
147 |
-
});
|
148 |
-
console.log(`API Request: ${url} with options: ${JSON.stringify(options)}`);
|
149 |
-
return await this._handleResponse(response);
|
150 |
-
} catch (error) {
|
151 |
-
console.debug(`Request error for ${url}:`, error);
|
152 |
-
throw error;
|
153 |
-
}
|
154 |
-
}
|
155 |
-
|
156 |
-
async _handleResponse(response) {
|
157 |
-
if (!response.ok) {
|
158 |
-
const errorDetails = await response.text();
|
159 |
-
throw new Error(`HTTP Error ${response.status}: ${errorDetails}`);
|
160 |
-
}
|
161 |
-
try {
|
162 |
-
return await response.json();
|
163 |
-
} catch (error) {
|
164 |
-
console.debug('Error parsing JSON response:', error);
|
165 |
-
throw error;
|
166 |
-
}
|
167 |
-
}
|
168 |
-
|
169 |
-
clearCache() {
|
170 |
-
this.cache = {
|
171 |
-
filmStore: null,
|
172 |
-
tvStore: null,
|
173 |
-
allMovies: null,
|
174 |
-
allSeries: null,
|
175 |
-
movieMetadata: new Map(),
|
176 |
-
seriesMetadata: new Map(),
|
177 |
-
};
|
178 |
-
}
|
179 |
-
}
|
180 |
-
|
181 |
-
export { LoadBalancerAPI };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/lib/api.js
DELETED
@@ -1,159 +0,0 @@
|
|
1 |
-
|
2 |
-
import { LoadBalancerAPI } from "./LoadBalancerAPI";
|
3 |
-
|
4 |
-
const lb = new LoadBalancerAPI("https://hans-den-load-balancer.hf.space");
|
5 |
-
|
6 |
-
export async function getRecentItems(limit = 5) {
|
7 |
-
const recentData = await lb.getRecent(limit);
|
8 |
-
console.debug("Raw recent data:", recentData);
|
9 |
-
|
10 |
-
const slides = [];
|
11 |
-
|
12 |
-
// Process movies and format them as slide objects
|
13 |
-
if (recentData.movies && Array.isArray(recentData.movies)) {
|
14 |
-
recentData.movies.forEach(movie => {
|
15 |
-
const [title, year, description, image, genres] = movie;
|
16 |
-
slides.push({
|
17 |
-
type: 'movie',
|
18 |
-
title,
|
19 |
-
genre: genres.map(g => g.name), // returns an array of genre names
|
20 |
-
image,
|
21 |
-
description,
|
22 |
-
year,
|
23 |
-
});
|
24 |
-
});
|
25 |
-
}
|
26 |
-
|
27 |
-
// Process series and format them as slide objects with type "tvshow"
|
28 |
-
if (recentData.series && Array.isArray(recentData.series)) {
|
29 |
-
recentData.series.forEach(series => {
|
30 |
-
const [title, year, description, image, genres] = series;
|
31 |
-
slides.push({
|
32 |
-
type: 'tvshow',
|
33 |
-
title,
|
34 |
-
genre: genres.map(g => g.name), // returns an array of genre names
|
35 |
-
image,
|
36 |
-
description,
|
37 |
-
year,
|
38 |
-
});
|
39 |
-
});
|
40 |
-
}
|
41 |
-
console.debug(slides);
|
42 |
-
return slides;
|
43 |
-
}
|
44 |
-
|
45 |
-
export async function getNewContents(limit = 5) {
|
46 |
-
const recentData = await lb.getRecent(limit);
|
47 |
-
console.debug("Raw recent data:", recentData);
|
48 |
-
|
49 |
-
const movies = [];
|
50 |
-
const tvshows = [];
|
51 |
-
|
52 |
-
// Process movies
|
53 |
-
if (Array.isArray(recentData.movies)) {
|
54 |
-
recentData.movies.forEach(([title, year, description, image, genres]) => {
|
55 |
-
movies.push({
|
56 |
-
title,
|
57 |
-
genre: genres.map(g => g.name),
|
58 |
-
image,
|
59 |
-
description,
|
60 |
-
year,
|
61 |
-
});
|
62 |
-
});
|
63 |
-
}
|
64 |
-
|
65 |
-
// Process TV shows
|
66 |
-
if (Array.isArray(recentData.series)) {
|
67 |
-
recentData.series.forEach(([title, year, description, image, genres]) => {
|
68 |
-
tvshows.push({
|
69 |
-
title,
|
70 |
-
genre: genres.map(g => g.name),
|
71 |
-
image,
|
72 |
-
description,
|
73 |
-
year,
|
74 |
-
});
|
75 |
-
});
|
76 |
-
}
|
77 |
-
|
78 |
-
console.debug({ movies, tvshows });
|
79 |
-
return { movies, tvshows };
|
80 |
-
}
|
81 |
-
|
82 |
-
export async function getAllMovies(){
|
83 |
-
const movies = await lb.getAllMovies();
|
84 |
-
console.debug(movies);
|
85 |
-
|
86 |
-
const formattedMovies = movies.map(title => ({
|
87 |
-
title: title.replace('films/', '')
|
88 |
-
}));
|
89 |
-
return formattedMovies;
|
90 |
-
}
|
91 |
-
|
92 |
-
export async function getAllTvShows() {
|
93 |
-
const tvshows = await lb.getAllSeriesShows();
|
94 |
-
|
95 |
-
// Transform the response to return TV show names with episode count
|
96 |
-
const formattedTvShows = Object.entries(tvshows).map(([title, episodes]) => ({
|
97 |
-
title,
|
98 |
-
episodeCount: episodes.length
|
99 |
-
}));
|
100 |
-
|
101 |
-
return formattedTvShows;
|
102 |
-
}
|
103 |
-
|
104 |
-
export async function getMovieLinkByTitle(title){
|
105 |
-
const response = await lb.getMovieByTitle(title);
|
106 |
-
console.debug(response);
|
107 |
-
return response;
|
108 |
-
}
|
109 |
-
|
110 |
-
export async function getEpisodeLinkByTitle(title, season, episode){
|
111 |
-
const response = await lb.getSeriesEpisode(title, season, episode);
|
112 |
-
console.debug(response);
|
113 |
-
return response;
|
114 |
-
}
|
115 |
-
|
116 |
-
export async function getMovieCard(title){
|
117 |
-
const movie = await lb.getMovieCard(title);
|
118 |
-
console.debug(movie);
|
119 |
-
return movie;
|
120 |
-
}
|
121 |
-
|
122 |
-
export async function getTvShowCard(title){
|
123 |
-
const tvshow = await lb.getSeriesCard(title);
|
124 |
-
console.debug(tvshow);
|
125 |
-
return tvshow;
|
126 |
-
}
|
127 |
-
|
128 |
-
export async function getMovieMetadata(title){
|
129 |
-
const movie = await lb.getMovieMetadataByTitle(title);
|
130 |
-
console.debug(movie);
|
131 |
-
return movie;
|
132 |
-
}
|
133 |
-
|
134 |
-
export async function getTvShowMetadata(title){
|
135 |
-
const tvshow = await lb.getSeriesMetadataByTitle(title);
|
136 |
-
console.debug(tvshow);
|
137 |
-
return tvshow;
|
138 |
-
}
|
139 |
-
|
140 |
-
export async function getSeasonMetadata(title, season){
|
141 |
-
const data = await lb.getSeasonMetadataByTitleAndSeason(title, season);
|
142 |
-
console.debug(data);
|
143 |
-
return data;
|
144 |
-
}
|
145 |
-
|
146 |
-
export async function getGenreCategories(mediaType){
|
147 |
-
const gc = await lb.getGenreCategories(mediaType);
|
148 |
-
console.debug(gc);
|
149 |
-
if (gc.genres)
|
150 |
-
return gc.genres;
|
151 |
-
else
|
152 |
-
return [];
|
153 |
-
}
|
154 |
-
|
155 |
-
export async function getGenresItems(genres, mediaType, limit = 10, page = 1){
|
156 |
-
const genresRes = await lb.getGenreItems(genres, mediaType, limit, page);
|
157 |
-
console.debug(genresRes);
|
158 |
-
return genresRes;
|
159 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/lib/remarkSource.ts
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { visit } from 'unist-util-visit'
|
2 |
+
import type { Plugin } from 'unified'
|
3 |
+
import type { Root, HTML } from 'mdast'
|
4 |
+
|
5 |
+
const remarkSource: Plugin = () => (tree: Root) => {
|
6 |
+
visit(tree, 'html', (node: HTML, index, parent) => {
|
7 |
+
if (node.value.startsWith('<source')) {
|
8 |
+
console.debug('[remarkSource] found source tag:', node.value)
|
9 |
+
}
|
10 |
+
const match = node.value.match(/<source\s+path=['"](.+?)['"]\s*\/>/)
|
11 |
+
if (!match || !parent) return
|
12 |
+
const [, path] = match
|
13 |
+
parent.children.splice(index, 1, {
|
14 |
+
type: 'source',
|
15 |
+
path,
|
16 |
+
data: {},
|
17 |
+
} as any)
|
18 |
+
})
|
19 |
+
}
|
20 |
+
|
21 |
+
export default remarkSource
|
frontend/src/lib/search-api.ts
DELETED
@@ -1,64 +0,0 @@
|
|
1 |
-
// Client for the search API
|
2 |
-
|
3 |
-
type RawSearchResponse = {
|
4 |
-
films: string[];
|
5 |
-
series: string[];
|
6 |
-
episodes: {
|
7 |
-
series: string;
|
8 |
-
title: string;
|
9 |
-
path: string;
|
10 |
-
season: string;
|
11 |
-
}[];
|
12 |
-
};
|
13 |
-
|
14 |
-
const API_BASE_URL = 'https://hans-den-search.hf.space'; // Change this to your actual API URL
|
15 |
-
|
16 |
-
export const searchAPI = {
|
17 |
-
search: async (query: string): Promise<RawSearchResponse> => {
|
18 |
-
try {
|
19 |
-
const response = await fetch(`${API_BASE_URL}/api/search`, {
|
20 |
-
method: 'POST',
|
21 |
-
headers: {
|
22 |
-
'Content-Type': 'application/json',
|
23 |
-
},
|
24 |
-
body: JSON.stringify({ query }),
|
25 |
-
});
|
26 |
-
|
27 |
-
if (!response.ok) {
|
28 |
-
throw new Error(`Search API returned ${response.status}`);
|
29 |
-
}
|
30 |
-
|
31 |
-
const data = await response.json();
|
32 |
-
console.log('Search API response:', data);
|
33 |
-
return data;
|
34 |
-
} catch (error) {
|
35 |
-
console.error('Error searching:', error);
|
36 |
-
return { films: [], series: [], episodes: [] };
|
37 |
-
}
|
38 |
-
},
|
39 |
-
|
40 |
-
healthCheck: async (): Promise<boolean> => {
|
41 |
-
try {
|
42 |
-
const response = await fetch(`${API_BASE_URL}/health`);
|
43 |
-
return response.ok;
|
44 |
-
} catch (error) {
|
45 |
-
console.error('API health check failed:', error);
|
46 |
-
return false;
|
47 |
-
}
|
48 |
-
},
|
49 |
-
|
50 |
-
getData: async () => {
|
51 |
-
try {
|
52 |
-
const response = await fetch(`${API_BASE_URL}/api/data`);
|
53 |
-
|
54 |
-
if (!response.ok) {
|
55 |
-
throw new Error(`API returned ${response.status}`);
|
56 |
-
}
|
57 |
-
|
58 |
-
return await response.json();
|
59 |
-
} catch (error) {
|
60 |
-
console.error('Error fetching API data:', error);
|
61 |
-
return null;
|
62 |
-
}
|
63 |
-
}
|
64 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/lib/storage.ts
CHANGED
@@ -1,249 +1,137 @@
|
|
1 |
-
|
2 |
-
// Updated storage utility with additional functions
|
3 |
-
|
4 |
-
// Type for storing watch history and progress
|
5 |
-
export interface WatchProgress {
|
6 |
-
currentTime: number;
|
7 |
-
duration: number;
|
8 |
-
lastPlayed: string;
|
9 |
-
completed: boolean;
|
10 |
-
}
|
11 |
-
|
12 |
-
// Type for my list items
|
13 |
-
export interface MyListItem {
|
14 |
-
title: string;
|
15 |
-
type: 'movie' | 'tvshow';
|
16 |
-
addedAt: string;
|
17 |
-
posterPath?: string;
|
18 |
-
backdropPath?: string;
|
19 |
-
}
|
20 |
-
|
21 |
/**
|
22 |
-
*
|
|
|
23 |
*/
|
24 |
-
export const saveVideoProgress = (
|
25 |
-
type: 'movie' | 'tvshow',
|
26 |
-
title: string,
|
27 |
-
seasonEpisode: string | null,
|
28 |
-
progress: WatchProgress
|
29 |
-
): void => {
|
30 |
-
try {
|
31 |
-
// Create a unique identifier for the content
|
32 |
-
const id = seasonEpisode ? `${type}-${title}-${seasonEpisode}` : `${type}-${title}`;
|
33 |
-
|
34 |
-
// Get existing watch history
|
35 |
-
const historyStr = localStorage.getItem('watch-history') || '{}';
|
36 |
-
const history = JSON.parse(historyStr);
|
37 |
-
|
38 |
-
// Update the history with new progress
|
39 |
-
history[id] = {
|
40 |
-
...progress,
|
41 |
-
title,
|
42 |
-
type,
|
43 |
-
seasonEpisode,
|
44 |
-
updatedAt: new Date().toISOString()
|
45 |
-
};
|
46 |
-
|
47 |
-
// Save back to localStorage
|
48 |
-
localStorage.setItem('watch-history', JSON.stringify(history));
|
49 |
-
} catch (error) {
|
50 |
-
console.error('Failed to save video progress:', error);
|
51 |
-
}
|
52 |
-
};
|
53 |
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
try {
|
63 |
-
// Create a unique identifier for the content
|
64 |
-
const id = seasonEpisode ? `${type}-${title}-${seasonEpisode}` : `${type}-${title}`;
|
65 |
-
|
66 |
-
// Get existing watch history
|
67 |
-
const historyStr = localStorage.getItem('watch-history') || '{}';
|
68 |
-
const history = JSON.parse(historyStr);
|
69 |
-
|
70 |
-
// Return the progress if it exists
|
71 |
-
return history[id] || null;
|
72 |
-
} catch (error) {
|
73 |
-
console.error('Failed to get video progress:', error);
|
74 |
-
return null;
|
75 |
-
}
|
76 |
-
};
|
77 |
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
try {
|
83 |
-
// Get existing watch history
|
84 |
-
const historyStr = localStorage.getItem('watch-history') || '{}';
|
85 |
-
const history = JSON.parse(historyStr);
|
86 |
-
|
87 |
-
// Convert object to array and sort by updatedAt (most recent first)
|
88 |
-
const historyArray = Object.values(history).sort((a: any, b: any) => {
|
89 |
-
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
90 |
-
});
|
91 |
-
|
92 |
-
// Return all or limited number of items
|
93 |
-
return limit > 0 ? historyArray.slice(0, limit) : historyArray;
|
94 |
-
} catch (error) {
|
95 |
-
console.error('Failed to get watch history:', error);
|
96 |
-
return [];
|
97 |
-
}
|
98 |
-
};
|
99 |
|
100 |
-
|
101 |
-
* Clear watch history
|
102 |
-
*/
|
103 |
-
export const clearWatchHistory = (): void => {
|
104 |
-
try {
|
105 |
-
localStorage.removeItem('watch-history');
|
106 |
-
} catch (error) {
|
107 |
-
console.error('Failed to clear watch history:', error);
|
108 |
-
}
|
109 |
-
};
|
110 |
|
111 |
/**
|
112 |
-
*
|
|
|
113 |
*/
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
// Check if item already exists
|
121 |
-
const exists = myList.some((listItem: MyListItem) =>
|
122 |
-
listItem.title === item.title && listItem.type === item.type
|
123 |
-
);
|
124 |
-
|
125 |
-
// Add item if it doesn't exist
|
126 |
-
if (!exists) {
|
127 |
-
myList.push({
|
128 |
-
...item,
|
129 |
-
addedAt: new Date().toISOString()
|
130 |
-
});
|
131 |
|
132 |
-
//
|
133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
134 |
}
|
135 |
-
} catch (error) {
|
136 |
-
console.error('Failed to add item to My List:', error);
|
137 |
-
}
|
138 |
-
};
|
139 |
-
|
140 |
-
/**
|
141 |
-
* Remove item from My List
|
142 |
-
*/
|
143 |
-
export const removeFromMyList = (title: string, type: 'movie' | 'tvshow'): void => {
|
144 |
-
try {
|
145 |
-
// Get existing my list
|
146 |
-
const myListStr = localStorage.getItem('my-list') || '[]';
|
147 |
-
const myList = JSON.parse(myListStr);
|
148 |
-
|
149 |
-
// Filter out the item
|
150 |
-
const newList = myList.filter((item: MyListItem) =>
|
151 |
-
!(item.title === title && item.type === type)
|
152 |
-
);
|
153 |
-
|
154 |
-
// Save back to localStorage
|
155 |
-
localStorage.setItem('my-list', JSON.stringify(newList));
|
156 |
-
} catch (error) {
|
157 |
-
console.error('Failed to remove item from My List:', error);
|
158 |
}
|
159 |
-
};
|
160 |
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
console.error('Failed to check if item is in My List:', error);
|
176 |
-
return false;
|
177 |
}
|
178 |
-
};
|
179 |
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
console.error('Failed to get My List:', error);
|
190 |
-
return [];
|
191 |
}
|
192 |
-
};
|
193 |
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
export const clearMyList = (): void => {
|
198 |
-
try {
|
199 |
-
localStorage.removeItem('my-list');
|
200 |
-
} catch (error) {
|
201 |
-
console.error('Failed to clear My List:', error);
|
202 |
}
|
203 |
-
};
|
204 |
|
205 |
-
//
|
206 |
-
|
207 |
-
export const storageService = {
|
208 |
-
// Video progress functions
|
209 |
-
saveVideoProgress,
|
210 |
-
getVideoProgress,
|
211 |
-
getWatchHistory,
|
212 |
-
clearWatchHistory,
|
213 |
-
|
214 |
-
// My list functions
|
215 |
-
addToMyList,
|
216 |
-
removeFromMyList,
|
217 |
-
isInMyList,
|
218 |
-
getAllFromMyList,
|
219 |
-
clearMyList,
|
220 |
-
|
221 |
-
// Generic storage functions that could be replaced
|
222 |
-
setItem: (key: string, value: any): void => {
|
223 |
try {
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
try {
|
232 |
-
const item = localStorage.getItem(key);
|
233 |
-
return item ? JSON.parse(item) : defaultValue;
|
234 |
} catch (error) {
|
235 |
-
console.error(
|
236 |
-
return
|
237 |
}
|
238 |
-
}
|
239 |
-
|
240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
241 |
try {
|
242 |
-
|
|
|
|
|
|
|
|
|
|
|
243 |
} catch (error) {
|
244 |
-
console.error(
|
|
|
245 |
}
|
246 |
}
|
247 |
-
}
|
248 |
|
249 |
-
export
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
/**
|
2 |
+
* Storage Library for Financial Insight System
|
3 |
+
* Provides a unified interface for data storage with potential for external storage integration
|
4 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
+
// Define storage keys for better type safety and avoid string duplication
|
7 |
+
export const STORAGE_KEYS = {
|
8 |
+
CHATS: 'fis-chats',
|
9 |
+
SETTINGS: 'fis-settings',
|
10 |
+
API_ENDPOINT: 'apiEndpoint',
|
11 |
+
THEME: 'fis-theme',
|
12 |
+
SOURCES: 'fis-sources',
|
13 |
+
} as const;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
|
15 |
+
// Types for our storage
|
16 |
+
export interface StorageOptions {
|
17 |
+
ttl?: number; // Time to live in milliseconds
|
18 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
+
export type StorageValue = string | object | number | boolean | null | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
|
22 |
/**
|
23 |
+
* Storage service that provides unified interface for storing and retrieving data
|
24 |
+
* Currently uses localStorage, but can be extended to use external storage in the future
|
25 |
*/
|
26 |
+
class StorageService {
|
27 |
+
// Get item from storage with automatic parsing
|
28 |
+
get<T = any>(key: string): T | null {
|
29 |
+
try {
|
30 |
+
const item = localStorage.getItem(key);
|
31 |
+
if (!item) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
32 |
|
33 |
+
// Parse the stored value
|
34 |
+
const { value, expires } = JSON.parse(item);
|
35 |
+
|
36 |
+
// Check if the value has expired
|
37 |
+
if (expires && expires < Date.now()) {
|
38 |
+
this.remove(key);
|
39 |
+
return null;
|
40 |
+
}
|
41 |
+
|
42 |
+
return value as T;
|
43 |
+
} catch (error) {
|
44 |
+
console.error(`Error getting item from storage: ${key}`, error);
|
45 |
+
return null;
|
46 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
}
|
|
|
48 |
|
49 |
+
// Set item in storage with optional TTL
|
50 |
+
set(key: string, value: StorageValue, options: StorageOptions = {}): boolean {
|
51 |
+
try {
|
52 |
+
const storageItem = {
|
53 |
+
value,
|
54 |
+
expires: options.ttl ? Date.now() + options.ttl : null
|
55 |
+
};
|
56 |
+
|
57 |
+
localStorage.setItem(key, JSON.stringify(storageItem));
|
58 |
+
return true;
|
59 |
+
} catch (error) {
|
60 |
+
console.error(`Error setting item in storage: ${key}`, error);
|
61 |
+
return false;
|
62 |
+
}
|
|
|
|
|
63 |
}
|
|
|
64 |
|
65 |
+
// Remove item from storage
|
66 |
+
remove(key: string): boolean {
|
67 |
+
try {
|
68 |
+
localStorage.removeItem(key);
|
69 |
+
return true;
|
70 |
+
} catch (error) {
|
71 |
+
console.error(`Error removing item from storage: ${key}`, error);
|
72 |
+
return false;
|
73 |
+
}
|
|
|
|
|
74 |
}
|
|
|
75 |
|
76 |
+
// Check if key exists in storage
|
77 |
+
has(key: string): boolean {
|
78 |
+
return localStorage.getItem(key) !== null;
|
|
|
|
|
|
|
|
|
|
|
79 |
}
|
|
|
80 |
|
81 |
+
// Clear all storage for the application
|
82 |
+
clear(): boolean {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
try {
|
84 |
+
// Only clear keys that start with our application prefix (fis-)
|
85 |
+
Object.keys(localStorage).forEach(key => {
|
86 |
+
if (key.startsWith('fis-')) {
|
87 |
+
localStorage.removeItem(key);
|
88 |
+
}
|
89 |
+
});
|
90 |
+
return true;
|
|
|
|
|
|
|
91 |
} catch (error) {
|
92 |
+
console.error('Error clearing storage', error);
|
93 |
+
return false;
|
94 |
}
|
95 |
+
}
|
96 |
+
|
97 |
+
/**
|
98 |
+
* Export all application data for keys defined in STORAGE_KEYS as a JSON object.
|
99 |
+
* Returns an object with key-value pairs.
|
100 |
+
*/
|
101 |
+
export(): Record<string, any> {
|
102 |
+
const exported: Record<string, any> = {};
|
103 |
+
Object.values(STORAGE_KEYS).forEach(key => {
|
104 |
+
try {
|
105 |
+
const item = localStorage.getItem(key);
|
106 |
+
if (item) {
|
107 |
+
exported[key] = JSON.parse(item);
|
108 |
+
}
|
109 |
+
} catch (error) {
|
110 |
+
console.error(`Error exporting key: ${key}`, error);
|
111 |
+
}
|
112 |
+
});
|
113 |
+
return exported;
|
114 |
+
}
|
115 |
+
|
116 |
+
/**
|
117 |
+
* Import data from an external source into local storage.
|
118 |
+
* Accepts an object with key-value pairs (as produced by export()).
|
119 |
+
* Overwrites existing keys, but only those defined in STORAGE_KEYS.
|
120 |
+
*/
|
121 |
+
import(data: Record<string, any>): boolean {
|
122 |
try {
|
123 |
+
Object.entries(data).forEach(([key, value]) => {
|
124 |
+
if (Object.values(STORAGE_KEYS).includes(key as any)) {
|
125 |
+
localStorage.setItem(key, JSON.stringify(value));
|
126 |
+
}
|
127 |
+
});
|
128 |
+
return true;
|
129 |
} catch (error) {
|
130 |
+
console.error('Error importing data into storage', error);
|
131 |
+
return false;
|
132 |
}
|
133 |
}
|
134 |
+
}
|
135 |
|
136 |
+
// Create and export a singleton instance
|
137 |
+
export const storage = new StorageService();
|
frontend/src/lib/utils.ts
CHANGED
@@ -1,16 +1,6 @@
|
|
1 |
-
|
2 |
import { clsx, type ClassValue } from "clsx"
|
3 |
import { twMerge } from "tailwind-merge"
|
4 |
|
5 |
export function cn(...inputs: ClassValue[]) {
|
6 |
return twMerge(clsx(inputs))
|
7 |
}
|
8 |
-
|
9 |
-
export const formatTime = (time: number): string => {
|
10 |
-
const hours = Math.floor(time / 3600);
|
11 |
-
const minutes = Math.floor((time % 3600) / 60);
|
12 |
-
const seconds = Math.floor(time % 60);
|
13 |
-
const minutesStr = minutes.toString().padStart(2, '0');
|
14 |
-
const secondsStr = seconds.toString().padStart(2, '0');
|
15 |
-
return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`;
|
16 |
-
};
|
|
|
|
|
1 |
import { clsx, type ClassValue } from "clsx"
|
2 |
import { twMerge } from "tailwind-merge"
|
3 |
|
4 |
export function cn(...inputs: ClassValue[]) {
|
5 |
return twMerge(clsx(inputs))
|
6 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/main.tsx
CHANGED
@@ -1,6 +1,5 @@
|
|
1 |
-
|
2 |
-
import
|
3 |
-
import
|
4 |
-
import './index.css';
|
5 |
|
6 |
createRoot(document.getElementById("root")!).render(<App />);
|
|
|
1 |
+
import { createRoot } from 'react-dom/client'
|
2 |
+
import App from './App.tsx'
|
3 |
+
import './index.css'
|
|
|
4 |
|
5 |
createRoot(document.getElementById("root")!).render(<App />);
|
frontend/src/pages/HomePage.tsx
CHANGED
@@ -1,184 +1,741 @@
|
|
1 |
-
import { useEffect, useState } from 'react';
|
2 |
-
import HeroSection from '../components/HeroSection';
|
3 |
-
import ContentRow from '../components/ContentRow';
|
4 |
-
import { getRecentItems, getGenreCategories, getGenresItems, getMovieCard, getTvShowCard } from '../lib/api';
|
5 |
-
import { useToast } from '@/hooks/use-toast';
|
6 |
-
|
7 |
-
// GenreRow component for dynamic loading of a genre row
|
8 |
-
const GenreRow = ({ genre, type }) => {
|
9 |
-
const [loading, setLoading] = useState(true);
|
10 |
-
const [items, setItems] = useState([]);
|
11 |
-
const { toast } = useToast();
|
12 |
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
return;
|
29 |
-
}
|
30 |
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
}
|
58 |
-
}
|
59 |
-
} catch (error) {
|
60 |
-
console.error(`Error fetching card for ${title}:`, error);
|
61 |
-
return null;
|
62 |
-
}
|
63 |
-
return null;
|
64 |
-
};
|
65 |
|
66 |
-
|
67 |
-
|
68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
} catch (error) {
|
70 |
-
console.error(
|
71 |
-
|
72 |
-
title: `Error loading ${type} items`,
|
73 |
-
description: `Failed to load ${genre} ${type} items`,
|
74 |
-
variant: "destructive"
|
75 |
-
});
|
76 |
-
} finally {
|
77 |
-
setLoading(false);
|
78 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
79 |
};
|
80 |
|
81 |
-
|
82 |
-
}
|
83 |
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
<h2 className="text-xl font-bold mb-4">{genre} {type === "movie" ? "Movies" : "Shows"}</h2>
|
89 |
-
<div className="flex items-center justify-center h-32">
|
90 |
-
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-netflix-red"></div>
|
91 |
-
</div>
|
92 |
-
</div>
|
93 |
-
);
|
94 |
-
}
|
95 |
|
96 |
-
|
97 |
-
|
|
|
98 |
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
|
|
103 |
|
104 |
-
const
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
110 |
|
111 |
useEffect(() => {
|
112 |
-
|
113 |
-
|
114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
115 |
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
121 |
}
|
|
|
|
|
122 |
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
const
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
140 |
});
|
141 |
-
} finally {
|
142 |
-
setLoading(false);
|
143 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
144 |
};
|
145 |
|
146 |
-
|
147 |
-
|
|
|
|
|
|
|
|
|
|
|
148 |
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
);
|
155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
156 |
|
157 |
return (
|
158 |
-
<div>
|
159 |
-
{/*
|
160 |
-
{
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
180 |
</div>
|
181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
182 |
</div>
|
183 |
</div>
|
184 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
|
2 |
+
import { useState, useRef, useEffect } from "react";
|
3 |
+
import { useNavigate } from "react-router-dom";
|
4 |
+
import { Send, Plus, CornerDownLeft, TrashIcon, RefreshCcw, Bot, Sparkles, Menu } from "lucide-react";
|
5 |
+
import { Button } from "@/components/ui/button";
|
6 |
+
import { Input } from "@/components/ui/input";
|
7 |
+
import { rulingService, Message as APIMessage } from "@/services/rulingService";
|
8 |
+
import { toast } from "@/components/ui/sonner";
|
9 |
+
import { ChatBubble } from "@/components/chat/ChatBubble";
|
10 |
+
import { Separator } from "@/components/ui/separator";
|
11 |
+
import { cn } from "@/lib/utils";
|
12 |
+
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
13 |
+
import { format } from "date-fns";
|
14 |
+
import { ModeToggle } from "@/components/layout/ModeToggle";
|
15 |
+
import { Link } from "react-router-dom";
|
16 |
+
import { FileText, MessageCircle, Settings, PanelLeft } from "lucide-react";
|
|
|
|
|
17 |
|
18 |
+
interface Message {
|
19 |
+
id: string;
|
20 |
+
content: string;
|
21 |
+
sender: "user" | "system";
|
22 |
+
timestamp: Date;
|
23 |
+
isLoading?: boolean;
|
24 |
+
error?: boolean;
|
25 |
+
result?: any;
|
26 |
+
}
|
27 |
+
|
28 |
+
interface Chat {
|
29 |
+
id: string;
|
30 |
+
title: string;
|
31 |
+
messages: Message[];
|
32 |
+
createdAt: Date;
|
33 |
+
updatedAt: Date;
|
34 |
+
}
|
35 |
+
|
36 |
+
const WELCOME_MESSAGE = "Hello! I'm Insight AI. How can I help you today?";
|
37 |
+
|
38 |
+
const generateId = () => Math.random().toString(36).substring(2, 11);
|
39 |
+
|
40 |
+
const navItems = [
|
41 |
+
{ name: "Conversations", path: "/", icon: MessageCircle },
|
42 |
+
{ name: "Sources", path: "/sources", icon: FileText }
|
43 |
+
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
|
45 |
+
const HomePage = () => {
|
46 |
+
const [chats, setChats] = useState<Chat[]>(() => {
|
47 |
+
const savedChats = storage.get<Chat[]>(STORAGE_KEYS.CHATS);
|
48 |
+
if (savedChats) {
|
49 |
+
try {
|
50 |
+
return savedChats.map((chat: any) => ({
|
51 |
+
...chat,
|
52 |
+
messages: chat.messages.map((msg: any) => ({
|
53 |
+
...msg,
|
54 |
+
timestamp: new Date(msg.timestamp),
|
55 |
+
sender: msg.sender as "user" | "system"
|
56 |
+
})),
|
57 |
+
createdAt: new Date(chat.createdAt),
|
58 |
+
updatedAt: new Date(chat.updatedAt)
|
59 |
+
}));
|
60 |
} catch (error) {
|
61 |
+
console.error("Failed to parse saved chats:", error);
|
62 |
+
return [];
|
|
|
|
|
|
|
|
|
|
|
|
|
63 |
}
|
64 |
+
}
|
65 |
+
return [];
|
66 |
+
});
|
67 |
+
|
68 |
+
const [activeChat, setActiveChat] = useState<Chat | null>(() => {
|
69 |
+
if (chats.length > 0) {
|
70 |
+
return chats[0];
|
71 |
+
}
|
72 |
+
|
73 |
+
// Create initial chat if none exists
|
74 |
+
const initialChat: Chat = {
|
75 |
+
id: generateId(),
|
76 |
+
title: "New Chat",
|
77 |
+
messages: [
|
78 |
+
{
|
79 |
+
id: generateId(),
|
80 |
+
content: WELCOME_MESSAGE,
|
81 |
+
sender: "system" as const,
|
82 |
+
timestamp: new Date()
|
83 |
+
}
|
84 |
+
],
|
85 |
+
createdAt: new Date(),
|
86 |
+
updatedAt: new Date()
|
87 |
};
|
88 |
|
89 |
+
return initialChat;
|
90 |
+
});
|
91 |
|
92 |
+
const [inputValue, setInputValue] = useState("");
|
93 |
+
const [isLoading, setIsLoading] = useState(false);
|
94 |
+
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
95 |
+
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
96 |
|
97 |
+
const navigate = useNavigate();
|
98 |
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
99 |
+
const inputRef = useRef<HTMLInputElement>(null);
|
100 |
|
101 |
+
useEffect(() => {
|
102 |
+
// Save chats to storage whenever they change
|
103 |
+
if (activeChat && !chats.find(chat => chat.id === activeChat.id)) {
|
104 |
+
setChats([activeChat, ...chats]);
|
105 |
+
}
|
106 |
|
107 |
+
const allChats = activeChat
|
108 |
+
? [
|
109 |
+
activeChat,
|
110 |
+
...chats.filter(chat => chat.id !== activeChat.id)
|
111 |
+
]
|
112 |
+
: chats;
|
113 |
+
|
114 |
+
storage.set(STORAGE_KEYS.CHATS, allChats);
|
115 |
+
|
116 |
+
// Dispatch a custom event to notify storage updates
|
117 |
+
window.dispatchEvent(new Event("storage-updated"));
|
118 |
+
}, [chats, activeChat]);
|
119 |
+
|
120 |
+
const scrollToBottom = () => {
|
121 |
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
122 |
+
};
|
123 |
|
124 |
useEffect(() => {
|
125 |
+
scrollToBottom();
|
126 |
+
}, [activeChat?.messages]);
|
127 |
+
|
128 |
+
useEffect(() => {
|
129 |
+
// Focus input when component mounts or when loading ends
|
130 |
+
if (!isLoading) {
|
131 |
+
setTimeout(() => {
|
132 |
+
inputRef.current?.focus();
|
133 |
+
}, 100);
|
134 |
+
}
|
135 |
+
}, [isLoading]);
|
136 |
|
137 |
+
// Event listeners for custom events
|
138 |
+
useEffect(() => {
|
139 |
+
const handleNewChat = () => {
|
140 |
+
const newChat: Chat = {
|
141 |
+
id: generateId(),
|
142 |
+
title: "New Chat",
|
143 |
+
messages: [
|
144 |
+
{
|
145 |
+
id: generateId(),
|
146 |
+
content: WELCOME_MESSAGE,
|
147 |
+
sender: "system" as const,
|
148 |
+
timestamp: new Date()
|
149 |
+
}
|
150 |
+
],
|
151 |
+
createdAt: new Date(),
|
152 |
+
updatedAt: new Date()
|
153 |
+
};
|
154 |
+
|
155 |
+
setActiveChat(newChat);
|
156 |
+
setChats(prev => [newChat, ...prev]);
|
157 |
+
setIsSidebarOpen(false);
|
158 |
+
setTimeout(() => {
|
159 |
+
inputRef.current?.focus();
|
160 |
+
}, 100);
|
161 |
+
};
|
162 |
+
|
163 |
+
const handleSelectChat = (e: Event) => {
|
164 |
+
const customEvent = e as CustomEvent;
|
165 |
+
const chatId = customEvent.detail?.chatId;
|
166 |
+
if (chatId) {
|
167 |
+
const selectedChat = chats.find(chat => chat.id === chatId);
|
168 |
+
if (selectedChat) {
|
169 |
+
setActiveChat(selectedChat);
|
170 |
+
setIsSidebarOpen(false);
|
171 |
+
setTimeout(() => {
|
172 |
+
inputRef.current?.focus();
|
173 |
+
}, 100);
|
174 |
}
|
175 |
+
}
|
176 |
+
};
|
177 |
|
178 |
+
const handleDeleteChat = (e: Event) => {
|
179 |
+
const customEvent = e as CustomEvent;
|
180 |
+
const chatId = customEvent.detail?.chatId;
|
181 |
+
if (chatId) {
|
182 |
+
const updatedChats = chats.filter(chat => chat.id !== chatId);
|
183 |
+
setChats(updatedChats);
|
184 |
+
|
185 |
+
// If we're deleting the active chat, switch to another one
|
186 |
+
if (activeChat?.id === chatId) {
|
187 |
+
setActiveChat(updatedChats.length > 0 ? updatedChats[0] : null);
|
188 |
+
|
189 |
+
// If no chats left, create a new one
|
190 |
+
if (updatedChats.length === 0) {
|
191 |
+
handleNewChat();
|
192 |
+
}
|
193 |
+
}
|
194 |
+
}
|
195 |
+
};
|
196 |
+
|
197 |
+
document.addEventListener("insight:new-chat", handleNewChat);
|
198 |
+
document.addEventListener("insight:select-chat", handleSelectChat);
|
199 |
+
document.addEventListener("insight:delete-chat", handleDeleteChat);
|
200 |
+
|
201 |
+
return () => {
|
202 |
+
document.removeEventListener("insight:new-chat", handleNewChat);
|
203 |
+
document.removeEventListener("insight:select-chat", handleSelectChat);
|
204 |
+
document.removeEventListener("insight:delete-chat", handleDeleteChat);
|
205 |
+
};
|
206 |
+
}, [chats, activeChat]);
|
207 |
+
|
208 |
+
const generateChatTitle = async (query: string) => {
|
209 |
+
if (!activeChat) return;
|
210 |
+
|
211 |
+
// Only generate titles for new chats
|
212 |
+
if (activeChat.title !== "New Chat") return;
|
213 |
+
|
214 |
+
setIsGeneratingTitle(true);
|
215 |
+
|
216 |
+
try {
|
217 |
+
const response = await rulingService.generateTitle(query);
|
218 |
+
|
219 |
+
if (response.title) {
|
220 |
+
// Update the existing chat object directly
|
221 |
+
setActiveChat(prevChat => {
|
222 |
+
if (!prevChat) return null;
|
223 |
+
|
224 |
+
const updatedChat = {
|
225 |
+
...prevChat,
|
226 |
+
title: response.title
|
227 |
+
};
|
228 |
+
|
229 |
+
// Update chats list
|
230 |
+
setChats(prevChats =>
|
231 |
+
prevChats.map(chat =>
|
232 |
+
chat.id === updatedChat.id ? updatedChat : chat
|
233 |
+
)
|
234 |
+
);
|
235 |
+
|
236 |
+
return updatedChat;
|
237 |
+
});
|
238 |
+
}
|
239 |
+
} catch (error) {
|
240 |
+
console.error("Error generating chat title:", error);
|
241 |
+
// Fallback to using query as title if title generation fails
|
242 |
+
if (activeChat.title === "New Chat") {
|
243 |
+
setActiveChat(prevChat => {
|
244 |
+
if (!prevChat) return null;
|
245 |
+
|
246 |
+
const updatedChat = {
|
247 |
+
...prevChat,
|
248 |
+
title: query.slice(0, 30) + (query.length > 30 ? '...' : '')
|
249 |
+
};
|
250 |
+
|
251 |
+
// Update chats list
|
252 |
+
setChats(prevChats =>
|
253 |
+
prevChats.map(chat =>
|
254 |
+
chat.id === updatedChat.id ? updatedChat : chat
|
255 |
+
)
|
256 |
+
);
|
257 |
+
|
258 |
+
return updatedChat;
|
259 |
});
|
|
|
|
|
260 |
}
|
261 |
+
} finally {
|
262 |
+
setIsGeneratingTitle(false);
|
263 |
+
}
|
264 |
+
};
|
265 |
+
|
266 |
+
const handleSendMessage = async (e?: React.FormEvent) => {
|
267 |
+
if (e) e.preventDefault();
|
268 |
+
|
269 |
+
if (!inputValue.trim() || !activeChat) return;
|
270 |
+
|
271 |
+
const userMessage: Message = {
|
272 |
+
id: generateId(),
|
273 |
+
content: inputValue,
|
274 |
+
sender: "user",
|
275 |
+
timestamp: new Date()
|
276 |
};
|
277 |
|
278 |
+
const loadingMessage: Message = {
|
279 |
+
id: generateId(),
|
280 |
+
content: "",
|
281 |
+
sender: "system",
|
282 |
+
timestamp: new Date(),
|
283 |
+
isLoading: true
|
284 |
+
};
|
285 |
|
286 |
+
// Update active chat with new messages
|
287 |
+
const updatedChat = {
|
288 |
+
...activeChat,
|
289 |
+
messages: [...activeChat.messages, userMessage, loadingMessage],
|
290 |
+
updatedAt: new Date()
|
291 |
+
};
|
292 |
+
|
293 |
+
setActiveChat(updatedChat);
|
294 |
+
setInputValue("");
|
295 |
+
setIsLoading(true);
|
296 |
+
|
297 |
+
try {
|
298 |
+
// Prepare chat history for the API
|
299 |
+
const chatHistory: APIMessage[] = updatedChat.messages
|
300 |
+
.filter(msg => !msg.isLoading && msg.content) // filter out loading messages and empty messages
|
301 |
+
.slice(0, -1) // exclude the loading message we just added
|
302 |
+
.map(msg => ({
|
303 |
+
role: msg.sender === "user" ? "user" : "assistant",
|
304 |
+
content: msg.content
|
305 |
+
}));
|
306 |
+
|
307 |
+
const response = await rulingService.queryRulings({
|
308 |
+
query: userMessage.content,
|
309 |
+
chat_history: chatHistory
|
310 |
+
});
|
311 |
+
|
312 |
+
// Replace loading message with actual response
|
313 |
+
const updatedMessages = updatedChat.messages.map(msg =>
|
314 |
+
msg.id === loadingMessage.id
|
315 |
+
? {
|
316 |
+
...msg,
|
317 |
+
content: response.answer,
|
318 |
+
isLoading: false,
|
319 |
+
result: response.retrieved_sources
|
320 |
+
}
|
321 |
+
: msg
|
322 |
+
);
|
323 |
+
|
324 |
+
const finalChat = {
|
325 |
+
...updatedChat,
|
326 |
+
messages: updatedMessages,
|
327 |
+
};
|
328 |
+
|
329 |
+
setActiveChat(finalChat);
|
330 |
+
|
331 |
+
// Update chats list
|
332 |
+
setChats(prevChats =>
|
333 |
+
prevChats.map(chat =>
|
334 |
+
chat.id === finalChat.id ? finalChat : chat
|
335 |
+
)
|
336 |
+
);
|
337 |
+
|
338 |
+
// Generate title if this is a new chat
|
339 |
+
if (updatedChat.title === "New Chat" && updatedChat.messages.length <= 3) {
|
340 |
+
generateChatTitle(userMessage.content);
|
341 |
+
}
|
342 |
+
|
343 |
+
} catch (error) {
|
344 |
+
console.error("Error querying rulings:", error);
|
345 |
+
|
346 |
+
// Replace loading message with error
|
347 |
+
const updatedMessages = updatedChat.messages.map(msg =>
|
348 |
+
msg.id === loadingMessage.id
|
349 |
+
? {
|
350 |
+
...msg,
|
351 |
+
content: "I'm sorry, I couldn't process your request. Please try again.",
|
352 |
+
isLoading: false,
|
353 |
+
error: true
|
354 |
+
}
|
355 |
+
: msg
|
356 |
+
);
|
357 |
+
|
358 |
+
setActiveChat({
|
359 |
+
...updatedChat,
|
360 |
+
messages: updatedMessages
|
361 |
+
});
|
362 |
+
|
363 |
+
toast.error("Failed to process your request");
|
364 |
+
} finally {
|
365 |
+
setIsLoading(false);
|
366 |
+
// Refocus the input after sending message
|
367 |
+
setTimeout(() => {
|
368 |
+
inputRef.current?.focus();
|
369 |
+
}, 100);
|
370 |
+
}
|
371 |
+
};
|
372 |
+
|
373 |
+
const handleViewSearchResults = (messageId: string) => {
|
374 |
+
const message = activeChat?.messages.find(msg => msg.id === messageId);
|
375 |
+
if (message && message.content) {
|
376 |
+
navigate(`/sources`);
|
377 |
+
}
|
378 |
+
};
|
379 |
+
|
380 |
+
const handleNewChat = () => {
|
381 |
+
const newChat: Chat = {
|
382 |
+
id: generateId(),
|
383 |
+
title: "New Chat",
|
384 |
+
messages: [
|
385 |
+
{
|
386 |
+
id: generateId(),
|
387 |
+
content: WELCOME_MESSAGE,
|
388 |
+
sender: "system" as const,
|
389 |
+
timestamp: new Date()
|
390 |
+
}
|
391 |
+
],
|
392 |
+
createdAt: new Date(),
|
393 |
+
updatedAt: new Date()
|
394 |
+
};
|
395 |
+
|
396 |
+
setActiveChat(newChat);
|
397 |
+
setChats(prev => [newChat, ...prev]);
|
398 |
+
inputRef.current?.focus();
|
399 |
+
setIsSidebarOpen(false);
|
400 |
+
};
|
401 |
+
|
402 |
+
const handleSelectChat = (chatId: string) => {
|
403 |
+
const selectedChat = chats.find(chat => chat.id === chatId);
|
404 |
+
if (selectedChat) {
|
405 |
+
setActiveChat(selectedChat);
|
406 |
+
setIsSidebarOpen(false);
|
407 |
+
setTimeout(() => {
|
408 |
+
inputRef.current?.focus();
|
409 |
+
}, 100);
|
410 |
+
}
|
411 |
+
};
|
412 |
+
|
413 |
+
const handleDeleteChat = (chatId: string, e: React.MouseEvent) => {
|
414 |
+
e.stopPropagation();
|
415 |
+
|
416 |
+
const updatedChats = chats.filter(chat => chat.id !== chatId);
|
417 |
+
setChats(updatedChats);
|
418 |
+
|
419 |
+
// If we're deleting the active chat, switch to another one
|
420 |
+
if (activeChat?.id === chatId) {
|
421 |
+
setActiveChat(updatedChats.length > 0 ? updatedChats[0] : null);
|
422 |
+
|
423 |
+
// If no chats left, create a new one
|
424 |
+
if (updatedChats.length === 0) {
|
425 |
+
handleNewChat();
|
426 |
+
}
|
427 |
+
}
|
428 |
+
};
|
429 |
+
|
430 |
+
const handleRetryMessage = (messageId: string) => {
|
431 |
+
if (!activeChat) return;
|
432 |
+
|
433 |
+
// Find the failed message
|
434 |
+
const failedMessageIndex = activeChat.messages.findIndex(
|
435 |
+
msg => msg.id === messageId && msg.error
|
436 |
);
|
437 |
+
|
438 |
+
if (failedMessageIndex < 0) return;
|
439 |
+
|
440 |
+
// Get the last user message before this failed message
|
441 |
+
let userMessageContent = "";
|
442 |
+
for (let i = failedMessageIndex - 1; i >= 0; i--) {
|
443 |
+
if (activeChat.messages[i].sender === "user") {
|
444 |
+
userMessageContent = activeChat.messages[i].content;
|
445 |
+
break;
|
446 |
+
}
|
447 |
+
}
|
448 |
+
|
449 |
+
if (!userMessageContent) return;
|
450 |
+
|
451 |
+
// Remove the failed message
|
452 |
+
const updatedMessages = [...activeChat.messages];
|
453 |
+
updatedMessages[failedMessageIndex] = {
|
454 |
+
...updatedMessages[failedMessageIndex],
|
455 |
+
isLoading: true,
|
456 |
+
error: false,
|
457 |
+
content: ""
|
458 |
+
};
|
459 |
+
|
460 |
+
const updatedChat = {
|
461 |
+
...activeChat,
|
462 |
+
messages: updatedMessages
|
463 |
+
};
|
464 |
+
|
465 |
+
setActiveChat(updatedChat);
|
466 |
+
setIsLoading(true);
|
467 |
+
|
468 |
+
// Prepare chat history for the API
|
469 |
+
const chatHistory: APIMessage[] = updatedChat.messages
|
470 |
+
.filter(msg => !msg.isLoading && msg.content && updatedChat.messages.indexOf(msg) < failedMessageIndex - 1)
|
471 |
+
.map(msg => ({
|
472 |
+
role: msg.sender === "user" ? "user" : "assistant",
|
473 |
+
content: msg.content
|
474 |
+
}));
|
475 |
+
|
476 |
+
// Retry the query
|
477 |
+
rulingService.queryRulings({
|
478 |
+
query: userMessageContent,
|
479 |
+
chat_history: chatHistory
|
480 |
+
})
|
481 |
+
.then(response => {
|
482 |
+
const finalMessages = [...updatedMessages];
|
483 |
+
finalMessages[failedMessageIndex] = {
|
484 |
+
...finalMessages[failedMessageIndex],
|
485 |
+
content: response.answer,
|
486 |
+
isLoading: false,
|
487 |
+
error: false,
|
488 |
+
result: response.retrieved_sources
|
489 |
+
};
|
490 |
+
|
491 |
+
const finalChat = {
|
492 |
+
...updatedChat,
|
493 |
+
messages: finalMessages
|
494 |
+
};
|
495 |
+
|
496 |
+
setActiveChat(finalChat);
|
497 |
+
|
498 |
+
// Update chats list
|
499 |
+
setChats(prevChats =>
|
500 |
+
prevChats.map(chat =>
|
501 |
+
chat.id === finalChat.id ? finalChat : chat
|
502 |
+
)
|
503 |
+
);
|
504 |
+
})
|
505 |
+
.catch(error => {
|
506 |
+
console.error("Error retrying query:", error);
|
507 |
+
|
508 |
+
const finalMessages = [...updatedMessages];
|
509 |
+
finalMessages[failedMessageIndex] = {
|
510 |
+
...finalMessages[failedMessageIndex],
|
511 |
+
content: "I'm sorry, I couldn't process your request. Please try again.",
|
512 |
+
isLoading: false,
|
513 |
+
error: true
|
514 |
+
};
|
515 |
+
|
516 |
+
setActiveChat({
|
517 |
+
...updatedChat,
|
518 |
+
messages: finalMessages
|
519 |
+
});
|
520 |
+
|
521 |
+
toast.error("Failed to process your request");
|
522 |
+
})
|
523 |
+
.finally(() => {
|
524 |
+
setIsLoading(false);
|
525 |
+
setTimeout(() => {
|
526 |
+
inputRef.current?.focus();
|
527 |
+
}, 100);
|
528 |
+
});
|
529 |
+
};
|
530 |
+
|
531 |
+
const toggleSidebar = () => {
|
532 |
+
setIsSidebarOpen(!isSidebarOpen);
|
533 |
+
};
|
534 |
|
535 |
return (
|
536 |
+
<div className="flex h-full">
|
537 |
+
{/* Chat Sidebar */}
|
538 |
+
<div className={cn(
|
539 |
+
"fixed top-0 bottom-0 left-0 z-20 bg-background/90 backdrop-blur-lg",
|
540 |
+
"transition-transform duration-300 ease-in-out",
|
541 |
+
isSidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
542 |
+
"w-72 lg:w-80 border-r border-border/50 flex-shrink-0",
|
543 |
+
"md:relative md:inset-auto h-full md:z-0"
|
544 |
+
)}>
|
545 |
+
<div className="flex flex-col h-full">
|
546 |
+
<div className="p-4 pb-0 border-b border-border/50">
|
547 |
+
<div className="flex pb-4 justify-between items-center gap-3">
|
548 |
+
<Link to="/" className="flex items-center space-x-2">
|
549 |
+
<div className="h-8 w-8 bg-financial-accent rounded-md flex items-center justify-center">
|
550 |
+
<span className="text-white font-bold text-lg">AI</span>
|
551 |
+
</div>
|
552 |
+
<h1 className="text-xl font-semibold text-financial-navy dark:text-white">
|
553 |
+
Insight AI
|
554 |
+
</h1>
|
555 |
+
</Link>
|
556 |
+
<Button className="md:hidden" variant="ghost" size="icon" onClick={toggleSidebar}>
|
557 |
+
<PanelLeft className="h-5 w-5" />
|
558 |
+
</Button>
|
559 |
+
</div>
|
560 |
+
<Separator />
|
561 |
+
{/* navigation */}
|
562 |
+
<div className="flex flex-row gap-2 mb-4">
|
563 |
+
{navItems.map((item) => (
|
564 |
+
<Link
|
565 |
+
key={item.name}
|
566 |
+
to={item.path}
|
567 |
+
className={cn(
|
568 |
+
"flex items-center gap-2 p-2 rounded-md text-sm",
|
569 |
+
item.path === location.pathname
|
570 |
+
? "bg-financial-accent/30 border border-financial-accent/30"
|
571 |
+
: "hover:bg-muted text-muted-foreground"
|
572 |
+
)}
|
573 |
+
onClick={() => setIsSidebarOpen(false)}
|
574 |
+
>
|
575 |
+
<item.icon className="h-4 w-4" />
|
576 |
+
<span>{item.name}</span>
|
577 |
+
</Link>
|
578 |
+
))}
|
579 |
+
</div>
|
580 |
+
<div className="flex items-center justify-between">
|
581 |
+
<div className="flex items-center gap-2">
|
582 |
+
<Bot className="h-5 w-5 text-financial-accent" />
|
583 |
+
<span className="font-semibold">Recent Chats</span>
|
584 |
+
</div>
|
585 |
+
<Button
|
586 |
+
onClick={handleNewChat}
|
587 |
+
variant="ghost"
|
588 |
+
className="h-8 w-8 p-0 hover:bg-financial-accent/10 hover:text-financial-accent"
|
589 |
+
>
|
590 |
+
<Plus className="h-4 w-4" />
|
591 |
+
</Button>
|
592 |
+
</div>
|
593 |
+
</div>
|
594 |
+
|
595 |
+
<div className="flex-1 overflow-y-auto p-1 space-y-2 scrollbar-thin">
|
596 |
+
{chats.length === 0 ? (
|
597 |
+
<div className="text-center text-muted-foreground p-4">
|
598 |
+
No conversations yet. Start a new one!
|
599 |
+
</div>
|
600 |
+
) : (
|
601 |
+
chats.map(chat => (
|
602 |
+
<div
|
603 |
+
key={chat.id}
|
604 |
+
onClick={() => handleSelectChat(chat.id)}
|
605 |
+
className={cn(
|
606 |
+
"flex items-center justify-between p-1 px-4 rounded-lg cursor-pointer group transition-all",
|
607 |
+
activeChat?.id === chat.id
|
608 |
+
? "bg-financial-accent/20 border border-financial-accent/30"
|
609 |
+
: "hover:bg-muted/50 border border-transparent"
|
610 |
+
)}
|
611 |
+
>
|
612 |
+
<div className="flex-1 truncate">
|
613 |
+
<div className={cn(
|
614 |
+
"font-medium truncate flex items-center",
|
615 |
+
activeChat?.id === chat.id && "text-financial-accent"
|
616 |
+
)}>
|
617 |
+
<Bot className="h-3.5 w-3.5 mr-1.5 opacity-70" />
|
618 |
+
{chat.title}
|
619 |
+
{chat.id === activeChat?.id && isGeneratingTitle && (
|
620 |
+
<span className="ml-1.5 inline-block h-2 w-2 rounded-full bg-financial-accent/70 animate-pulse"></span>
|
621 |
+
)}
|
622 |
+
</div>
|
623 |
+
<div className="text-xs text-muted-foreground">
|
624 |
+
{chat.messages.filter(m => m.sender === "user").length} messages • {format(new Date(chat.updatedAt), "MMM d")}
|
625 |
+
</div>
|
626 |
+
</div>
|
627 |
+
<Button
|
628 |
+
variant="ghost"
|
629 |
+
size="icon"
|
630 |
+
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive/10 hover:text-destructive"
|
631 |
+
onClick={(e) => handleDeleteChat(chat.id, e)}
|
632 |
+
>
|
633 |
+
<TrashIcon className="h-3.5 w-3.5" />
|
634 |
+
</Button>
|
635 |
+
</div>
|
636 |
+
))
|
637 |
+
)}
|
638 |
+
</div>
|
639 |
+
|
640 |
+
{/* Sidebar Footer */}
|
641 |
+
<div className="p-3 border-t mt-auto flex justify-between items-center">
|
642 |
+
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1.5">
|
643 |
+
<Settings className="h-3.5 w-3.5" />
|
644 |
+
<span>Settings</span>
|
645 |
+
</Link>
|
646 |
+
|
647 |
+
<ModeToggle />
|
648 |
+
</div>
|
649 |
+
</div>
|
650 |
+
</div>
|
651 |
+
|
652 |
+
{/* Chat Main Area */}
|
653 |
+
<div className="flex-1 flex flex-col overflow-hidden">
|
654 |
+
{/* Mobile Header */}
|
655 |
+
<div className="md:hidden p-2 flex items-center border-b">
|
656 |
+
<Button variant="ghost" size="icon" onClick={toggleSidebar}>
|
657 |
+
<PanelLeft className="h-5 w-5" />
|
658 |
+
</Button>
|
659 |
+
<div className="mx-auto font-medium flex items-center">
|
660 |
+
<Bot className="h-4 w-4 mr-1.5" />
|
661 |
+
{activeChat?.title || "New Chat"}
|
662 |
</div>
|
663 |
+
<Button variant="ghost" size="icon" onClick={handleNewChat}>
|
664 |
+
<Plus className="h-5 w-5" />
|
665 |
+
</Button>
|
666 |
+
</div>
|
667 |
+
|
668 |
+
{/* Chat Area */}
|
669 |
+
<div className="relative flex-1 overflow-hidden">
|
670 |
+
{!activeChat ? (
|
671 |
+
<div className="h-full flex flex-col items-center justify-center">
|
672 |
+
<div className="text-center max-w-md p-8 bg-card/40 backdrop-blur-sm rounded-2xl border border-border/50">
|
673 |
+
<Bot className="h-10 w-10 mx-auto mb-4 text-financial-accent" />
|
674 |
+
<h2 className="text-2xl font-bold mb-2 bg-gradient-to-br from-financial-accent to-financial-light-accent bg-clip-text text-transparent">Welcome to Insight AI</h2>
|
675 |
+
<p className="text-muted-foreground mb-6">
|
676 |
+
Ask me anything, and I'll do my best to help you
|
677 |
+
</p>
|
678 |
+
<Button onClick={handleNewChat} className="animate-bounce-in bg-financial-accent hover:bg-financial-accent/90">
|
679 |
+
Start a new conversation
|
680 |
+
</Button>
|
681 |
+
</div>
|
682 |
+
</div>
|
683 |
+
) : (
|
684 |
+
<>
|
685 |
+
<div className="h-full overflow-y-auto px-4 pb-32 pt-4">
|
686 |
+
<div className="max-w-3xl mx-auto space-y-4">
|
687 |
+
{activeChat.messages.map((message) => (
|
688 |
+
<ChatBubble
|
689 |
+
key={message.id}
|
690 |
+
message={message}
|
691 |
+
onViewSearchResults={handleViewSearchResults}
|
692 |
+
onRetry={handleRetryMessage}
|
693 |
+
/>
|
694 |
+
))}
|
695 |
+
<div ref={messagesEndRef} />
|
696 |
+
</div>
|
697 |
+
</div>
|
698 |
+
|
699 |
+
{/* Input Area */}
|
700 |
+
<div className="absolute bottom-0 left-0 right-0 p-4 bg-background/80 dark:bg-background/50 backdrop-blur-md border-t border-border/30">
|
701 |
+
<div className="max-w-3xl mx-auto">
|
702 |
+
<form onSubmit={handleSendMessage} className="relative">
|
703 |
+
<div className="relative">
|
704 |
+
<Input
|
705 |
+
ref={inputRef}
|
706 |
+
type="text"
|
707 |
+
placeholder="Ask me anything..."
|
708 |
+
value={inputValue}
|
709 |
+
onChange={(e) => setInputValue(e.target.value)}
|
710 |
+
className="pr-20 py-6 text-base bg-background/50 border border-border/50 focus:border-financial-accent/50 focus-visible:ring-1 focus-visible:ring-financial-accent/50 rounded-xl"
|
711 |
+
disabled={isLoading}
|
712 |
+
/>
|
713 |
+
|
714 |
+
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
715 |
+
<Button
|
716 |
+
type="submit"
|
717 |
+
size="icon"
|
718 |
+
className="rounded-lg bg-financial-accent hover:bg-financial-accent/90"
|
719 |
+
disabled={isLoading || !inputValue.trim()}
|
720 |
+
>
|
721 |
+
{isLoading ? (
|
722 |
+
<div className="h-4 w-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
723 |
+
) : (
|
724 |
+
<CornerDownLeft className="h-4 w-4" />
|
725 |
+
)}
|
726 |
+
</Button>
|
727 |
+
</div>
|
728 |
+
</div>
|
729 |
+
|
730 |
+
<div className="mt-2 text-center text-xs text-muted-foreground">
|
731 |
+
Insight AI may produce inaccurate information. Verify important details.
|
732 |
+
</div>
|
733 |
+
</form>
|
734 |
+
</div>
|
735 |
+
</div>
|
736 |
+
</>
|
737 |
+
)}
|
738 |
+
</div>
|
739 |
</div>
|
740 |
</div>
|
741 |
);
|
frontend/src/pages/Index.tsx
CHANGED
@@ -1,17 +1,8 @@
|
|
1 |
-
|
2 |
-
import
|
3 |
-
import HomePage from './HomePage';
|
4 |
|
5 |
const Index = () => {
|
6 |
-
return
|
7 |
-
<div className="min-h-screen bg-netflix-black text-white">
|
8 |
-
<Navbar />
|
9 |
-
<main>
|
10 |
-
<HomePage />
|
11 |
-
</main>
|
12 |
-
<Footer />
|
13 |
-
</div>
|
14 |
-
);
|
15 |
};
|
16 |
|
17 |
export default Index;
|
|
|
1 |
+
|
2 |
+
import { Navigate } from "react-router-dom";
|
|
|
3 |
|
4 |
const Index = () => {
|
5 |
+
return <Navigate to="/" replace />;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
};
|
7 |
|
8 |
export default Index;
|
frontend/src/pages/MainLayout.tsx
DELETED
@@ -1,18 +0,0 @@
|
|
1 |
-
|
2 |
-
import Navbar from '../components/Navbar';
|
3 |
-
import Footer from '../components/Footer';
|
4 |
-
import { Outlet } from 'react-router-dom';
|
5 |
-
|
6 |
-
const MainLayout = () => {
|
7 |
-
return (
|
8 |
-
<div className="min-h-screen bg-netflix-black text-white">
|
9 |
-
<Navbar />
|
10 |
-
<main>
|
11 |
-
<Outlet />
|
12 |
-
</main>
|
13 |
-
<Footer />
|
14 |
-
</div>
|
15 |
-
);
|
16 |
-
};
|
17 |
-
|
18 |
-
export default MainLayout;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/MovieDetailPage.tsx
DELETED
@@ -1,226 +0,0 @@
|
|
1 |
-
import React, { useEffect, useState } from 'react';
|
2 |
-
import { useParams, Link } from 'react-router-dom';
|
3 |
-
import { Play, Plus, ThumbsUp, Share2 } from 'lucide-react';
|
4 |
-
import { getMovieMetadata, getGenresItems, getMovieCard } from '../lib/api';
|
5 |
-
import ContentRow from '../components/ContentRow';
|
6 |
-
import { useToast } from '@/hooks/use-toast';
|
7 |
-
|
8 |
-
const MovieDetailPage = () => {
|
9 |
-
const { title } = useParams<{ title: string }>();
|
10 |
-
const [movie, setMovie] = useState<any>(null);
|
11 |
-
const [loading, setLoading] = useState(true);
|
12 |
-
const [similarMovies, setSimilarMovies] = useState<any[]>([]);
|
13 |
-
const { toast } = useToast();
|
14 |
-
|
15 |
-
useEffect(() => {
|
16 |
-
const fetchMovieData = async () => {
|
17 |
-
if (!title) return;
|
18 |
-
|
19 |
-
try {
|
20 |
-
setLoading(true);
|
21 |
-
const data = await getMovieMetadata(title);
|
22 |
-
setMovie(data);
|
23 |
-
const movieData = data.data;
|
24 |
-
console.log(movieData);
|
25 |
-
|
26 |
-
// Fetch similar movies based on individual genres
|
27 |
-
if (movieData.genres && movieData.genres.length > 0) {
|
28 |
-
const currentMovieName = movieData.name;
|
29 |
-
const moviesByGenre = await Promise.all(
|
30 |
-
movieData.genres.map(async (genre: any) => {
|
31 |
-
// Pass a single genre name for each call
|
32 |
-
const genreResult = await getGenresItems([genre.name], 'movie', 10, 1);
|
33 |
-
if (genreResult.movies && Array.isArray(genreResult.movies)) {
|
34 |
-
return genreResult.movies.map((movieItem: any) => {
|
35 |
-
const { title: similarTitle } = movieItem;
|
36 |
-
// Skip current movie
|
37 |
-
if (similarTitle === currentMovieName) return null;
|
38 |
-
return {
|
39 |
-
type: 'movie',
|
40 |
-
title: similarTitle,
|
41 |
-
};
|
42 |
-
});
|
43 |
-
}
|
44 |
-
return [];
|
45 |
-
})
|
46 |
-
);
|
47 |
-
// Flatten the array of arrays and remove null results
|
48 |
-
const flattenedMovies = moviesByGenre.flat().filter(Boolean);
|
49 |
-
// Remove duplicates based on the title
|
50 |
-
const uniqueMovies = Array.from(
|
51 |
-
new Map(flattenedMovies.map(movie => [movie.title, movie])).values()
|
52 |
-
);
|
53 |
-
setSimilarMovies(uniqueMovies);
|
54 |
-
}
|
55 |
-
} catch (error) {
|
56 |
-
console.error(`Error fetching movie details for ${title}:`, error);
|
57 |
-
toast({
|
58 |
-
title: "Error loading movie details",
|
59 |
-
description: "Please try again later",
|
60 |
-
variant: "destructive"
|
61 |
-
});
|
62 |
-
} finally {
|
63 |
-
setLoading(false);
|
64 |
-
}
|
65 |
-
};
|
66 |
-
|
67 |
-
fetchMovieData();
|
68 |
-
}, [title, toast]);
|
69 |
-
|
70 |
-
if (loading) {
|
71 |
-
return (
|
72 |
-
<div className="flex items-center justify-center min-h-screen">
|
73 |
-
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-netflix-red"></div>
|
74 |
-
</div>
|
75 |
-
);
|
76 |
-
}
|
77 |
-
|
78 |
-
if (!movie) {
|
79 |
-
return (
|
80 |
-
<div className="pt-24 px-4 md:px-8 text-center min-h-screen">
|
81 |
-
<h1 className="text-3xl font-bold mb-4">Movie Not Found</h1>
|
82 |
-
<p className="text-netflix-gray mb-6">We couldn't find the movie you're looking for.</p>
|
83 |
-
<Link to="/movies" className="bg-netflix-red px-6 py-2 rounded font-medium">
|
84 |
-
Back to Movies
|
85 |
-
</Link>
|
86 |
-
</div>
|
87 |
-
);
|
88 |
-
}
|
89 |
-
|
90 |
-
// Use movieData fields from the new structure
|
91 |
-
const movieData = movie.data;
|
92 |
-
const runtime = movieData.runtime
|
93 |
-
? `${Math.floor(movieData.runtime / 60)}h ${movieData.runtime % 60}m`
|
94 |
-
: '';
|
95 |
-
const releaseYear = movieData.year || '';
|
96 |
-
const movieName = (movieData.translations?.nameTranslations?.find((t: any) => t.language === 'eng')?.name || movieData.name || '');
|
97 |
-
const overview =
|
98 |
-
movieData.overview ||
|
99 |
-
(movieData.translations?.overviewTranslations?.find((t: any) => t.language === 'eng')?.overview || '');
|
100 |
-
|
101 |
-
return (
|
102 |
-
<div className="pb-12 animate-fade-in">
|
103 |
-
{/* Hero backdrop */}
|
104 |
-
<div className="relative w-full h-[500px] md:h-[600px]">
|
105 |
-
<div className="absolute inset-0">
|
106 |
-
<img
|
107 |
-
src={movieData.image}
|
108 |
-
alt={movieName}
|
109 |
-
className="w-full h-full object-cover"
|
110 |
-
onError={(e) => {
|
111 |
-
const target = e.target as HTMLImageElement;
|
112 |
-
target.src = '/placeholder.svg';
|
113 |
-
}}
|
114 |
-
/>
|
115 |
-
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
|
116 |
-
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" />
|
117 |
-
</div>
|
118 |
-
</div>
|
119 |
-
|
120 |
-
{/* Movie details */}
|
121 |
-
<div className="px-4 md:px-8 -mt-60 relative z-10 max-w-7xl mx-auto">
|
122 |
-
<div className="flex flex-col md:flex-row gap-8">
|
123 |
-
{/* Poster */}
|
124 |
-
<div className="flex-shrink-0 hidden md:block">
|
125 |
-
<img
|
126 |
-
src={movieData.image}
|
127 |
-
alt={movieName}
|
128 |
-
className="w-64 h-96 object-cover rounded-md shadow-lg"
|
129 |
-
onError={(e) => {
|
130 |
-
const target = e.target as HTMLImageElement;
|
131 |
-
target.src = '/placeholder.svg';
|
132 |
-
}}
|
133 |
-
/>
|
134 |
-
</div>
|
135 |
-
|
136 |
-
{/* Details */}
|
137 |
-
<div className="flex-grow">
|
138 |
-
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-3">{movieName}</h1>
|
139 |
-
|
140 |
-
<div className="flex flex-wrap items-center text-sm text-gray-300 mb-6">
|
141 |
-
{releaseYear && <span className="mr-3">{releaseYear}</span>}
|
142 |
-
{runtime && <span className="mr-3">{runtime}</span>}
|
143 |
-
{movieData.contentRatings && movieData.contentRatings.length > 0 && (
|
144 |
-
<span className="mr-3 bg-netflix-red/80 px-2 py-0.5 rounded text-xs">
|
145 |
-
{movieData.contentRatings[0].name}+
|
146 |
-
</span>
|
147 |
-
)}
|
148 |
-
</div>
|
149 |
-
|
150 |
-
<div className="flex flex-wrap items-center gap-2 my-4">
|
151 |
-
{movieData.genres &&
|
152 |
-
movieData.genres.map((genre: any) => (
|
153 |
-
<Link
|
154 |
-
key={genre.id}
|
155 |
-
to={`/movies?genre=${genre.name}`}
|
156 |
-
className="px-3 py-1 bg-netflix-gray/20 rounded-full text-sm hover:bg-netflix-gray/40 transition"
|
157 |
-
>
|
158 |
-
{genre.name}
|
159 |
-
</Link>
|
160 |
-
))}
|
161 |
-
</div>
|
162 |
-
|
163 |
-
<p className="text-gray-300 mb-8 max-w-3xl">{overview}</p>
|
164 |
-
|
165 |
-
<div className="flex flex-wrap gap-3 mb-8">
|
166 |
-
<Link
|
167 |
-
to={`/movie/${encodeURIComponent(title!)}/watch`}
|
168 |
-
className="flex items-center px-6 py-2 rounded bg-netflix-red text-white font-semibold hover:bg-red-700 transition"
|
169 |
-
>
|
170 |
-
<Play className="w-5 h-5 mr-2" /> Play
|
171 |
-
</Link>
|
172 |
-
|
173 |
-
<button className="flex items-center px-4 py-2 rounded bg-gray-700 text-white hover:bg-gray-600 transition">
|
174 |
-
<Plus className="w-5 h-5 mr-2" /> My List
|
175 |
-
</button>
|
176 |
-
|
177 |
-
<button className="flex items-center justify-center w-10 h-10 rounded-full bg-gray-700 text-white hover:bg-gray-600 transition">
|
178 |
-
<ThumbsUp className="w-5 h-5" />
|
179 |
-
</button>
|
180 |
-
|
181 |
-
<button className="flex items-center justify-center w-10 h-10 rounded-full bg-gray-700 text-white hover:bg-gray-600 transition">
|
182 |
-
<Share2 className="w-5 h-5" />
|
183 |
-
</button>
|
184 |
-
</div>
|
185 |
-
|
186 |
-
{/* Additional details */}
|
187 |
-
<div className="mb-6">
|
188 |
-
{movieData.translations?.nameTranslations?.find((t: any) => t.isPrimary) && (
|
189 |
-
<p className="text-gray-400 italic mb-4">
|
190 |
-
"{movieData.translations.nameTranslations.find((t: any) => t.isPrimary).tagline}"
|
191 |
-
</p>
|
192 |
-
)}
|
193 |
-
|
194 |
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
195 |
-
{movieData.production_companies && movieData.production_companies.length > 0 && (
|
196 |
-
<div>
|
197 |
-
<h3 className="text-gray-400 font-semibold mb-1">Production</h3>
|
198 |
-
<p className="text-white">
|
199 |
-
{movieData.production_companies.map((company: any) => company.name).join(', ')}
|
200 |
-
</p>
|
201 |
-
</div>
|
202 |
-
)}
|
203 |
-
|
204 |
-
{movieData.spoken_languages && movieData.spoken_languages.length > 0 && (
|
205 |
-
<div>
|
206 |
-
<h3 className="text-gray-400 font-semibold mb-1">Languages</h3>
|
207 |
-
<p className="text-white">{movieData.spoken_languages.join(', ')}</p>
|
208 |
-
</div>
|
209 |
-
)}
|
210 |
-
</div>
|
211 |
-
</div>
|
212 |
-
</div>
|
213 |
-
</div>
|
214 |
-
|
215 |
-
{/* Similar Movies */}
|
216 |
-
{similarMovies.length > 0 && (
|
217 |
-
<div className="mt-16">
|
218 |
-
<ContentRow title="More Like This" items={similarMovies} />
|
219 |
-
</div>
|
220 |
-
)}
|
221 |
-
</div>
|
222 |
-
</div>
|
223 |
-
);
|
224 |
-
};
|
225 |
-
|
226 |
-
export default MovieDetailPage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/MoviePlayerPage.tsx
DELETED
@@ -1,66 +0,0 @@
|
|
1 |
-
|
2 |
-
import React from 'react';
|
3 |
-
import { useParams, useNavigate } from 'react-router-dom';
|
4 |
-
import MoviePlayer from '../components/MoviePlayer';
|
5 |
-
|
6 |
-
const MoviePlayerPage = () => {
|
7 |
-
const { title } = useParams<{ title: string }>();
|
8 |
-
const navigate = useNavigate();
|
9 |
-
|
10 |
-
const handleBack = () => {
|
11 |
-
navigate(`/movie/${encodeURIComponent(title || '')}`);
|
12 |
-
};
|
13 |
-
|
14 |
-
// Save playback progress to localStorage
|
15 |
-
const savePlaybackProgress = (currentTime: number, duration: number) => {
|
16 |
-
if (!title) return;
|
17 |
-
|
18 |
-
try {
|
19 |
-
const progressKey = `movie-progress-${title}`;
|
20 |
-
const isCompleted = (currentTime / duration) > 0.9; // Mark as completed if 90% watched
|
21 |
-
|
22 |
-
localStorage.setItem(progressKey, JSON.stringify({
|
23 |
-
currentTime,
|
24 |
-
duration,
|
25 |
-
lastPlayed: new Date().toISOString(),
|
26 |
-
completed: isCompleted
|
27 |
-
}));
|
28 |
-
} catch (error) {
|
29 |
-
console.error('Error saving playback progress:', error);
|
30 |
-
}
|
31 |
-
};
|
32 |
-
|
33 |
-
// Fetch stored playback progress from localStorage
|
34 |
-
const getPlaybackProgress = () => {
|
35 |
-
if (!title) return 0;
|
36 |
-
|
37 |
-
try {
|
38 |
-
const progressKey = `movie-progress-${title}`;
|
39 |
-
const storedProgress = localStorage.getItem(progressKey);
|
40 |
-
|
41 |
-
if (storedProgress) {
|
42 |
-
const progress = JSON.parse(storedProgress);
|
43 |
-
if (progress && progress.currentTime && !progress.completed) {
|
44 |
-
return progress.currentTime;
|
45 |
-
}
|
46 |
-
}
|
47 |
-
} catch (error) {
|
48 |
-
console.error('Error reading playback progress:', error);
|
49 |
-
}
|
50 |
-
|
51 |
-
return 0;
|
52 |
-
};
|
53 |
-
|
54 |
-
return (
|
55 |
-
<div className="fixed inset-0 h-screen w-screen overflow-hidden bg-black">
|
56 |
-
<MoviePlayer
|
57 |
-
movieTitle={title || ''}
|
58 |
-
startTime={getPlaybackProgress()}
|
59 |
-
onClosePlayer={handleBack}
|
60 |
-
onProgressUpdate={savePlaybackProgress}
|
61 |
-
/>
|
62 |
-
</div>
|
63 |
-
);
|
64 |
-
};
|
65 |
-
|
66 |
-
export default MoviePlayerPage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/MoviesPage.tsx
DELETED
@@ -1,92 +0,0 @@
|
|
1 |
-
|
2 |
-
import React, { useEffect, useState } from 'react';
|
3 |
-
import PageHeader from '../components/PageHeader';
|
4 |
-
import ContentGrid from '../components/ContentGrid';
|
5 |
-
import { getAllMovies, getMovieCard } from '../lib/api';
|
6 |
-
import { useToast } from '@/hooks/use-toast';
|
7 |
-
import { useSearchParams } from 'react-router-dom';
|
8 |
-
|
9 |
-
const MoviesPage = () => {
|
10 |
-
const [loading, setLoading] = useState(true);
|
11 |
-
const [movies, setMovies] = useState<any[]>([]);
|
12 |
-
const [searchParams] = useSearchParams();
|
13 |
-
const genreFilter = searchParams.get('genre');
|
14 |
-
const { toast } = useToast();
|
15 |
-
|
16 |
-
useEffect(() => {
|
17 |
-
const fetchMovies = async () => {
|
18 |
-
try {
|
19 |
-
setLoading(true);
|
20 |
-
const allMovies = await getAllMovies();
|
21 |
-
|
22 |
-
// For each movie, get its card info for display
|
23 |
-
const moviePromises = allMovies.slice(0, 30).map(async (movie: any) => {
|
24 |
-
try {
|
25 |
-
const movieInfo = await getMovieCard(movie.title);
|
26 |
-
if (movieInfo) {
|
27 |
-
return {
|
28 |
-
type: 'movie',
|
29 |
-
title: movie.title,
|
30 |
-
image: movieInfo.image,
|
31 |
-
description: movieInfo.overview,
|
32 |
-
genre: movieInfo.genres?.map((g: any) => g.name) || [],
|
33 |
-
year: movieInfo.year
|
34 |
-
};
|
35 |
-
}
|
36 |
-
return null;
|
37 |
-
} catch (error) {
|
38 |
-
console.error(`Error fetching movie info for ${movie.title}:`, error);
|
39 |
-
return null;
|
40 |
-
}
|
41 |
-
});
|
42 |
-
|
43 |
-
let moviesData = await Promise.all(moviePromises);
|
44 |
-
moviesData = moviesData.filter(movie => movie !== null);
|
45 |
-
|
46 |
-
// Apply genre filter if present
|
47 |
-
if (genreFilter) {
|
48 |
-
moviesData = moviesData.filter(movie =>
|
49 |
-
movie.genre.some((g: string) => g.toLowerCase() === genreFilter.toLowerCase())
|
50 |
-
);
|
51 |
-
}
|
52 |
-
|
53 |
-
setMovies(moviesData);
|
54 |
-
} catch (error) {
|
55 |
-
console.error('Error fetching movies:', error);
|
56 |
-
toast({
|
57 |
-
title: "Error loading movies",
|
58 |
-
description: "Please try again later",
|
59 |
-
variant: "destructive"
|
60 |
-
});
|
61 |
-
} finally {
|
62 |
-
setLoading(false);
|
63 |
-
}
|
64 |
-
};
|
65 |
-
|
66 |
-
fetchMovies();
|
67 |
-
}, [genreFilter, toast]);
|
68 |
-
|
69 |
-
return (
|
70 |
-
<div className="pb-12">
|
71 |
-
<PageHeader
|
72 |
-
title={genreFilter ? `${genreFilter} Movies` : "All Movies"}
|
73 |
-
subtitle={`${movies.length} titles available`}
|
74 |
-
/>
|
75 |
-
|
76 |
-
{loading ? (
|
77 |
-
<div className="flex items-center justify-center h-64">
|
78 |
-
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-netflix-red"></div>
|
79 |
-
</div>
|
80 |
-
) : (
|
81 |
-
<ContentGrid
|
82 |
-
items={movies}
|
83 |
-
emptyMessage={genreFilter
|
84 |
-
? `No movies found in the ${genreFilter} genre`
|
85 |
-
: "No movies available"}
|
86 |
-
/>
|
87 |
-
)}
|
88 |
-
</div>
|
89 |
-
);
|
90 |
-
};
|
91 |
-
|
92 |
-
export default MoviesPage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/MyListPage.tsx
DELETED
@@ -1,168 +0,0 @@
|
|
1 |
-
|
2 |
-
import React, { useState, useEffect } from 'react';
|
3 |
-
import { useNavigate } from 'react-router-dom';
|
4 |
-
import PageHeader from '../components/PageHeader';
|
5 |
-
import ContentGrid, { ContentItem } from '../components/ContentGrid';
|
6 |
-
import { getAllFromMyList, removeFromMyList } from '../lib/storage';
|
7 |
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
8 |
-
import { Plus, TrashIcon } from 'lucide-react';
|
9 |
-
import { useToast } from '@/hooks/use-toast';
|
10 |
-
|
11 |
-
interface MyListItem {
|
12 |
-
type: 'movie' | 'tvshow';
|
13 |
-
title: string;
|
14 |
-
addedAt: string;
|
15 |
-
}
|
16 |
-
|
17 |
-
const MyListPage = () => {
|
18 |
-
const [myListItems, setMyListItems] = useState<MyListItem[]>([]);
|
19 |
-
const [showRemoveButtons, setShowRemoveButtons] = useState(false);
|
20 |
-
const [activeTab, setActiveTab] = useState('all');
|
21 |
-
const [isLoading, setIsLoading] = useState(true);
|
22 |
-
const navigate = useNavigate();
|
23 |
-
const { toast } = useToast();
|
24 |
-
|
25 |
-
useEffect(() => {
|
26 |
-
const fetchMyList = async () => {
|
27 |
-
try {
|
28 |
-
setIsLoading(true);
|
29 |
-
const items = await getAllFromMyList();
|
30 |
-
// Sort by most recently added
|
31 |
-
items.sort((a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime());
|
32 |
-
setMyListItems(items);
|
33 |
-
} catch (error) {
|
34 |
-
console.error("Error loading My List:", error);
|
35 |
-
} finally {
|
36 |
-
setIsLoading(false);
|
37 |
-
}
|
38 |
-
};
|
39 |
-
|
40 |
-
fetchMyList();
|
41 |
-
}, []);
|
42 |
-
|
43 |
-
const handleRemoveItem = async (title: string, type: 'movie' | 'tvshow') => {
|
44 |
-
try {
|
45 |
-
await removeFromMyList(title, type);
|
46 |
-
setMyListItems(prev => prev.filter(item => !(item.title === title && item.type === type)));
|
47 |
-
toast({
|
48 |
-
title: "Removed from My List",
|
49 |
-
description: `"${title}" has been removed from your list`,
|
50 |
-
});
|
51 |
-
} catch (error) {
|
52 |
-
console.error("Error removing item from My List:", error);
|
53 |
-
toast({
|
54 |
-
title: "Error",
|
55 |
-
description: "Failed to remove item from your list",
|
56 |
-
variant: "destructive"
|
57 |
-
});
|
58 |
-
}
|
59 |
-
};
|
60 |
-
|
61 |
-
const toggleRemoveButtons = () => {
|
62 |
-
setShowRemoveButtons(!showRemoveButtons);
|
63 |
-
};
|
64 |
-
|
65 |
-
const getFilteredItems = (filter: string): ContentItem[] => {
|
66 |
-
let filtered = myListItems;
|
67 |
-
if (filter === 'movies') {
|
68 |
-
filtered = myListItems.filter(item => item.type === 'movie');
|
69 |
-
} else if (filter === 'tvshows') {
|
70 |
-
filtered = myListItems.filter(item => item.type === 'tvshow');
|
71 |
-
}
|
72 |
-
|
73 |
-
// Convert to ContentItem format
|
74 |
-
return filtered.map(item => ({
|
75 |
-
type: item.type,
|
76 |
-
title: item.title,
|
77 |
-
image: undefined // ContentCard will fetch the image if not provided
|
78 |
-
}));
|
79 |
-
};
|
80 |
-
|
81 |
-
const allItems = getFilteredItems('all');
|
82 |
-
const movieItems = getFilteredItems('movies');
|
83 |
-
const tvShowItems = getFilteredItems('tvshows');
|
84 |
-
|
85 |
-
if (isLoading) {
|
86 |
-
return (
|
87 |
-
<div className="container mx-auto px-4 py-8 animate-pulse">
|
88 |
-
<div className="h-8 w-1/3 bg-gray-700 rounded mb-8"></div>
|
89 |
-
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
|
90 |
-
{[...Array(10)].map((_, i) => (
|
91 |
-
<div key={i} className="h-32 rounded bg-gray-800"></div>
|
92 |
-
))}
|
93 |
-
</div>
|
94 |
-
</div>
|
95 |
-
);
|
96 |
-
}
|
97 |
-
|
98 |
-
return (
|
99 |
-
<div className="container mx-auto px-4 py-8">
|
100 |
-
<div className="flex justify-between items-center mb-6">
|
101 |
-
<PageHeader
|
102 |
-
title="My List"
|
103 |
-
subtitle={`${myListItems.length} ${myListItems.length === 1 ? 'title' : 'titles'}`}
|
104 |
-
/>
|
105 |
-
|
106 |
-
<div className="flex gap-4 items-center">
|
107 |
-
{myListItems.length > 0 && (
|
108 |
-
<button
|
109 |
-
onClick={toggleRemoveButtons}
|
110 |
-
className="px-4 py-2 rounded-full bg-theme-card hover:bg-theme-card-hover text-sm flex items-center gap-2"
|
111 |
-
>
|
112 |
-
{showRemoveButtons ? 'Done' : (
|
113 |
-
<>
|
114 |
-
<TrashIcon size={16} />
|
115 |
-
<span className="hidden sm:inline">Edit List</span>
|
116 |
-
</>
|
117 |
-
)}
|
118 |
-
</button>
|
119 |
-
)}
|
120 |
-
|
121 |
-
<button
|
122 |
-
onClick={() => navigate('/browse')}
|
123 |
-
className="px-4 py-2 rounded-full bg-theme-primary hover:bg-theme-primary-hover text-white text-sm flex items-center gap-2"
|
124 |
-
>
|
125 |
-
<Plus size={16} />
|
126 |
-
<span className="hidden sm:inline">Add Titles</span>
|
127 |
-
</button>
|
128 |
-
</div>
|
129 |
-
</div>
|
130 |
-
|
131 |
-
{myListItems.length === 0 ? (
|
132 |
-
<div className="flex flex-col items-center justify-center py-16 text-center">
|
133 |
-
<div className="text-5xl mb-4">🎬</div>
|
134 |
-
<h3 className="text-xl font-bold mb-2">Your list is empty</h3>
|
135 |
-
<p className="text-gray-400 mb-6">Start adding movies and shows to create your watchlist.</p>
|
136 |
-
<button
|
137 |
-
onClick={() => navigate('/browse')}
|
138 |
-
className="px-6 py-2 rounded bg-theme-primary hover:bg-theme-primary-hover text-white text-sm font-medium"
|
139 |
-
>
|
140 |
-
Browse Content
|
141 |
-
</button>
|
142 |
-
</div>
|
143 |
-
) : (
|
144 |
-
<Tabs defaultValue={activeTab} onValueChange={setActiveTab}>
|
145 |
-
<TabsList className="mb-8">
|
146 |
-
<TabsTrigger value="all">All ({allItems.length})</TabsTrigger>
|
147 |
-
<TabsTrigger value="movies">Movies ({movieItems.length})</TabsTrigger>
|
148 |
-
<TabsTrigger value="tvshows">TV Shows ({tvShowItems.length})</TabsTrigger>
|
149 |
-
</TabsList>
|
150 |
-
|
151 |
-
<TabsContent value="all">
|
152 |
-
<ContentGrid items={allItems} emptyMessage="No items in your list" />
|
153 |
-
</TabsContent>
|
154 |
-
|
155 |
-
<TabsContent value="movies">
|
156 |
-
<ContentGrid items={movieItems} emptyMessage="No movies in your list" />
|
157 |
-
</TabsContent>
|
158 |
-
|
159 |
-
<TabsContent value="tvshows">
|
160 |
-
<ContentGrid items={tvShowItems} emptyMessage="No TV shows in your list" />
|
161 |
-
</TabsContent>
|
162 |
-
</Tabs>
|
163 |
-
)}
|
164 |
-
</div>
|
165 |
-
);
|
166 |
-
};
|
167 |
-
|
168 |
-
export default MyListPage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/NotFound.tsx
CHANGED
@@ -1,15 +1,36 @@
|
|
1 |
|
2 |
-
import
|
3 |
-
import {
|
|
|
|
|
|
|
4 |
|
5 |
const NotFound = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
return (
|
7 |
-
<div className="
|
8 |
-
<
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
</div>
|
14 |
);
|
15 |
};
|
|
|
1 |
|
2 |
+
import { useLocation } from "react-router-dom";
|
3 |
+
import { useEffect } from "react";
|
4 |
+
import { FileQuestion } from "lucide-react";
|
5 |
+
import { Button } from "@/components/ui/button";
|
6 |
+
import { Link } from "react-router-dom";
|
7 |
|
8 |
const NotFound = () => {
|
9 |
+
const location = useLocation();
|
10 |
+
|
11 |
+
useEffect(() => {
|
12 |
+
console.error(
|
13 |
+
"404 Error: User attempted to access non-existent route:",
|
14 |
+
location.pathname
|
15 |
+
);
|
16 |
+
}, [location.pathname]);
|
17 |
+
|
18 |
return (
|
19 |
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
20 |
+
<div className="text-center max-w-md px-4">
|
21 |
+
<div className="w-24 h-24 bg-muted rounded-full flex items-center justify-center mx-auto mb-6">
|
22 |
+
<FileQuestion className="h-12 w-12 text-muted-foreground" />
|
23 |
+
</div>
|
24 |
+
<h1 className="text-4xl font-bold mb-4 text-financial-navy dark:text-white">404</h1>
|
25 |
+
<p className="text-xl text-muted-foreground mb-8">
|
26 |
+
The page you are looking for could not be found.
|
27 |
+
</p>
|
28 |
+
<div className="space-x-4">
|
29 |
+
<Button asChild>
|
30 |
+
<Link to="/">Return to Home</Link>
|
31 |
+
</Button>
|
32 |
+
</div>
|
33 |
+
</div>
|
34 |
</div>
|
35 |
);
|
36 |
};
|
frontend/src/pages/ProfilePage.tsx
DELETED
@@ -1,296 +0,0 @@
|
|
1 |
-
|
2 |
-
import React, { useState, useEffect } from 'react';
|
3 |
-
import PageHeader from '../components/PageHeader';
|
4 |
-
import ContentGrid, { ContentItem } from '../components/ContentGrid';
|
5 |
-
import { Button } from '@/components/ui/button';
|
6 |
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
7 |
-
import { useToast } from '@/hooks/use-toast';
|
8 |
-
import { Trash2, DownloadCloud, Upload } from 'lucide-react';
|
9 |
-
import { getAllFromMyList } from '../lib/storage';
|
10 |
-
|
11 |
-
interface WatchHistoryItem {
|
12 |
-
type: 'movie' | 'tvshow';
|
13 |
-
title: string;
|
14 |
-
lastWatched: string;
|
15 |
-
progress: number;
|
16 |
-
completed: boolean;
|
17 |
-
}
|
18 |
-
|
19 |
-
const ProfilePage = () => {
|
20 |
-
const [watchHistory, setWatchHistory] = useState<WatchHistoryItem[]>([]);
|
21 |
-
const [myListItems, setMyListItems] = useState<ContentItem[]>([]);
|
22 |
-
const [activeTab, setActiveTab] = useState('history');
|
23 |
-
const { toast } = useToast();
|
24 |
-
|
25 |
-
// Load watch history from localStorage
|
26 |
-
useEffect(() => {
|
27 |
-
const loadWatchHistory = () => {
|
28 |
-
try {
|
29 |
-
const history: WatchHistoryItem[] = [];
|
30 |
-
|
31 |
-
// Scan localStorage for movie progress
|
32 |
-
for (let i = 0; i < localStorage.length; i++) {
|
33 |
-
const key = localStorage.key(i);
|
34 |
-
if (key?.startsWith('movie-progress-')) {
|
35 |
-
const title = key.replace('movie-progress-', '');
|
36 |
-
const data = JSON.parse(localStorage.getItem(key) || '{}');
|
37 |
-
|
38 |
-
if (data && data.lastPlayed) {
|
39 |
-
history.push({
|
40 |
-
type: 'movie',
|
41 |
-
title,
|
42 |
-
lastWatched: data.lastPlayed,
|
43 |
-
progress: Math.round((data.currentTime / data.duration) * 100) || 0,
|
44 |
-
completed: data.completed || false
|
45 |
-
});
|
46 |
-
}
|
47 |
-
}
|
48 |
-
|
49 |
-
// Scan for TV show progress
|
50 |
-
if (key?.startsWith('playback-')) {
|
51 |
-
const showTitle = key.replace('playback-', '');
|
52 |
-
const showData = JSON.parse(localStorage.getItem(key) || '{}');
|
53 |
-
|
54 |
-
let lastEpisodeDate = '';
|
55 |
-
let lastEpisodeProgress = 0;
|
56 |
-
let anyEpisodeCompleted = false;
|
57 |
-
|
58 |
-
// Find the most recently watched episode
|
59 |
-
Object.entries(showData).forEach(([_, value]) => {
|
60 |
-
const episodeData = value as {
|
61 |
-
lastPlayed: string;
|
62 |
-
currentTime: number;
|
63 |
-
duration: number;
|
64 |
-
completed: boolean;
|
65 |
-
};
|
66 |
-
|
67 |
-
if (!lastEpisodeDate || new Date(episodeData.lastPlayed) > new Date(lastEpisodeDate)) {
|
68 |
-
lastEpisodeDate = episodeData.lastPlayed;
|
69 |
-
lastEpisodeProgress = Math.round((episodeData.currentTime / episodeData.duration) * 100) || 0;
|
70 |
-
if (episodeData.completed) anyEpisodeCompleted = true;
|
71 |
-
}
|
72 |
-
});
|
73 |
-
|
74 |
-
if (lastEpisodeDate) {
|
75 |
-
history.push({
|
76 |
-
type: 'tvshow',
|
77 |
-
title: showTitle,
|
78 |
-
lastWatched: lastEpisodeDate,
|
79 |
-
progress: lastEpisodeProgress,
|
80 |
-
completed: anyEpisodeCompleted
|
81 |
-
});
|
82 |
-
}
|
83 |
-
}
|
84 |
-
}
|
85 |
-
|
86 |
-
// Sort by most recently watched
|
87 |
-
history.sort((a, b) =>
|
88 |
-
new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime()
|
89 |
-
);
|
90 |
-
|
91 |
-
setWatchHistory(history);
|
92 |
-
} catch (error) {
|
93 |
-
console.error('Error loading watch history:', error);
|
94 |
-
}
|
95 |
-
};
|
96 |
-
|
97 |
-
loadWatchHistory();
|
98 |
-
}, []);
|
99 |
-
|
100 |
-
// Load My List items
|
101 |
-
useEffect(() => {
|
102 |
-
const loadMyList = async () => {
|
103 |
-
try {
|
104 |
-
const items = await getAllFromMyList();
|
105 |
-
const contentItems: ContentItem[] = items.map(item => ({
|
106 |
-
type: item.type,
|
107 |
-
title: item.title,
|
108 |
-
image: undefined // ContentCard component will fetch the image
|
109 |
-
}));
|
110 |
-
setMyListItems(contentItems);
|
111 |
-
} catch (error) {
|
112 |
-
console.error('Error loading my list:', error);
|
113 |
-
}
|
114 |
-
};
|
115 |
-
|
116 |
-
loadMyList();
|
117 |
-
}, []);
|
118 |
-
|
119 |
-
const clearWatchHistory = () => {
|
120 |
-
// Filter localStorage keys related to watch history
|
121 |
-
const keysToRemove: string[] = [];
|
122 |
-
|
123 |
-
for (let i = 0; i < localStorage.length; i++) {
|
124 |
-
const key = localStorage.key(i);
|
125 |
-
if (key && (key.startsWith('movie-progress-') || key.startsWith('playback-'))) {
|
126 |
-
keysToRemove.push(key);
|
127 |
-
}
|
128 |
-
}
|
129 |
-
|
130 |
-
// Remove the keys
|
131 |
-
keysToRemove.forEach(key => localStorage.removeItem(key));
|
132 |
-
|
133 |
-
// Update state
|
134 |
-
setWatchHistory([]);
|
135 |
-
|
136 |
-
toast({
|
137 |
-
title: "Watch History Cleared",
|
138 |
-
description: "Your watch history has been successfully cleared.",
|
139 |
-
});
|
140 |
-
};
|
141 |
-
|
142 |
-
const exportUserData = () => {
|
143 |
-
try {
|
144 |
-
const userData = {
|
145 |
-
watchHistory: {},
|
146 |
-
myList: {}
|
147 |
-
};
|
148 |
-
|
149 |
-
// Export all localStorage data
|
150 |
-
for (let i = 0; i < localStorage.length; i++) {
|
151 |
-
const key = localStorage.key(i);
|
152 |
-
if (!key) continue;
|
153 |
-
|
154 |
-
if (key.startsWith('movie-progress-') || key.startsWith('playback-')) {
|
155 |
-
userData.watchHistory[key] = JSON.parse(localStorage.getItem(key) || '{}');
|
156 |
-
}
|
157 |
-
|
158 |
-
if (key === 'myList') {
|
159 |
-
userData.myList = JSON.parse(localStorage.getItem(key) || '[]');
|
160 |
-
}
|
161 |
-
}
|
162 |
-
|
163 |
-
// Create downloadable JSON
|
164 |
-
const dataStr = JSON.stringify(userData, null, 2);
|
165 |
-
const blob = new Blob([dataStr], { type: 'application/json' });
|
166 |
-
const url = URL.createObjectURL(blob);
|
167 |
-
|
168 |
-
// Create temporary link and trigger download
|
169 |
-
const a = document.createElement('a');
|
170 |
-
a.href = url;
|
171 |
-
a.download = `streamflix-user-data-${new Date().toISOString().slice(0, 10)}.json`;
|
172 |
-
document.body.appendChild(a);
|
173 |
-
a.click();
|
174 |
-
document.body.removeChild(a);
|
175 |
-
URL.revokeObjectURL(url);
|
176 |
-
|
177 |
-
toast({
|
178 |
-
title: "Export Successful",
|
179 |
-
description: "Your data has been exported successfully.",
|
180 |
-
});
|
181 |
-
} catch (error) {
|
182 |
-
console.error('Error exporting user data:', error);
|
183 |
-
toast({
|
184 |
-
title: "Export Failed",
|
185 |
-
description: "There was an error exporting your data.",
|
186 |
-
variant: "destructive"
|
187 |
-
});
|
188 |
-
}
|
189 |
-
};
|
190 |
-
|
191 |
-
const renderWatchHistoryItems = (): ContentItem[] => {
|
192 |
-
return watchHistory.map(item => ({
|
193 |
-
type: item.type,
|
194 |
-
title: item.title,
|
195 |
-
image: undefined // ContentCard will fetch the image
|
196 |
-
}));
|
197 |
-
};
|
198 |
-
|
199 |
-
return (
|
200 |
-
<div className="container mx-auto px-4 py-8">
|
201 |
-
<PageHeader title="Your Profile" subtitle="Manage your preferences and data" />
|
202 |
-
|
203 |
-
<div className="mt-8">
|
204 |
-
<Tabs defaultValue={activeTab} onValueChange={setActiveTab}>
|
205 |
-
<TabsList>
|
206 |
-
<TabsTrigger value="history">Watch History</TabsTrigger>
|
207 |
-
<TabsTrigger value="mylist">My List</TabsTrigger>
|
208 |
-
<TabsTrigger value="settings">Settings</TabsTrigger>
|
209 |
-
</TabsList>
|
210 |
-
|
211 |
-
<TabsContent value="history" className="pt-6">
|
212 |
-
<div className="flex justify-between items-center mb-6">
|
213 |
-
<h2 className="text-xl font-bold">Watch History</h2>
|
214 |
-
{watchHistory.length > 0 && (
|
215 |
-
<Button
|
216 |
-
variant="destructive"
|
217 |
-
onClick={clearWatchHistory}
|
218 |
-
className="flex items-center gap-2"
|
219 |
-
>
|
220 |
-
<Trash2 size={16} />
|
221 |
-
<span>Clear History</span>
|
222 |
-
</Button>
|
223 |
-
)}
|
224 |
-
</div>
|
225 |
-
|
226 |
-
{watchHistory.length === 0 ? (
|
227 |
-
<div className="text-center py-12">
|
228 |
-
<p className="text-gray-400">You have no watch history yet.</p>
|
229 |
-
<p className="text-sm text-gray-500 mt-2">Start watching movies and shows to build your history.</p>
|
230 |
-
</div>
|
231 |
-
) : (
|
232 |
-
<ContentGrid items={renderWatchHistoryItems()} />
|
233 |
-
)}
|
234 |
-
</TabsContent>
|
235 |
-
|
236 |
-
<TabsContent value="mylist" className="pt-6">
|
237 |
-
<div className="flex justify-between items-center mb-6">
|
238 |
-
<h2 className="text-xl font-bold">My List</h2>
|
239 |
-
</div>
|
240 |
-
|
241 |
-
{myListItems.length === 0 ? (
|
242 |
-
<div className="text-center py-12">
|
243 |
-
<p className="text-gray-400">You haven't added anything to your list yet.</p>
|
244 |
-
<p className="text-sm text-gray-500 mt-2">Browse content and click the "+" icon to add titles to your list.</p>
|
245 |
-
</div>
|
246 |
-
) : (
|
247 |
-
<ContentGrid items={myListItems} />
|
248 |
-
)}
|
249 |
-
</TabsContent>
|
250 |
-
|
251 |
-
<TabsContent value="settings" className="pt-6">
|
252 |
-
<div className="space-y-6">
|
253 |
-
<div>
|
254 |
-
<h2 className="text-xl font-bold mb-4">Data Management</h2>
|
255 |
-
|
256 |
-
<div className="grid gap-4 md:grid-cols-2">
|
257 |
-
<div className="bg-card rounded-lg p-4 border">
|
258 |
-
<h3 className="text-lg font-medium mb-2">Export Your Data</h3>
|
259 |
-
<p className="text-sm text-gray-400 mb-4">Download your watch history and list data as a JSON file.</p>
|
260 |
-
<Button
|
261 |
-
onClick={exportUserData}
|
262 |
-
className="flex items-center gap-2"
|
263 |
-
>
|
264 |
-
<DownloadCloud size={16} />
|
265 |
-
<span>Export Data</span>
|
266 |
-
</Button>
|
267 |
-
</div>
|
268 |
-
|
269 |
-
<div className="bg-card rounded-lg p-4 border">
|
270 |
-
<h3 className="text-lg font-medium mb-2">Import Your Data</h3>
|
271 |
-
<p className="text-sm text-gray-400 mb-4">Restore previously exported data (coming soon)</p>
|
272 |
-
<Button
|
273 |
-
disabled
|
274 |
-
variant="outline"
|
275 |
-
className="flex items-center gap-2 opacity-50"
|
276 |
-
>
|
277 |
-
<Upload size={16} />
|
278 |
-
<span>Import Data</span>
|
279 |
-
</Button>
|
280 |
-
</div>
|
281 |
-
</div>
|
282 |
-
</div>
|
283 |
-
|
284 |
-
<div>
|
285 |
-
<h2 className="text-xl font-bold mb-4">Account Settings</h2>
|
286 |
-
<p className="text-gray-400">Account management features coming soon.</p>
|
287 |
-
</div>
|
288 |
-
</div>
|
289 |
-
</TabsContent>
|
290 |
-
</Tabs>
|
291 |
-
</div>
|
292 |
-
</div>
|
293 |
-
);
|
294 |
-
};
|
295 |
-
|
296 |
-
export default ProfilePage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/SearchPage.tsx
DELETED
@@ -1,199 +0,0 @@
|
|
1 |
-
|
2 |
-
import React, { useState, useEffect } from 'react';
|
3 |
-
import { useSearchParams } from 'react-router-dom';
|
4 |
-
import { useToast } from '@/hooks/use-toast';
|
5 |
-
import { searchAPI } from '../lib/search-api';
|
6 |
-
import PageHeader from '../components/PageHeader';
|
7 |
-
import ContentGrid from '../components/ContentGrid';
|
8 |
-
import ContentCard from '../components/ContentCard';
|
9 |
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
10 |
-
import { Loader2 } from 'lucide-react';
|
11 |
-
|
12 |
-
const SearchPage = () => {
|
13 |
-
const [searchParams] = useSearchParams();
|
14 |
-
const query = searchParams.get('q') || '';
|
15 |
-
const [searchResults, setSearchResults] = useState<{
|
16 |
-
movies: string[];
|
17 |
-
shows: string[];
|
18 |
-
episodes: {
|
19 |
-
series: string;
|
20 |
-
title: string;
|
21 |
-
path: string;
|
22 |
-
season: string;
|
23 |
-
}[];
|
24 |
-
}>({
|
25 |
-
movies: [],
|
26 |
-
shows: [],
|
27 |
-
episodes: []
|
28 |
-
});
|
29 |
-
const [loading, setLoading] = useState(false);
|
30 |
-
const [activeTab, setActiveTab] = useState('all');
|
31 |
-
const { toast } = useToast();
|
32 |
-
|
33 |
-
useEffect(() => {
|
34 |
-
const fetchSearchResults = async () => {
|
35 |
-
if (!query) return;
|
36 |
-
|
37 |
-
try {
|
38 |
-
setLoading(true);
|
39 |
-
const results = await searchAPI.search(query);
|
40 |
-
setSearchResults({
|
41 |
-
movies: results.films || [],
|
42 |
-
shows: results.series || [],
|
43 |
-
episodes: results.episodes || []
|
44 |
-
});
|
45 |
-
} catch (error) {
|
46 |
-
console.error('Search error:', error);
|
47 |
-
toast({
|
48 |
-
title: "Search Failed",
|
49 |
-
description: "Unable to perform search. Please try again.",
|
50 |
-
variant: "destructive"
|
51 |
-
});
|
52 |
-
} finally {
|
53 |
-
setLoading(false);
|
54 |
-
}
|
55 |
-
};
|
56 |
-
|
57 |
-
fetchSearchResults();
|
58 |
-
}, [query, toast]);
|
59 |
-
|
60 |
-
const getTotalResultsCount = () => {
|
61 |
-
return searchResults.movies.length + searchResults.shows.length + searchResults.episodes.length;
|
62 |
-
};
|
63 |
-
|
64 |
-
const renderMovieResults = () => {
|
65 |
-
if (searchResults.movies.length === 0) {
|
66 |
-
return <p className="text-center text-gray-400 my-8">No movies found matching "{query}"</p>;
|
67 |
-
}
|
68 |
-
|
69 |
-
return (
|
70 |
-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
71 |
-
{searchResults.movies.map((title, index) => (
|
72 |
-
<ContentCard
|
73 |
-
key={`movie-${title}-${index}`}
|
74 |
-
type="movie"
|
75 |
-
title={title.split('/')[1]}
|
76 |
-
prefetchData={true}
|
77 |
-
/>
|
78 |
-
))}
|
79 |
-
</div>
|
80 |
-
);
|
81 |
-
};
|
82 |
-
|
83 |
-
const renderShowResults = () => {
|
84 |
-
if (searchResults.shows.length === 0) {
|
85 |
-
return <p className="text-center text-gray-400 my-8">No TV shows found matching "{query}"</p>;
|
86 |
-
}
|
87 |
-
|
88 |
-
return (
|
89 |
-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
90 |
-
{searchResults.shows.map((title, index) => (
|
91 |
-
<ContentCard
|
92 |
-
key={`show-${title}-${index}`}
|
93 |
-
type="tvshow"
|
94 |
-
title={title}
|
95 |
-
prefetchData={true}
|
96 |
-
/>
|
97 |
-
))}
|
98 |
-
</div>
|
99 |
-
);
|
100 |
-
};
|
101 |
-
|
102 |
-
const renderEpisodeResults = () => {
|
103 |
-
if (searchResults.episodes.length === 0) {
|
104 |
-
return <p className="text-center text-gray-400 my-8">No episodes found matching "{query}"</p>;
|
105 |
-
}
|
106 |
-
|
107 |
-
return (
|
108 |
-
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
109 |
-
{searchResults.episodes.map((episode, index) => (
|
110 |
-
<div key={`episode-${index}`} className="bg-theme-card p-4 rounded-md hover:bg-theme-card-hover transition-colors">
|
111 |
-
<h3 className="font-semibold">{episode.title}</h3>
|
112 |
-
<p className="text-sm text-gray-400">
|
113 |
-
{episode.series} • Season {episode.season}
|
114 |
-
</p>
|
115 |
-
</div>
|
116 |
-
))}
|
117 |
-
</div>
|
118 |
-
);
|
119 |
-
};
|
120 |
-
|
121 |
-
return (
|
122 |
-
<div className="container mx-auto px-4 py-8">
|
123 |
-
<PageHeader
|
124 |
-
title={query ? `Search Results for "${query}"` : "Search"}
|
125 |
-
subtitle={query ? `${getTotalResultsCount()} results found` : "Enter a search term in the search box"}
|
126 |
-
/>
|
127 |
-
|
128 |
-
{loading ? (
|
129 |
-
<div className="flex justify-center items-center py-12">
|
130 |
-
<Loader2 className="w-12 h-12 animate-spin text-theme-primary/70" />
|
131 |
-
</div>
|
132 |
-
) : query ? (
|
133 |
-
<Tabs defaultValue={activeTab} onValueChange={setActiveTab} className="w-full mt-6">
|
134 |
-
<TabsList className="mb-6">
|
135 |
-
<TabsTrigger value="all">
|
136 |
-
All Results ({getTotalResultsCount()})
|
137 |
-
</TabsTrigger>
|
138 |
-
<TabsTrigger value="movies">
|
139 |
-
Movies ({searchResults.movies.length})
|
140 |
-
</TabsTrigger>
|
141 |
-
<TabsTrigger value="shows">
|
142 |
-
TV Shows ({searchResults.shows.length})
|
143 |
-
</TabsTrigger>
|
144 |
-
<TabsTrigger value="episodes">
|
145 |
-
Episodes ({searchResults.episodes.length})
|
146 |
-
</TabsTrigger>
|
147 |
-
</TabsList>
|
148 |
-
|
149 |
-
<TabsContent value="all">
|
150 |
-
{getTotalResultsCount() === 0 ? (
|
151 |
-
<p className="text-center text-gray-400 my-8">No results found matching "{query}"</p>
|
152 |
-
) : (
|
153 |
-
<>
|
154 |
-
{searchResults.movies.length > 0 && (
|
155 |
-
<div className="mb-8">
|
156 |
-
<h2 className="text-xl font-semibold mb-4">Movies</h2>
|
157 |
-
{renderMovieResults()}
|
158 |
-
</div>
|
159 |
-
)}
|
160 |
-
|
161 |
-
{searchResults.shows.length > 0 && (
|
162 |
-
<div className="mb-8">
|
163 |
-
<h2 className="text-xl font-semibold mb-4">TV Shows</h2>
|
164 |
-
{renderShowResults()}
|
165 |
-
</div>
|
166 |
-
)}
|
167 |
-
|
168 |
-
{searchResults.episodes.length > 0 && (
|
169 |
-
<div>
|
170 |
-
<h2 className="text-xl font-semibold mb-4">Episodes</h2>
|
171 |
-
{renderEpisodeResults()}
|
172 |
-
</div>
|
173 |
-
)}
|
174 |
-
</>
|
175 |
-
)}
|
176 |
-
</TabsContent>
|
177 |
-
|
178 |
-
<TabsContent value="movies">
|
179 |
-
{renderMovieResults()}
|
180 |
-
</TabsContent>
|
181 |
-
|
182 |
-
<TabsContent value="shows">
|
183 |
-
{renderShowResults()}
|
184 |
-
</TabsContent>
|
185 |
-
|
186 |
-
<TabsContent value="episodes">
|
187 |
-
{renderEpisodeResults()}
|
188 |
-
</TabsContent>
|
189 |
-
</Tabs>
|
190 |
-
) : (
|
191 |
-
<div className="text-center py-12">
|
192 |
-
<p className="text-gray-400">Enter a search term to find movies, TV shows, and episodes.</p>
|
193 |
-
</div>
|
194 |
-
)}
|
195 |
-
</div>
|
196 |
-
);
|
197 |
-
};
|
198 |
-
|
199 |
-
export default SearchPage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/SettingsPage.tsx
ADDED
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useRef } from "react";
|
2 |
+
import { Settings, Moon, Sun, Globe, Shield, Database, Cloud } from "lucide-react";
|
3 |
+
import { Button } from "@/components/ui/button";
|
4 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
5 |
+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
6 |
+
import { Label } from "@/components/ui/label";
|
7 |
+
import { Separator } from "@/components/ui/separator";
|
8 |
+
import { Input } from "@/components/ui/input";
|
9 |
+
import { toast } from "@/components/ui/sonner";
|
10 |
+
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
11 |
+
|
12 |
+
const SettingsPage = () => {
|
13 |
+
const [theme, setTheme] = useState(() => {
|
14 |
+
return storage.get<string>(STORAGE_KEYS.THEME) || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
15 |
+
});
|
16 |
+
|
17 |
+
const [apiEndpoint, setApiEndpoint] = useState(
|
18 |
+
storage.get<string>(STORAGE_KEYS.API_ENDPOINT) || "http://localhost:8000"
|
19 |
+
);
|
20 |
+
|
21 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
22 |
+
|
23 |
+
const handleThemeChange = (newTheme: string) => {
|
24 |
+
setTheme(newTheme);
|
25 |
+
|
26 |
+
const root = window.document.documentElement;
|
27 |
+
|
28 |
+
if (newTheme === "dark") {
|
29 |
+
root.classList.add("dark");
|
30 |
+
} else if (newTheme === "light") {
|
31 |
+
root.classList.remove("dark");
|
32 |
+
} else {
|
33 |
+
// System theme
|
34 |
+
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
35 |
+
root.classList.add("dark");
|
36 |
+
} else {
|
37 |
+
root.classList.remove("dark");
|
38 |
+
}
|
39 |
+
}
|
40 |
+
|
41 |
+
storage.set(STORAGE_KEYS.THEME, newTheme);
|
42 |
+
toast.success("Theme updated successfully");
|
43 |
+
};
|
44 |
+
|
45 |
+
const handleSaveEndpoint = () => {
|
46 |
+
storage.set(STORAGE_KEYS.API_ENDPOINT, apiEndpoint);
|
47 |
+
toast.success("API endpoint saved successfully");
|
48 |
+
};
|
49 |
+
|
50 |
+
const handleClearChats = () => {
|
51 |
+
storage.set(STORAGE_KEYS.CHATS, []);
|
52 |
+
toast.success("Chat history cleared successfully");
|
53 |
+
};
|
54 |
+
|
55 |
+
const handleClearSources = () => {
|
56 |
+
storage.set(STORAGE_KEYS.SOURCES, []);
|
57 |
+
toast.success("Sources cleared successfully");
|
58 |
+
};
|
59 |
+
|
60 |
+
const handleResetSettings = () => {
|
61 |
+
storage.remove(STORAGE_KEYS.THEME);
|
62 |
+
storage.remove(STORAGE_KEYS.API_ENDPOINT);
|
63 |
+
|
64 |
+
setApiEndpoint("http://localhost:8000");
|
65 |
+
setTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
66 |
+
|
67 |
+
toast.success("Settings reset to defaults");
|
68 |
+
};
|
69 |
+
|
70 |
+
const handleExportStorage = () => {
|
71 |
+
const data = storage.export();
|
72 |
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
73 |
+
const url = URL.createObjectURL(blob);
|
74 |
+
|
75 |
+
const a = document.createElement("a");
|
76 |
+
a.href = url;
|
77 |
+
a.download = "insight-storage-export.json";
|
78 |
+
a.click();
|
79 |
+
URL.revokeObjectURL(url);
|
80 |
+
|
81 |
+
toast.success("Storage exported successfully");
|
82 |
+
};
|
83 |
+
|
84 |
+
const handleImportStorage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
85 |
+
const file = event.target.files?.[0];
|
86 |
+
if (!file) return;
|
87 |
+
const reader = new FileReader();
|
88 |
+
reader.onload = (e) => {
|
89 |
+
try {
|
90 |
+
const result = e.target?.result as string;
|
91 |
+
const data = JSON.parse(result);
|
92 |
+
if (storage.import(data)) {
|
93 |
+
toast.success("Storage imported successfully. Please refresh the page.");
|
94 |
+
} else {
|
95 |
+
toast.error("Failed to import storage.");
|
96 |
+
}
|
97 |
+
} catch {
|
98 |
+
toast.error("Invalid file format.");
|
99 |
+
}
|
100 |
+
};
|
101 |
+
reader.readAsText(file);
|
102 |
+
event.target.value = "";
|
103 |
+
};
|
104 |
+
|
105 |
+
return (
|
106 |
+
<div className="container mx-auto px-4 py-8">
|
107 |
+
<div className="max-w-4xl mx-auto">
|
108 |
+
<h1 className="text-2xl font-bold mb-6 text-gradient flex items-center">
|
109 |
+
<Settings className="mr-2 h-5 w-5" />
|
110 |
+
System Settings
|
111 |
+
</h1>
|
112 |
+
|
113 |
+
<div className="grid gap-6">
|
114 |
+
<Card className="glass-effect">
|
115 |
+
<CardHeader className="border-b border-border/40 pb-4">
|
116 |
+
<CardTitle className="flex items-center">
|
117 |
+
<Moon className="mr-2 h-4 w-4" />
|
118 |
+
Appearance
|
119 |
+
</CardTitle>
|
120 |
+
<CardDescription>
|
121 |
+
Customize how Insight AI looks
|
122 |
+
</CardDescription>
|
123 |
+
</CardHeader>
|
124 |
+
<CardContent className="pt-4">
|
125 |
+
<RadioGroup
|
126 |
+
value={theme}
|
127 |
+
onValueChange={handleThemeChange}
|
128 |
+
className="space-y-4"
|
129 |
+
>
|
130 |
+
<div className="flex items-center space-x-2">
|
131 |
+
<RadioGroupItem value="light" id="light" />
|
132 |
+
<Label htmlFor="light" className="flex items-center cursor-pointer">
|
133 |
+
<Sun className="h-4 w-4 mr-2" />
|
134 |
+
Light
|
135 |
+
</Label>
|
136 |
+
</div>
|
137 |
+
<div className="flex items-center space-x-2">
|
138 |
+
<RadioGroupItem value="dark" id="dark" />
|
139 |
+
<Label htmlFor="dark" className="flex items-center cursor-pointer">
|
140 |
+
<Moon className="h-4 w-4 mr-2" />
|
141 |
+
Dark
|
142 |
+
</Label>
|
143 |
+
</div>
|
144 |
+
<div className="flex items-center space-x-2">
|
145 |
+
<RadioGroupItem value="system" id="system" />
|
146 |
+
<Label htmlFor="system" className="flex items-center cursor-pointer">
|
147 |
+
<Globe className="h-4 w-4 mr-2" />
|
148 |
+
System
|
149 |
+
</Label>
|
150 |
+
</div>
|
151 |
+
</RadioGroup>
|
152 |
+
</CardContent>
|
153 |
+
</Card>
|
154 |
+
|
155 |
+
<Card className="glass-effect">
|
156 |
+
<CardHeader className="border-b border-border/40 pb-4">
|
157 |
+
<CardTitle className="flex items-center">
|
158 |
+
<Database className="mr-2 h-4 w-4" />
|
159 |
+
API Configuration
|
160 |
+
</CardTitle>
|
161 |
+
<CardDescription>
|
162 |
+
Configure connection to the financial rulings database
|
163 |
+
</CardDescription>
|
164 |
+
</CardHeader>
|
165 |
+
<CardContent className="space-y-4 pt-4">
|
166 |
+
<div className="space-y-2">
|
167 |
+
<Label htmlFor="api-endpoint">API Endpoint</Label>
|
168 |
+
<div className="flex space-x-2">
|
169 |
+
<Input
|
170 |
+
id="api-endpoint"
|
171 |
+
value={apiEndpoint}
|
172 |
+
onChange={(e) => setApiEndpoint(e.target.value)}
|
173 |
+
placeholder="http://localhost:8000"
|
174 |
+
className="glass-input"
|
175 |
+
/>
|
176 |
+
<Button onClick={handleSaveEndpoint} variant="outline" className="tech-button">
|
177 |
+
<Cloud className="h-4 w-4 mr-2" />
|
178 |
+
Save
|
179 |
+
</Button>
|
180 |
+
</div>
|
181 |
+
</div>
|
182 |
+
</CardContent>
|
183 |
+
</Card>
|
184 |
+
|
185 |
+
<Card className="glass-effect">
|
186 |
+
<CardHeader className="border-b border-border/40 pb-4">
|
187 |
+
<CardTitle className="flex items-center">
|
188 |
+
<Shield className="mr-2 h-4 w-4" />
|
189 |
+
Privacy & Data
|
190 |
+
</CardTitle>
|
191 |
+
<CardDescription>
|
192 |
+
Manage your data and privacy settings
|
193 |
+
</CardDescription>
|
194 |
+
</CardHeader>
|
195 |
+
<CardContent className="pt-4 space-y-4">
|
196 |
+
<div className="space-y-2">
|
197 |
+
<h3 className="text-sm font-medium">Data Management</h3>
|
198 |
+
<div className="flex flex-wrap gap-3">
|
199 |
+
<Button
|
200 |
+
variant="outline"
|
201 |
+
onClick={handleClearChats}
|
202 |
+
className="tech-button"
|
203 |
+
>
|
204 |
+
Clear Chat History
|
205 |
+
</Button>
|
206 |
+
|
207 |
+
<Button
|
208 |
+
variant="outline"
|
209 |
+
onClick={handleClearSources}
|
210 |
+
className="tech-button"
|
211 |
+
>
|
212 |
+
Clear Source References
|
213 |
+
</Button>
|
214 |
+
|
215 |
+
<Button
|
216 |
+
variant="destructive"
|
217 |
+
onClick={handleResetSettings}
|
218 |
+
className="tech-button"
|
219 |
+
>
|
220 |
+
Reset All Settings
|
221 |
+
</Button>
|
222 |
+
</div>
|
223 |
+
</div>
|
224 |
+
</CardContent>
|
225 |
+
</Card>
|
226 |
+
|
227 |
+
<Card className="glass-effect">
|
228 |
+
<CardHeader className="border-b border-border/40 pb-4">
|
229 |
+
<CardTitle className="flex items-center">
|
230 |
+
<Cloud className="mr-2 h-4 w-4" />
|
231 |
+
Storage Export / Import
|
232 |
+
</CardTitle>
|
233 |
+
<CardDescription>
|
234 |
+
Backup or restore your application data
|
235 |
+
</CardDescription>
|
236 |
+
</CardHeader>
|
237 |
+
<CardContent className="pt-4 space-y-4">
|
238 |
+
<div className="flex flex-wrap gap-3">
|
239 |
+
<Button
|
240 |
+
variant="outline"
|
241 |
+
onClick={handleExportStorage}
|
242 |
+
className="tech-button"
|
243 |
+
>
|
244 |
+
Export Storage
|
245 |
+
</Button>
|
246 |
+
<Button
|
247 |
+
variant="outline"
|
248 |
+
onClick={() => fileInputRef.current?.click()}
|
249 |
+
className="tech-button"
|
250 |
+
>
|
251 |
+
Import Storage
|
252 |
+
</Button>
|
253 |
+
<input
|
254 |
+
ref={fileInputRef}
|
255 |
+
type="file"
|
256 |
+
accept="application/json"
|
257 |
+
style={{ display: "none" }}
|
258 |
+
onChange={handleImportStorage}
|
259 |
+
/>
|
260 |
+
</div>
|
261 |
+
<div className="text-xs text-muted-foreground">
|
262 |
+
Export will download your data as a JSON file. Import will overwrite your current data with the file contents.
|
263 |
+
</div>
|
264 |
+
</CardContent>
|
265 |
+
</Card>
|
266 |
+
</div>
|
267 |
+
</div>
|
268 |
+
</div>
|
269 |
+
);
|
270 |
+
};
|
271 |
+
|
272 |
+
export default SettingsPage;
|
frontend/src/pages/SourcesPage.tsx
ADDED
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
import { useState, useEffect } from "react";
|
3 |
+
import { FileText, Search, Filter, Calendar, ArrowUpDown, ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
|
4 |
+
import { Button } from "@/components/ui/button";
|
5 |
+
import { Input } from "@/components/ui/input";
|
6 |
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
7 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
8 |
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
9 |
+
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
10 |
+
import { cn } from "@/lib/utils";
|
11 |
+
import { Separator } from "@/components/ui/separator";
|
12 |
+
import { format } from "date-fns";
|
13 |
+
import { DateRange } from "@/types";
|
14 |
+
import { RetrievedSource } from "@/services/rulingService";
|
15 |
+
import { storage, STORAGE_KEYS } from "@/lib/storage";
|
16 |
+
|
17 |
+
const SourcesPage = () => {
|
18 |
+
const [searchTerm, setSearchTerm] = useState("");
|
19 |
+
const [selectedSource, setSelectedSource] = useState<string>("");
|
20 |
+
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
21 |
+
const [date, setDate] = useState<DateRange>({
|
22 |
+
from: undefined,
|
23 |
+
to: undefined,
|
24 |
+
});
|
25 |
+
const [sortBy, setSortBy] = useState<string>("date-desc");
|
26 |
+
const [sources, setSources] = useState<RetrievedSource[]>([]);
|
27 |
+
const [sourceTypes, setSourceTypes] = useState<string[]>([]);
|
28 |
+
const [categories, setCategories] = useState<string[]>([]);
|
29 |
+
|
30 |
+
// Load sources from storage
|
31 |
+
useEffect(() => {
|
32 |
+
const storedSources = storage.get<RetrievedSource[]>(STORAGE_KEYS.SOURCES) || [];
|
33 |
+
setSources(storedSources);
|
34 |
+
|
35 |
+
// Extract unique source types and categories
|
36 |
+
const types = Array.from(new Set(storedSources.map(s => s.metadata?.source).filter(Boolean)));
|
37 |
+
setSourceTypes(types as string[]);
|
38 |
+
|
39 |
+
const cats = Array.from(new Set(storedSources.map(s => {
|
40 |
+
// For this example, we'll use the first word of the content as a mock category
|
41 |
+
// In a real app, you'd use a proper category field from metadata
|
42 |
+
const firstWord = s.content_snippet.split(' ')[0];
|
43 |
+
return firstWord.length > 3 ? firstWord : "General";
|
44 |
+
})));
|
45 |
+
setCategories(cats);
|
46 |
+
}, []);
|
47 |
+
|
48 |
+
// Filter sources based on search term, source, category, and date
|
49 |
+
const filteredSources = sources.filter(source => {
|
50 |
+
const matchesSearch = !searchTerm ||
|
51 |
+
source.content_snippet.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
52 |
+
(source.metadata?.source || "").toLowerCase().includes(searchTerm.toLowerCase());
|
53 |
+
|
54 |
+
const matchesSource = !selectedSource || source.metadata?.source === selectedSource;
|
55 |
+
|
56 |
+
// Mock category matching based on first word of content
|
57 |
+
const sourceCategory = source.content_snippet.split(' ')[0].length > 3 ?
|
58 |
+
source.content_snippet.split(' ')[0] : "General";
|
59 |
+
const matchesCategory = !selectedCategory || sourceCategory === selectedCategory;
|
60 |
+
|
61 |
+
let matchesDate = true;
|
62 |
+
if (date.from && source.metadata?.ruling_date) {
|
63 |
+
matchesDate = matchesDate && new Date(source.metadata.ruling_date) >= date.from;
|
64 |
+
}
|
65 |
+
if (date.to && source.metadata?.ruling_date) {
|
66 |
+
matchesDate = matchesDate && new Date(source.metadata.ruling_date) <= date.to;
|
67 |
+
}
|
68 |
+
|
69 |
+
return matchesSearch && matchesSource && matchesCategory && matchesDate;
|
70 |
+
});
|
71 |
+
|
72 |
+
// Sort sources
|
73 |
+
const sortedSources = [...filteredSources].sort((a, b) => {
|
74 |
+
if (sortBy === "date-desc") {
|
75 |
+
return new Date(b.metadata?.ruling_date || "").getTime() -
|
76 |
+
new Date(a.metadata?.ruling_date || "").getTime();
|
77 |
+
} else if (sortBy === "date-asc") {
|
78 |
+
return new Date(a.metadata?.ruling_date || "").getTime() -
|
79 |
+
new Date(b.metadata?.ruling_date || "").getTime();
|
80 |
+
} else if (sortBy === "relevance-desc") {
|
81 |
+
return b.content_snippet.length - a.content_snippet.length;
|
82 |
+
}
|
83 |
+
return 0;
|
84 |
+
});
|
85 |
+
|
86 |
+
const resetFilters = () => {
|
87 |
+
setSearchTerm("");
|
88 |
+
setSelectedSource("");
|
89 |
+
setSelectedCategory("");
|
90 |
+
setDate({ from: undefined, to: undefined });
|
91 |
+
};
|
92 |
+
|
93 |
+
const [expandedSources, setExpandedSources] = useState<Record<number, boolean>>({});
|
94 |
+
|
95 |
+
const toggleSource = (index: number) => {
|
96 |
+
setExpandedSources(prev => ({
|
97 |
+
...prev,
|
98 |
+
[index]: !prev[index]
|
99 |
+
}));
|
100 |
+
};
|
101 |
+
|
102 |
+
return (
|
103 |
+
<div className="container mx-auto px-4 py-8 animate-fade-in">
|
104 |
+
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6">
|
105 |
+
<h1 className="text-3xl font-bold mb-4 md:mb-0 text-financial-navy dark:text-white bg-clip-text text-transparent bg-gradient-to-r from-financial-accent to-financial-light-accent">
|
106 |
+
Financial Sources
|
107 |
+
</h1>
|
108 |
+
|
109 |
+
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2">
|
110 |
+
<Select value={sortBy} onValueChange={setSortBy}>
|
111 |
+
<SelectTrigger className="w-[180px] glass-effect">
|
112 |
+
<div className="flex items-center">
|
113 |
+
<ArrowUpDown className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
114 |
+
<span>Sort By</span>
|
115 |
+
</div>
|
116 |
+
</SelectTrigger>
|
117 |
+
<SelectContent>
|
118 |
+
<SelectItem value="date-desc">Date (Newest)</SelectItem>
|
119 |
+
<SelectItem value="date-asc">Date (Oldest)</SelectItem>
|
120 |
+
<SelectItem value="relevance-desc">Relevance</SelectItem>
|
121 |
+
</SelectContent>
|
122 |
+
</Select>
|
123 |
+
</div>
|
124 |
+
</div>
|
125 |
+
|
126 |
+
<div className="glass-effect rounded-lg p-6 mb-8 shadow-lg">
|
127 |
+
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2">
|
128 |
+
<div className="relative flex-1">
|
129 |
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
130 |
+
<Input
|
131 |
+
type="text"
|
132 |
+
placeholder="Search sources..."
|
133 |
+
value={searchTerm}
|
134 |
+
onChange={(e) => setSearchTerm(e.target.value)}
|
135 |
+
className="pl-10 glass-input"
|
136 |
+
/>
|
137 |
+
</div>
|
138 |
+
|
139 |
+
<Select value={selectedSource} onValueChange={setSelectedSource}>
|
140 |
+
<SelectTrigger className="w-full md:w-[180px] glass-input">
|
141 |
+
<span className="truncate">
|
142 |
+
{selectedSource || "All Sources"}
|
143 |
+
</span>
|
144 |
+
</SelectTrigger>
|
145 |
+
<SelectContent>
|
146 |
+
<SelectItem value="All">All Sources</SelectItem>
|
147 |
+
{sourceTypes.map(type => (
|
148 |
+
<SelectItem key={type} value={type}>{type}</SelectItem>
|
149 |
+
))}
|
150 |
+
</SelectContent>
|
151 |
+
</Select>
|
152 |
+
|
153 |
+
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
154 |
+
<SelectTrigger className="w-full md:w-[180px] glass-input">
|
155 |
+
<span className="truncate">
|
156 |
+
{selectedCategory || "All Categories"}
|
157 |
+
</span>
|
158 |
+
</SelectTrigger>
|
159 |
+
<SelectContent>
|
160 |
+
<SelectItem value="All">All Categories</SelectItem>
|
161 |
+
{categories.map(category => (
|
162 |
+
<SelectItem key={category} value={category}>{category}</SelectItem>
|
163 |
+
))}
|
164 |
+
</SelectContent>
|
165 |
+
</Select>
|
166 |
+
|
167 |
+
<Popover>
|
168 |
+
<PopoverTrigger asChild>
|
169 |
+
<Button variant="outline" className="w-full md:w-[180px] justify-start text-left glass-input">
|
170 |
+
<Calendar className="mr-2 h-4 w-4" />
|
171 |
+
<span>
|
172 |
+
{date.from || date.to ? (
|
173 |
+
<>
|
174 |
+
{date.from ? format(date.from, "LLL dd, y") : "From"} - {" "}
|
175 |
+
{date.to ? format(date.to, "LLL dd, y") : "To"}
|
176 |
+
</>
|
177 |
+
) : (
|
178 |
+
"Date Range"
|
179 |
+
)}
|
180 |
+
</span>
|
181 |
+
</Button>
|
182 |
+
</PopoverTrigger>
|
183 |
+
<PopoverContent className="w-auto p-0">
|
184 |
+
<CalendarComponent
|
185 |
+
mode="range"
|
186 |
+
selected={date}
|
187 |
+
onSelect={(value: DateRange | undefined) => {
|
188 |
+
if (value) setDate(value);
|
189 |
+
}}
|
190 |
+
className={cn("p-3 pointer-events-auto")}
|
191 |
+
/>
|
192 |
+
</PopoverContent>
|
193 |
+
</Popover>
|
194 |
+
|
195 |
+
<Button
|
196 |
+
variant="outline"
|
197 |
+
className="md:w-auto glass-input"
|
198 |
+
onClick={resetFilters}
|
199 |
+
>
|
200 |
+
Reset
|
201 |
+
</Button>
|
202 |
+
</div>
|
203 |
+
</div>
|
204 |
+
|
205 |
+
<div className="space-y-4">
|
206 |
+
{sortedSources.length > 0 ? (
|
207 |
+
sortedSources.map((source, index) => (
|
208 |
+
<Card key={index} className="result-card glass-effect overflow-hidden transition-all duration-300">
|
209 |
+
<CardHeader className="pb-2">
|
210 |
+
<div className="flex items-start justify-between">
|
211 |
+
<div>
|
212 |
+
<CardTitle className="text-lg text-gradient">
|
213 |
+
{source.metadata?.source || "Financial Ruling"}
|
214 |
+
</CardTitle>
|
215 |
+
<div className="flex items-center mt-1 text-sm text-muted-foreground">
|
216 |
+
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
217 |
+
<span>{source.metadata?.source || "Unknown Source"}</span>
|
218 |
+
{source.metadata?.ruling_date && (
|
219 |
+
<>
|
220 |
+
<span className="mx-1.5">•</span>
|
221 |
+
<Calendar className="h-3.5 w-3.5 mr-1.5" />
|
222 |
+
<span>{new Date(source.metadata.ruling_date).toLocaleDateString()}</span>
|
223 |
+
</>
|
224 |
+
)}
|
225 |
+
</div>
|
226 |
+
</div>
|
227 |
+
<Button
|
228 |
+
variant="ghost"
|
229 |
+
size="sm"
|
230 |
+
onClick={() => toggleSource(index)}
|
231 |
+
className="p-0 h-8 w-8 hover:bg-accent/10"
|
232 |
+
>
|
233 |
+
{expandedSources[index] ? (
|
234 |
+
<ChevronUp className="h-4 w-4" />
|
235 |
+
) : (
|
236 |
+
<ChevronDown className="h-4 w-4" />
|
237 |
+
)}
|
238 |
+
</Button>
|
239 |
+
</div>
|
240 |
+
</CardHeader>
|
241 |
+
<CardContent className={expandedSources[index] ? "pb-2" : "max-h-24 overflow-hidden relative"}>
|
242 |
+
<p className="text-sm text-muted-foreground">
|
243 |
+
{expandedSources[index]
|
244 |
+
? source.content_snippet
|
245 |
+
: source.content_snippet.substring(0, 150) + "..."}
|
246 |
+
</p>
|
247 |
+
{!expandedSources[index] && (
|
248 |
+
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-card to-transparent"></div>
|
249 |
+
)}
|
250 |
+
</CardContent>
|
251 |
+
{expandedSources[index] && (
|
252 |
+
<CardFooter className="pt-0 text-sm flex justify-end">
|
253 |
+
<Button variant="link" size="sm" className="h-8 p-0 text-financial-accent">
|
254 |
+
<ExternalLink className="h-3 w-3 mr-1" />
|
255 |
+
View source
|
256 |
+
</Button>
|
257 |
+
</CardFooter>
|
258 |
+
)}
|
259 |
+
</Card>
|
260 |
+
))
|
261 |
+
) : (
|
262 |
+
<div className="glass-effect rounded-lg p-8 text-center shadow-lg">
|
263 |
+
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted/30 flex items-center justify-center">
|
264 |
+
<FileText className="h-8 w-8 text-muted-foreground" />
|
265 |
+
</div>
|
266 |
+
<h3 className="text-lg font-medium mb-2 text-gradient">No sources found</h3>
|
267 |
+
<p className="text-muted-foreground">
|
268 |
+
{sources.length > 0
|
269 |
+
? "No sources match your current search criteria. Try adjusting your filters."
|
270 |
+
: "Chat with Insight AI to get information with source citations."}
|
271 |
+
</p>
|
272 |
+
<Button
|
273 |
+
variant="outline"
|
274 |
+
className="mt-4 glass-input"
|
275 |
+
onClick={resetFilters}
|
276 |
+
>
|
277 |
+
Reset Filters
|
278 |
+
</Button>
|
279 |
+
</div>
|
280 |
+
)}
|
281 |
+
</div>
|
282 |
+
</div>
|
283 |
+
);
|
284 |
+
};
|
285 |
+
|
286 |
+
export default SourcesPage;
|
frontend/src/pages/TvShowDetailPage.tsx
DELETED
@@ -1,469 +0,0 @@
|
|
1 |
-
import React, { useEffect, useState } from 'react';
|
2 |
-
import { useParams, Link } from 'react-router-dom';
|
3 |
-
import { Play, Plus, ThumbsUp, Share2, ChevronDown } from 'lucide-react';
|
4 |
-
import { getTvShowMetadata, getGenresItems } from '../lib/api';
|
5 |
-
import ContentRow from '../components/ContentRow';
|
6 |
-
import { useToast } from '@/hooks/use-toast';
|
7 |
-
|
8 |
-
interface Episode {
|
9 |
-
episode_number: number;
|
10 |
-
name: string;
|
11 |
-
overview: string;
|
12 |
-
still_path: string;
|
13 |
-
air_date: string;
|
14 |
-
runtime: number;
|
15 |
-
fileName?: string; // The actual file name with extension
|
16 |
-
}
|
17 |
-
|
18 |
-
interface Season {
|
19 |
-
season_number: number;
|
20 |
-
name: string;
|
21 |
-
overview: string;
|
22 |
-
poster_path: string;
|
23 |
-
air_date: string;
|
24 |
-
episodes: Episode[];
|
25 |
-
}
|
26 |
-
|
27 |
-
interface FileStructureItem {
|
28 |
-
type: string;
|
29 |
-
path: string;
|
30 |
-
contents?: FileStructureItem[];
|
31 |
-
size?: number;
|
32 |
-
}
|
33 |
-
|
34 |
-
const TvShowDetailPage = () => {
|
35 |
-
const { title } = useParams<{ title: string }>();
|
36 |
-
const [tvShow, setTvShow] = useState<any>(null);
|
37 |
-
const [seasons, setSeasons] = useState<Season[]>([]);
|
38 |
-
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
39 |
-
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
40 |
-
const [loading, setLoading] = useState(true);
|
41 |
-
const [seasonsLoading, setSeasonsLoading] = useState(false);
|
42 |
-
const [similarShows, setSimilarShows] = useState<any[]>([]);
|
43 |
-
const [expandedSeasons, setExpandedSeasons] = useState(false);
|
44 |
-
const { toast } = useToast();
|
45 |
-
|
46 |
-
// Helper function to extract episode info from file path
|
47 |
-
const extractEpisodeInfoFromPath = (filePath: string): Episode | null => {
|
48 |
-
// Get the actual file name (with extension) from the full file path
|
49 |
-
const fileName = filePath.split('/').pop() || filePath;
|
50 |
-
// For file names like "Nanbaka - S01E02 - The Inmates Are Stupid! The Guards Are Kind of Stupid, Too! SDTV.mp4"
|
51 |
-
const episodeRegex = /S(\d+)E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i;
|
52 |
-
const match = fileName.match(episodeRegex);
|
53 |
-
|
54 |
-
if (match) {
|
55 |
-
const episodeNumber = parseInt(match[2], 10);
|
56 |
-
const episodeName = match[3].trim();
|
57 |
-
|
58 |
-
// Determine quality from the file name
|
59 |
-
const isHD = fileName.toLowerCase().includes('720p') ||
|
60 |
-
fileName.toLowerCase().includes('1080p') ||
|
61 |
-
fileName.toLowerCase().includes('hdtv');
|
62 |
-
|
63 |
-
return {
|
64 |
-
episode_number: episodeNumber,
|
65 |
-
name: episodeName,
|
66 |
-
overview: '', // No overview available from file path
|
67 |
-
still_path: '/placeholder.svg', // Use placeholder image
|
68 |
-
air_date: '', // No air date available
|
69 |
-
runtime: isHD ? 24 : 22, // Approximate runtime based on quality
|
70 |
-
fileName: fileName // Store only the file name with extension
|
71 |
-
};
|
72 |
-
}
|
73 |
-
|
74 |
-
return null;
|
75 |
-
};
|
76 |
-
|
77 |
-
// Helper function to extract season number and name from directory path
|
78 |
-
const getSeasonInfoFromPath = (path: string): { number: number, name: string } => {
|
79 |
-
const seasonRegex = /Season\s*(\d+)/i;
|
80 |
-
const specialsRegex = /Specials/i;
|
81 |
-
|
82 |
-
if (specialsRegex.test(path)) {
|
83 |
-
return { number: 0, name: 'Specials' };
|
84 |
-
}
|
85 |
-
|
86 |
-
const match = path.match(seasonRegex);
|
87 |
-
if (match) {
|
88 |
-
return {
|
89 |
-
number: parseInt(match[1], 10),
|
90 |
-
name: `Season ${match[1]}`
|
91 |
-
};
|
92 |
-
}
|
93 |
-
|
94 |
-
return { number: 1, name: 'Season 1' }; // Default if no match
|
95 |
-
};
|
96 |
-
|
97 |
-
// Process the file structure to extract seasons and episodes
|
98 |
-
const processTvShowFileStructure = (fileStructure: any): Season[] => {
|
99 |
-
if (!fileStructure || !fileStructure.contents) {
|
100 |
-
return [];
|
101 |
-
}
|
102 |
-
|
103 |
-
const extractedSeasons: Season[] = [];
|
104 |
-
|
105 |
-
// Find season directories
|
106 |
-
const seasonDirectories = fileStructure.contents.filter(
|
107 |
-
(item: FileStructureItem) => item.type === 'directory'
|
108 |
-
);
|
109 |
-
|
110 |
-
seasonDirectories.forEach((seasonDir: FileStructureItem) => {
|
111 |
-
if (!seasonDir.contents) return;
|
112 |
-
|
113 |
-
const seasonInfo = getSeasonInfoFromPath(seasonDir.path);
|
114 |
-
const episodesArr: Episode[] = [];
|
115 |
-
|
116 |
-
// Process files in this season directory
|
117 |
-
seasonDir.contents.forEach((item: FileStructureItem) => {
|
118 |
-
if (item.type === 'file') {
|
119 |
-
const episode = extractEpisodeInfoFromPath(item.path);
|
120 |
-
if (episode) {
|
121 |
-
episodesArr.push(episode);
|
122 |
-
}
|
123 |
-
}
|
124 |
-
});
|
125 |
-
|
126 |
-
// Sort episodes by episode number
|
127 |
-
episodesArr.sort((a, b) => a.episode_number - b.episode_number);
|
128 |
-
|
129 |
-
if (episodesArr.length > 0) {
|
130 |
-
extractedSeasons.push({
|
131 |
-
season_number: seasonInfo.number,
|
132 |
-
name: seasonInfo.name,
|
133 |
-
overview: '', // No overview available
|
134 |
-
poster_path: tvShow?.data?.image || '/placeholder.svg',
|
135 |
-
air_date: tvShow?.data?.year || '',
|
136 |
-
episodes: episodesArr
|
137 |
-
});
|
138 |
-
}
|
139 |
-
});
|
140 |
-
|
141 |
-
// Sort seasons by season number
|
142 |
-
extractedSeasons.sort((a, b) => a.season_number - b.season_number);
|
143 |
-
return extractedSeasons;
|
144 |
-
};
|
145 |
-
|
146 |
-
useEffect(() => {
|
147 |
-
const fetchTvShowData = async () => {
|
148 |
-
if (!title) return;
|
149 |
-
|
150 |
-
try {
|
151 |
-
setLoading(true);
|
152 |
-
const data = await getTvShowMetadata(title);
|
153 |
-
setTvShow(data);
|
154 |
-
|
155 |
-
if (data && data.file_structure) {
|
156 |
-
const processedSeasons = processTvShowFileStructure(data.file_structure);
|
157 |
-
setSeasons(processedSeasons);
|
158 |
-
|
159 |
-
// Select the first season by default (Specials = 0, Season 1 = 1)
|
160 |
-
if (processedSeasons.length > 0) {
|
161 |
-
setSelectedSeason(processedSeasons[0].season_number);
|
162 |
-
}
|
163 |
-
}
|
164 |
-
|
165 |
-
|
166 |
-
// Fetch similar shows based on individual genres
|
167 |
-
if (data.data && data.data.genres && data.data.genres.length > 0) {
|
168 |
-
const currentShowName = data.data.name;
|
169 |
-
const showsByGenre = await Promise.all(
|
170 |
-
data.data.genres.map(async (genre: any) => {
|
171 |
-
// Pass a single genre name for each call
|
172 |
-
const genreResult = await getGenresItems([genre.name], 'series', 10, 1);
|
173 |
-
console.log('Genre result:', genreResult);
|
174 |
-
if (genreResult.series && Array.isArray(genreResult.series)) {
|
175 |
-
return genreResult.series.map((showItem: any) => {
|
176 |
-
const { title: similarTitle } = showItem;
|
177 |
-
console.log('Similar show:', showItem);
|
178 |
-
// Skip current show
|
179 |
-
if (similarTitle === currentShowName) return null;
|
180 |
-
return {
|
181 |
-
type: 'tvshow',
|
182 |
-
title: similarTitle,
|
183 |
-
};
|
184 |
-
});
|
185 |
-
}
|
186 |
-
return [];
|
187 |
-
})
|
188 |
-
);
|
189 |
-
|
190 |
-
// Flatten the array of arrays and remove null results
|
191 |
-
const flattenedShows = showsByGenre.flat().filter(Boolean);
|
192 |
-
// Remove duplicates based on the title
|
193 |
-
const uniqueShows = Array.from(
|
194 |
-
new Map(flattenedShows.map(show => [show.title, show])).values()
|
195 |
-
);
|
196 |
-
setSimilarShows(uniqueShows);
|
197 |
-
}
|
198 |
-
} catch (error) {
|
199 |
-
console.error(`Error fetching TV show details for ${title}:`, error);
|
200 |
-
toast({
|
201 |
-
title: "Error loading TV show details",
|
202 |
-
description: "Please try again later",
|
203 |
-
variant: "destructive"
|
204 |
-
});
|
205 |
-
} finally {
|
206 |
-
setLoading(false);
|
207 |
-
}
|
208 |
-
};
|
209 |
-
|
210 |
-
fetchTvShowData();
|
211 |
-
}, [title, toast]);
|
212 |
-
|
213 |
-
// Update episodes when selectedSeason or seasons change
|
214 |
-
useEffect(() => {
|
215 |
-
if (seasons.length > 0) {
|
216 |
-
const season = seasons.find(s => s.season_number === selectedSeason);
|
217 |
-
if (season) {
|
218 |
-
setEpisodes(season.episodes);
|
219 |
-
} else {
|
220 |
-
setEpisodes([]);
|
221 |
-
}
|
222 |
-
}
|
223 |
-
}, [selectedSeason, seasons]);
|
224 |
-
|
225 |
-
const toggleExpandSeasons = () => {
|
226 |
-
setExpandedSeasons(!expandedSeasons);
|
227 |
-
};
|
228 |
-
|
229 |
-
if (loading) {
|
230 |
-
return (
|
231 |
-
<div className="flex items-center justify-center min-h-screen">
|
232 |
-
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-netflix-red"></div>
|
233 |
-
</div>
|
234 |
-
);
|
235 |
-
}
|
236 |
-
|
237 |
-
if (!tvShow) {
|
238 |
-
return (
|
239 |
-
<div className="pt-24 px-4 md:px-8 text-center min-h-screen">
|
240 |
-
<h1 className="text-3xl font-bold mb-4">TV Show Not Found</h1>
|
241 |
-
<p className="text-netflix-gray mb-6">We couldn't find the TV show you're looking for.</p>
|
242 |
-
<Link to="/tv-shows" className="bg-netflix-red px-6 py-2 rounded font-medium">
|
243 |
-
Back to TV Shows
|
244 |
-
</Link>
|
245 |
-
</div>
|
246 |
-
);
|
247 |
-
}
|
248 |
-
|
249 |
-
const tvShowData = tvShow.data;
|
250 |
-
const airYears = tvShowData.year;
|
251 |
-
const language = tvShowData.originalLanguage;
|
252 |
-
const showName = (tvShowData.translations?.nameTranslations?.find((t: any) => t.language === 'eng')?.name || tvShowData.name || '');
|
253 |
-
const overview =
|
254 |
-
tvShowData.translations?.overviewTranslations?.find((t: any) => t.language === 'eng')?.overview ||
|
255 |
-
tvShowData.translations?.overviewTranslations?.[0]?.overview ||
|
256 |
-
tvShowData.overview ||
|
257 |
-
'No overview available.';
|
258 |
-
|
259 |
-
// Get the current season details
|
260 |
-
const currentSeason = seasons.find(s => s.season_number === selectedSeason);
|
261 |
-
const currentSeasonName = currentSeason?.name || `Season ${selectedSeason}`;
|
262 |
-
|
263 |
-
return (
|
264 |
-
<div className="pb-12 animate-fade-in">
|
265 |
-
{/* Hero backdrop */}
|
266 |
-
<div className="relative w-full h-[500px] md:h-[600px]">
|
267 |
-
<div className="absolute inset-0">
|
268 |
-
<img
|
269 |
-
src={tvShowData.image}
|
270 |
-
alt={showName}
|
271 |
-
className="w-full h-full object-cover"
|
272 |
-
onError={(e) => {
|
273 |
-
const target = e.target as HTMLImageElement;
|
274 |
-
target.src = '/placeholder.svg';
|
275 |
-
}}
|
276 |
-
/>
|
277 |
-
<div className="absolute inset-0 bg-gradient-to-t from-netflix-black via-netflix-black/60 to-transparent" />
|
278 |
-
<div className="absolute inset-0 bg-gradient-to-r from-netflix-black/80 via-netflix-black/40 to-transparent" />
|
279 |
-
</div>
|
280 |
-
</div>
|
281 |
-
|
282 |
-
{/* TV Show details */}
|
283 |
-
<div className="px-4 md:px-8 -mt-60 relative z-10 max-w-7xl mx-auto">
|
284 |
-
<div className="flex flex-col md:flex-row gap-8">
|
285 |
-
{/* Poster */}
|
286 |
-
<div className="flex-shrink-0 hidden md:block">
|
287 |
-
<img
|
288 |
-
src={tvShowData.image}
|
289 |
-
alt={showName}
|
290 |
-
className="w-64 h-96 object-cover rounded-md shadow-lg"
|
291 |
-
onError={(e) => {
|
292 |
-
const target = e.target as HTMLImageElement;
|
293 |
-
target.src = '/placeholder.svg';
|
294 |
-
}}
|
295 |
-
/>
|
296 |
-
</div>
|
297 |
-
|
298 |
-
{/* Details */}
|
299 |
-
<div className="flex-grow">
|
300 |
-
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-3">{showName}</h1>
|
301 |
-
|
302 |
-
<div className="flex flex-wrap items-center text-sm text-gray-300 mb-6">
|
303 |
-
{airYears && <span className="mr-3">{airYears}</span>}
|
304 |
-
{tvShowData.vote_average && (
|
305 |
-
<span className="mr-3">
|
306 |
-
<span className="text-netflix-red">★</span> {tvShowData.vote_average.toFixed(1)}
|
307 |
-
</span>
|
308 |
-
)}
|
309 |
-
{seasons.length > 0 && (
|
310 |
-
<span className="mr-3">{seasons.length} Season{seasons.length !== 1 ? 's' : ''}</span>
|
311 |
-
)}
|
312 |
-
</div>
|
313 |
-
|
314 |
-
<div className="flex flex-wrap items-center gap-2 my-4">
|
315 |
-
{tvShowData.genres && tvShowData.genres.map((genre: any, index: number) => (
|
316 |
-
<Link
|
317 |
-
key={index}
|
318 |
-
to={`/tv-shows?genre=${genre.name || genre}`}
|
319 |
-
className="px-3 py-1 bg-netflix-gray/20 rounded-full text-sm hover:bg-netflix-gray/40 transition"
|
320 |
-
>
|
321 |
-
{genre.name || genre}
|
322 |
-
</Link>
|
323 |
-
))}
|
324 |
-
</div>
|
325 |
-
|
326 |
-
<p className="text-gray-300 mb-8 max-w-3xl">{overview}</p>
|
327 |
-
|
328 |
-
<div className="flex flex-wrap gap-3 mb-8">
|
329 |
-
<Link
|
330 |
-
to={`/tv-show/${encodeURIComponent(title!)}/watch?season=${encodeURIComponent(currentSeasonName)}&episode=${encodeURIComponent(episodes[0]?.fileName || '')}`}
|
331 |
-
className="flex items-center px-6 py-2 rounded bg-netflix-red text-white font-semibold hover:bg-red-700 transition"
|
332 |
-
>
|
333 |
-
<Play className="w-5 h-5 mr-2" /> Play
|
334 |
-
</Link>
|
335 |
-
|
336 |
-
<button className="flex items-center px-4 py-2 rounded bg-gray-700 text-white hover:bg-gray-600 transition">
|
337 |
-
<Plus className="w-5 h-5 mr-2" /> My List
|
338 |
-
</button>
|
339 |
-
|
340 |
-
<button className="flex items-center justify-center w-10 h-10 rounded-full bg-gray-700 text-white hover:bg-gray-600 transition">
|
341 |
-
<ThumbsUp className="w-5 h-5" />
|
342 |
-
</button>
|
343 |
-
|
344 |
-
<button className="flex items-center justify-center w-10 h-10 rounded-full bg-gray-700 text-white hover:bg-gray-600 transition">
|
345 |
-
<Share2 className="w-5 h-5" />
|
346 |
-
</button>
|
347 |
-
</div>
|
348 |
-
|
349 |
-
{/* Additional details */}
|
350 |
-
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
351 |
-
{language && (
|
352 |
-
<div>
|
353 |
-
<h3 className="text-gray-400 font-semibold mb-1">Language</h3>
|
354 |
-
<p className="text-white">{language}</p>
|
355 |
-
</div>
|
356 |
-
)}
|
357 |
-
{tvShowData.translations?.nameTranslations?.find((t: any) => t.isPrimary) && (
|
358 |
-
<div>
|
359 |
-
<h3 className="text-gray-400 font-semibold mb-1">Tagline</h3>
|
360 |
-
<p className="text-white">
|
361 |
-
"{tvShowData.translations.nameTranslations.find((t: any) => t.isPrimary).tagline || ''}"
|
362 |
-
</p>
|
363 |
-
</div>
|
364 |
-
)}
|
365 |
-
</div>
|
366 |
-
</div>
|
367 |
-
</div>
|
368 |
-
|
369 |
-
{/* Episodes */}
|
370 |
-
<div className="mt-12 bg-netflix-dark rounded-md overflow-hidden">
|
371 |
-
<div className="p-4 border-b border-netflix-gray/30">
|
372 |
-
<div className="flex justify-between items-center">
|
373 |
-
<h2 className="text-xl font-semibold">Episodes</h2>
|
374 |
-
|
375 |
-
<div className="relative">
|
376 |
-
<button
|
377 |
-
onClick={toggleExpandSeasons}
|
378 |
-
className="flex items-center gap-2 px-4 py-1.5 rounded border border-netflix-gray hover:bg-netflix-gray/20 transition"
|
379 |
-
>
|
380 |
-
<span>{currentSeasonName}</span>
|
381 |
-
<ChevronDown className={`w-4 h-4 transition-transform ${expandedSeasons ? 'rotate-180' : ''}`} />
|
382 |
-
</button>
|
383 |
-
|
384 |
-
{expandedSeasons && (
|
385 |
-
<div className="absolute right-0 mt-1 w-48 bg-netflix-dark rounded border border-netflix-gray/50 shadow-lg z-10 max-h-56 overflow-y-auto py-1">
|
386 |
-
{seasons.map((season) => (
|
387 |
-
<button
|
388 |
-
key={season.season_number}
|
389 |
-
className={`block w-full text-left px-4 py-2 hover:bg-netflix-gray/20 transition ${selectedSeason === season.season_number ? 'bg-netflix-gray/30' : ''}`}
|
390 |
-
onClick={() => {
|
391 |
-
setSelectedSeason(season.season_number);
|
392 |
-
setExpandedSeasons(false);
|
393 |
-
}}
|
394 |
-
>
|
395 |
-
{season.name}
|
396 |
-
</button>
|
397 |
-
))}
|
398 |
-
</div>
|
399 |
-
)}
|
400 |
-
</div>
|
401 |
-
</div>
|
402 |
-
</div>
|
403 |
-
|
404 |
-
<div className="divide-y divide-netflix-gray/30">
|
405 |
-
{seasonsLoading ? (
|
406 |
-
<div className="p-8 flex justify-center">
|
407 |
-
<div className="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-netflix-red"></div>
|
408 |
-
</div>
|
409 |
-
) : episodes.length === 0 ? (
|
410 |
-
<div className="p-8 text-center text-netflix-gray">
|
411 |
-
No episodes available for this season.
|
412 |
-
</div>
|
413 |
-
) : (
|
414 |
-
episodes.map((episode) => (
|
415 |
-
<div key={episode.episode_number} className="p-4 hover:bg-netflix-gray/10 transition">
|
416 |
-
<Link
|
417 |
-
to={`/tv-show/${encodeURIComponent(title!)}/watch?season=${encodeURIComponent(currentSeasonName)}&episode=${encodeURIComponent(episode.fileName || '')}`}
|
418 |
-
className="flex flex-col md:flex-row md:items-center gap-4"
|
419 |
-
>
|
420 |
-
<div className="flex-shrink-0 relative group">
|
421 |
-
<img
|
422 |
-
src={episode.still_path}
|
423 |
-
alt={episode.name}
|
424 |
-
className="w-full md:w-40 h-24 object-cover rounded"
|
425 |
-
onError={(e) => {
|
426 |
-
const target = e.target as HTMLImageElement;
|
427 |
-
target.src = '/placeholder.svg';
|
428 |
-
}}
|
429 |
-
/>
|
430 |
-
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
431 |
-
<Play className="w-10 h-10 text-white" />
|
432 |
-
</div>
|
433 |
-
<div className="absolute bottom-2 left-2 bg-black/70 px-2 py-1 rounded text-xs">
|
434 |
-
{episode.runtime ? `${episode.runtime} min` : '--'}
|
435 |
-
</div>
|
436 |
-
</div>
|
437 |
-
|
438 |
-
<div className="flex-grow">
|
439 |
-
<div className="flex justify-between">
|
440 |
-
<h3 className="font-medium">
|
441 |
-
{episode.episode_number}. {episode.name}
|
442 |
-
</h3>
|
443 |
-
<span className="text-netflix-gray text-sm">
|
444 |
-
{episode.air_date ? new Date(episode.air_date).toLocaleDateString() : ''}
|
445 |
-
</span>
|
446 |
-
</div>
|
447 |
-
<p className="text-netflix-gray text-sm mt-1 line-clamp-2">
|
448 |
-
{episode.overview || 'No description available.'}
|
449 |
-
</p>
|
450 |
-
</div>
|
451 |
-
</Link>
|
452 |
-
</div>
|
453 |
-
))
|
454 |
-
)}
|
455 |
-
</div>
|
456 |
-
</div>
|
457 |
-
|
458 |
-
{/* Similar Shows */}
|
459 |
-
{similarShows.length > 0 && (
|
460 |
-
<div className="mt-16">
|
461 |
-
<ContentRow title="More Like This" items={similarShows} />
|
462 |
-
</div>
|
463 |
-
)}
|
464 |
-
</div>
|
465 |
-
</div>
|
466 |
-
);
|
467 |
-
};
|
468 |
-
|
469 |
-
export default TvShowDetailPage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/pages/TvShowPlayerPage.tsx
DELETED
@@ -1,423 +0,0 @@
|
|
1 |
-
import React, { useEffect, useState } from 'react';
|
2 |
-
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
3 |
-
import { getTvShowMetadata } from '../lib/api';
|
4 |
-
import { useToast } from '@/hooks/use-toast';
|
5 |
-
import TVShowPlayer from '../components/TVShowPlayer';
|
6 |
-
import EpisodesPanel from '../components/EpisodesPanel';
|
7 |
-
|
8 |
-
interface FileStructureItem {
|
9 |
-
type: string;
|
10 |
-
path: string;
|
11 |
-
contents?: FileStructureItem[];
|
12 |
-
size?: number;
|
13 |
-
}
|
14 |
-
|
15 |
-
interface Episode {
|
16 |
-
episode_number: number;
|
17 |
-
name: string;
|
18 |
-
overview: string;
|
19 |
-
still_path: string;
|
20 |
-
air_date: string;
|
21 |
-
runtime: number;
|
22 |
-
fileName?: string; // The actual file name with extension
|
23 |
-
}
|
24 |
-
|
25 |
-
interface Season {
|
26 |
-
season_number: number;
|
27 |
-
name: string;
|
28 |
-
episodes: Episode[];
|
29 |
-
}
|
30 |
-
|
31 |
-
interface PlaybackProgress {
|
32 |
-
[key: string]: {
|
33 |
-
currentTime: number;
|
34 |
-
duration: number;
|
35 |
-
lastPlayed: string;
|
36 |
-
completed: boolean;
|
37 |
-
};
|
38 |
-
}
|
39 |
-
|
40 |
-
const TvShowPlayerPage = () => {
|
41 |
-
const [loading, setLoading] = useState(true);
|
42 |
-
const [error, setError] = useState<string | null>(null);
|
43 |
-
const [showInfo, setShowInfo] = useState<any>(null);
|
44 |
-
const [showName, setShowName] = useState<string>('');
|
45 |
-
const [seasons, setSeasons] = useState<Season[]>([]);
|
46 |
-
const [selectedSeason, setSelectedSeason] = useState<string>('');
|
47 |
-
const [selectedEpisode, setSelectedEpisode] = useState<string>('');
|
48 |
-
const [activeEpisodeIndex, setActiveEpisodeIndex] = useState<number>(0);
|
49 |
-
const [activeSeasonIndex, setActiveSeasonIndex] = useState<number>(0);
|
50 |
-
const [showEpisodeSelector, setShowEpisodeSelector] = useState(false);
|
51 |
-
const [playbackProgress, setPlaybackProgress] = useState<PlaybackProgress>({});
|
52 |
-
const [needsReload, setNeedsReload] = useState(false); // Flag to trigger video reload
|
53 |
-
|
54 |
-
const { title } = useParams<{ title: string }>();
|
55 |
-
const [searchParams] = useSearchParams();
|
56 |
-
const seasonParam = searchParams.get('season');
|
57 |
-
const episodeParam = searchParams.get('episode');
|
58 |
-
|
59 |
-
const navigate = useNavigate();
|
60 |
-
const { toast } = useToast();
|
61 |
-
|
62 |
-
// Helper function to extract episode info from file path
|
63 |
-
const extractEpisodeInfoFromPath = (filePath: string): Episode | null => {
|
64 |
-
const fileName = filePath.split('/').pop() || filePath;
|
65 |
-
const episodeRegex = /S(\d+)E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i;
|
66 |
-
const match = fileName.match(episodeRegex);
|
67 |
-
|
68 |
-
if (match) {
|
69 |
-
const episodeNumber = parseInt(match[2], 10);
|
70 |
-
const episodeName = match[3].trim();
|
71 |
-
const isHD = fileName.toLowerCase().includes('720p') ||
|
72 |
-
fileName.toLowerCase().includes('1080p') ||
|
73 |
-
fileName.toLowerCase().includes('hdtv');
|
74 |
-
|
75 |
-
return {
|
76 |
-
episode_number: episodeNumber,
|
77 |
-
name: episodeName,
|
78 |
-
overview: '',
|
79 |
-
still_path: '/placeholder.svg',
|
80 |
-
air_date: '',
|
81 |
-
runtime: isHD ? 24 : 22,
|
82 |
-
fileName: fileName
|
83 |
-
};
|
84 |
-
}
|
85 |
-
|
86 |
-
return null;
|
87 |
-
};
|
88 |
-
|
89 |
-
// Helper function to extract season info from directory path
|
90 |
-
const getSeasonInfoFromPath = (path: string): { number: number, name: string } => {
|
91 |
-
const seasonRegex = /Season\s*(\d+)/i;
|
92 |
-
const specialsRegex = /Specials/i;
|
93 |
-
|
94 |
-
if (specialsRegex.test(path)) {
|
95 |
-
return { number: 0, name: 'Specials' };
|
96 |
-
}
|
97 |
-
|
98 |
-
const match = path.match(seasonRegex);
|
99 |
-
if (match) {
|
100 |
-
return {
|
101 |
-
number: parseInt(match[1], 10),
|
102 |
-
name: `Season ${match[1]}`
|
103 |
-
};
|
104 |
-
}
|
105 |
-
|
106 |
-
return { number: 1, name: 'Season 1' };
|
107 |
-
};
|
108 |
-
|
109 |
-
// Process the file structure to extract seasons and episodes
|
110 |
-
const processTvShowFileStructure = (fileStructure: any): Season[] => {
|
111 |
-
if (!fileStructure || !fileStructure.contents) {
|
112 |
-
return [];
|
113 |
-
}
|
114 |
-
|
115 |
-
const extractedSeasons: Season[] = [];
|
116 |
-
|
117 |
-
// Find season directories
|
118 |
-
const seasonDirectories = fileStructure.contents.filter(
|
119 |
-
(item: FileStructureItem) => item.type === 'directory'
|
120 |
-
);
|
121 |
-
|
122 |
-
seasonDirectories.forEach((seasonDir: FileStructureItem) => {
|
123 |
-
if (!seasonDir.contents) return;
|
124 |
-
|
125 |
-
const seasonInfo = getSeasonInfoFromPath(seasonDir.path);
|
126 |
-
const episodesArr: Episode[] = [];
|
127 |
-
|
128 |
-
// Process files in this season directory
|
129 |
-
seasonDir.contents.forEach((item: FileStructureItem) => {
|
130 |
-
if (item.type === 'file') {
|
131 |
-
const episode = extractEpisodeInfoFromPath(item.path);
|
132 |
-
if (episode) {
|
133 |
-
episodesArr.push(episode);
|
134 |
-
}
|
135 |
-
}
|
136 |
-
});
|
137 |
-
|
138 |
-
// Sort episodes by episode number
|
139 |
-
episodesArr.sort((a, b) => a.episode_number - b.episode_number);
|
140 |
-
|
141 |
-
if (episodesArr.length > 0) {
|
142 |
-
extractedSeasons.push({
|
143 |
-
season_number: seasonInfo.number,
|
144 |
-
name: seasonInfo.name,
|
145 |
-
episodes: episodesArr
|
146 |
-
});
|
147 |
-
}
|
148 |
-
});
|
149 |
-
|
150 |
-
// Sort seasons by season number
|
151 |
-
extractedSeasons.sort((a, b) => a.season_number - b.season_number);
|
152 |
-
return extractedSeasons;
|
153 |
-
};
|
154 |
-
|
155 |
-
// Select first available episode when none is specified
|
156 |
-
const selectFirstAvailableEpisode = (seasons: Season[]) => {
|
157 |
-
if (seasons.length === 0) return;
|
158 |
-
|
159 |
-
// First try to find Season 1
|
160 |
-
const regularSeason = seasons.find(s => s.season_number === 1);
|
161 |
-
// If not available, use the first available season (could be Specials/Season 0)
|
162 |
-
const firstSeason = regularSeason || seasons[0];
|
163 |
-
|
164 |
-
if (firstSeason && firstSeason.episodes.length > 0) {
|
165 |
-
setSelectedSeason(firstSeason.name);
|
166 |
-
setSelectedEpisode(firstSeason.episodes[0].fileName || '');
|
167 |
-
setActiveSeasonIndex(seasons.indexOf(firstSeason));
|
168 |
-
setActiveEpisodeIndex(0);
|
169 |
-
}
|
170 |
-
};
|
171 |
-
|
172 |
-
// Load playback progress from localStorage
|
173 |
-
const loadPlaybackProgress = () => {
|
174 |
-
try {
|
175 |
-
const storedProgress = localStorage.getItem(`playback-${title}`);
|
176 |
-
if (storedProgress) {
|
177 |
-
setPlaybackProgress(JSON.parse(storedProgress));
|
178 |
-
}
|
179 |
-
} catch (error) {
|
180 |
-
console.error("Failed to load playback progress:", error);
|
181 |
-
}
|
182 |
-
};
|
183 |
-
|
184 |
-
// Save playback progress to localStorage
|
185 |
-
const savePlaybackProgress = (episodeId: string, currentTime: number, duration: number, completed: boolean = false) => {
|
186 |
-
try {
|
187 |
-
const newProgress = {
|
188 |
-
...playbackProgress,
|
189 |
-
[episodeId]: {
|
190 |
-
currentTime,
|
191 |
-
duration,
|
192 |
-
lastPlayed: new Date().toISOString(),
|
193 |
-
completed
|
194 |
-
}
|
195 |
-
};
|
196 |
-
|
197 |
-
localStorage.setItem(`playback-${title}`, JSON.stringify(newProgress));
|
198 |
-
setPlaybackProgress(newProgress);
|
199 |
-
} catch (error) {
|
200 |
-
console.error("Failed to save playback progress:", error);
|
201 |
-
}
|
202 |
-
};
|
203 |
-
|
204 |
-
// Function to load the next episode and reset the video
|
205 |
-
const loadNextEpisode = () => {
|
206 |
-
if (!seasons.length) return;
|
207 |
-
|
208 |
-
const currentSeason = seasons[activeSeasonIndex];
|
209 |
-
if (!currentSeason) return;
|
210 |
-
|
211 |
-
// If there's another episode in the current season
|
212 |
-
if (activeEpisodeIndex < currentSeason.episodes.length - 1) {
|
213 |
-
const nextEpisode = currentSeason.episodes[activeEpisodeIndex + 1];
|
214 |
-
setSelectedEpisode(nextEpisode.fileName || '');
|
215 |
-
setActiveEpisodeIndex(activeEpisodeIndex + 1);
|
216 |
-
setNeedsReload(true); // Flag to reload the video
|
217 |
-
|
218 |
-
// Update URL without page reload
|
219 |
-
navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(currentSeason.name)}&episode=${encodeURIComponent(nextEpisode.fileName || '')}`, { replace: true });
|
220 |
-
|
221 |
-
toast({
|
222 |
-
title: "Playing Next Episode",
|
223 |
-
description: `${nextEpisode.name}`,
|
224 |
-
});
|
225 |
-
}
|
226 |
-
// If there's another season available
|
227 |
-
else if (activeSeasonIndex < seasons.length - 1) {
|
228 |
-
const nextSeason = seasons[activeSeasonIndex + 1];
|
229 |
-
if (nextSeason.episodes.length > 0) {
|
230 |
-
const firstEpisode = nextSeason.episodes[0];
|
231 |
-
setSelectedSeason(nextSeason.name);
|
232 |
-
setSelectedEpisode(firstEpisode.fileName || '');
|
233 |
-
setActiveSeasonIndex(activeSeasonIndex + 1);
|
234 |
-
setActiveEpisodeIndex(0);
|
235 |
-
setNeedsReload(true); // Flag to reload the video
|
236 |
-
|
237 |
-
// Update URL without page reload
|
238 |
-
navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(nextSeason.name)}&episode=${encodeURIComponent(firstEpisode.fileName || '')}`, { replace: true });
|
239 |
-
|
240 |
-
toast({
|
241 |
-
title: "Starting Next Season",
|
242 |
-
description: `${nextSeason.name}: ${firstEpisode.name}`,
|
243 |
-
});
|
244 |
-
}
|
245 |
-
} else {
|
246 |
-
toast({
|
247 |
-
title: "End of Series",
|
248 |
-
description: "You've watched all available episodes.",
|
249 |
-
});
|
250 |
-
}
|
251 |
-
};
|
252 |
-
|
253 |
-
// Watch for changes in seasons or episodes selection
|
254 |
-
useEffect(() => {
|
255 |
-
if (seasons.length && selectedSeason && selectedEpisode) {
|
256 |
-
// Find active season and episode indexes
|
257 |
-
const seasonIndex = seasons.findIndex(s => s.name === selectedSeason);
|
258 |
-
if (seasonIndex >= 0) {
|
259 |
-
setActiveSeasonIndex(seasonIndex);
|
260 |
-
|
261 |
-
const episodeIndex = seasons[seasonIndex].episodes.findIndex(
|
262 |
-
e => e.fileName === selectedEpisode
|
263 |
-
);
|
264 |
-
if (episodeIndex >= 0) {
|
265 |
-
setActiveEpisodeIndex(episodeIndex);
|
266 |
-
}
|
267 |
-
}
|
268 |
-
}
|
269 |
-
}, [seasons, selectedSeason, selectedEpisode]);
|
270 |
-
|
271 |
-
useEffect(() => {
|
272 |
-
const fetchData = async () => {
|
273 |
-
if (!title) return;
|
274 |
-
|
275 |
-
try {
|
276 |
-
setLoading(true);
|
277 |
-
setError(null);
|
278 |
-
|
279 |
-
// Load saved playback progress
|
280 |
-
loadPlaybackProgress();
|
281 |
-
|
282 |
-
// Get TV show metadata first
|
283 |
-
const showData = await getTvShowMetadata(title);
|
284 |
-
setShowInfo(showData);
|
285 |
-
console.log('TV Show Metadata:', showData);
|
286 |
-
setShowName(showInfo?.data?.translations?.nameTranslations?.find((t: any) => t.language === 'eng')?.name || showInfo?.name || '');
|
287 |
-
// Process seasons and episodes from file structure
|
288 |
-
if (showData && showData.file_structure) {
|
289 |
-
const processedSeasons = processTvShowFileStructure(showData.file_structure);
|
290 |
-
setSeasons(processedSeasons);
|
291 |
-
|
292 |
-
// Set selected season and episode from URL params or select first available
|
293 |
-
if (seasonParam && episodeParam) {
|
294 |
-
setSelectedSeason(seasonParam);
|
295 |
-
setSelectedEpisode(episodeParam);
|
296 |
-
} else {
|
297 |
-
selectFirstAvailableEpisode(processedSeasons);
|
298 |
-
}
|
299 |
-
}
|
300 |
-
} catch (error) {
|
301 |
-
console.error(`Error fetching metadata for ${title}:`, error);
|
302 |
-
setError('Failed to load episode data');
|
303 |
-
toast({
|
304 |
-
title: "Error Loading Data",
|
305 |
-
description: "Please try again later",
|
306 |
-
variant: "destructive"
|
307 |
-
});
|
308 |
-
} finally {
|
309 |
-
setLoading(false);
|
310 |
-
}
|
311 |
-
};
|
312 |
-
|
313 |
-
fetchData();
|
314 |
-
}, [title, seasonParam, episodeParam, toast]);
|
315 |
-
|
316 |
-
const handleBack = () => {
|
317 |
-
navigate(`/tv-show/${encodeURIComponent(title || '')}`);
|
318 |
-
};
|
319 |
-
|
320 |
-
const getEpisodeNumber = () => {
|
321 |
-
if (!selectedEpisode) return "1";
|
322 |
-
|
323 |
-
const episodeMatch = selectedEpisode.match(/E(\d+)/i);
|
324 |
-
return episodeMatch ? episodeMatch[1] : "1";
|
325 |
-
};
|
326 |
-
|
327 |
-
const handleSelectEpisode = (seasonName: string, episode: Episode) => {
|
328 |
-
// Only reload if we're changing episodes
|
329 |
-
if (selectedEpisode !== episode.fileName) {
|
330 |
-
setSelectedSeason(seasonName);
|
331 |
-
setSelectedEpisode(episode.fileName || '');
|
332 |
-
setNeedsReload(true);
|
333 |
-
|
334 |
-
// Update URL
|
335 |
-
navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(seasonName)}&episode=${encodeURIComponent(episode.fileName || '')}`, { replace: true });
|
336 |
-
}
|
337 |
-
|
338 |
-
setShowEpisodeSelector(false);
|
339 |
-
};
|
340 |
-
|
341 |
-
const handleProgressUpdate = (currentTime: number, duration: number) => {
|
342 |
-
if (!selectedEpisode || !title) return;
|
343 |
-
|
344 |
-
const episodeId = `${selectedSeason}-${selectedEpisode}`;
|
345 |
-
const isCompleted = (currentTime / duration) > 0.9; // Mark as completed if watched 90%
|
346 |
-
|
347 |
-
savePlaybackProgress(episodeId, currentTime, duration, isCompleted);
|
348 |
-
};
|
349 |
-
|
350 |
-
const getStartTime = () => {
|
351 |
-
if (!selectedSeason || !selectedEpisode) return 0;
|
352 |
-
|
353 |
-
const episodeId = `${selectedSeason}-${selectedEpisode}`;
|
354 |
-
const progress = playbackProgress[episodeId];
|
355 |
-
|
356 |
-
if (progress && !progress.completed) {
|
357 |
-
return progress.currentTime;
|
358 |
-
}
|
359 |
-
return 0;
|
360 |
-
};
|
361 |
-
|
362 |
-
const episodeTitle = showInfo
|
363 |
-
? `${showInfo.data?.name} ${selectedSeason}E${getEpisodeNumber()}`
|
364 |
-
: `Episode`;
|
365 |
-
// Reset needs reload flag when video has been updated
|
366 |
-
useEffect(() => {
|
367 |
-
if (needsReload) {
|
368 |
-
setNeedsReload(false);
|
369 |
-
}
|
370 |
-
}, [selectedEpisode, selectedSeason]);
|
371 |
-
|
372 |
-
if (loading) {
|
373 |
-
return (
|
374 |
-
<div className="flex items-center justify-center min-h-screen bg-black">
|
375 |
-
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-red-600"></div>
|
376 |
-
</div>
|
377 |
-
);
|
378 |
-
}
|
379 |
-
|
380 |
-
// To force reload of video component when changing episodes
|
381 |
-
const tvShowPlayerKey = `${selectedSeason}-${selectedEpisode}-${needsReload ? 'reload' : 'loaded'}`;
|
382 |
-
|
383 |
-
return (
|
384 |
-
<div className="min-h-screen bg-black relative overflow-hidden">
|
385 |
-
{/* Episodes panel */}
|
386 |
-
{showEpisodeSelector && (
|
387 |
-
<div className="fixed inset-0 z-40 bg-black/80" onClick={() => setShowEpisodeSelector(false)}>
|
388 |
-
<div
|
389 |
-
className="fixed right-0 top-0 bottom-0 w-full md:w-1/3 lg:w-1/4 bg-gray-900 z-50 overflow-y-auto"
|
390 |
-
onClick={e => e.stopPropagation()}
|
391 |
-
>
|
392 |
-
<EpisodesPanel
|
393 |
-
seasons={seasons}
|
394 |
-
selectedSeason={selectedSeason}
|
395 |
-
selectedEpisode={selectedEpisode}
|
396 |
-
playbackProgress={playbackProgress}
|
397 |
-
onSelectEpisode={handleSelectEpisode}
|
398 |
-
onClose={() => setShowEpisodeSelector(false)}
|
399 |
-
showTitle={showName || 'Episodes'}
|
400 |
-
/>
|
401 |
-
</div>
|
402 |
-
</div>
|
403 |
-
)}
|
404 |
-
|
405 |
-
{/* TV Show Player component with key to force reload */}
|
406 |
-
<TVShowPlayer
|
407 |
-
key={tvShowPlayerKey}
|
408 |
-
videoTitle={title || ''}
|
409 |
-
season={selectedSeason}
|
410 |
-
episode={selectedEpisode}
|
411 |
-
movieTitle={title || ''}
|
412 |
-
contentRatings={showInfo?.data?.contentRatings || []}
|
413 |
-
startTime={getStartTime()}
|
414 |
-
onClosePlayer={handleBack}
|
415 |
-
onProgressUpdate={handleProgressUpdate}
|
416 |
-
onVideoEnded={loadNextEpisode}
|
417 |
-
onShowEpisodes={() => setShowEpisodeSelector(true)}
|
418 |
-
/>
|
419 |
-
</div>
|
420 |
-
);
|
421 |
-
};
|
422 |
-
|
423 |
-
export default TvShowPlayerPage;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|