Chandima Prabhath commited on
Commit
22b1735
·
1 Parent(s): beeb302
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. frontend/.gitignore +1 -0
  2. frontend/README.md +4 -4
  3. frontend/bun.lockb +2 -2
  4. frontend/index.html +2 -2
  5. frontend/netlify.toml +0 -6
  6. frontend/package-lock.json +21 -53
  7. frontend/package.json +7 -2
  8. frontend/src/App.css +11 -0
  9. frontend/src/App.tsx +41 -38
  10. frontend/src/components/ContentCard.tsx +0 -435
  11. frontend/src/components/ContentGrid.tsx +0 -45
  12. frontend/src/components/ContentRow.tsx +0 -107
  13. frontend/src/components/EpisodesPanel.tsx +0 -117
  14. frontend/src/components/Footer.tsx +0 -58
  15. frontend/src/components/HeroSection.tsx +0 -225
  16. frontend/src/components/MoviePlayer.tsx +0 -284
  17. frontend/src/components/Navbar.tsx +0 -157
  18. frontend/src/components/PageHeader.tsx +0 -18
  19. frontend/src/components/TVShowPlayer.tsx +0 -314
  20. frontend/src/components/VideoPlayer.tsx +0 -582
  21. frontend/src/components/VideoPlayerControls.tsx +0 -34
  22. frontend/src/components/WatchTogether.tsx +0 -314
  23. frontend/src/components/chat/ChatBubble.tsx +121 -0
  24. frontend/src/components/chat/ChatMessage.tsx +72 -0
  25. frontend/src/components/layout/MainLayout.tsx +24 -0
  26. frontend/src/components/layout/ModeToggle.tsx +38 -0
  27. frontend/src/components/ui/avatar.tsx +1 -0
  28. frontend/src/components/ui/sonner.tsx +2 -2
  29. frontend/src/index.css +264 -85
  30. frontend/src/lib/LoadBalancerAPI.js +0 -181
  31. frontend/src/lib/api.js +0 -159
  32. frontend/src/lib/remarkSource.ts +21 -0
  33. frontend/src/lib/search-api.ts +0 -64
  34. frontend/src/lib/storage.ts +111 -223
  35. frontend/src/lib/utils.ts +0 -10
  36. frontend/src/main.tsx +3 -4
  37. frontend/src/pages/HomePage.tsx +713 -156
  38. frontend/src/pages/Index.tsx +3 -12
  39. frontend/src/pages/MainLayout.tsx +0 -18
  40. frontend/src/pages/MovieDetailPage.tsx +0 -226
  41. frontend/src/pages/MoviePlayerPage.tsx +0 -66
  42. frontend/src/pages/MoviesPage.tsx +0 -92
  43. frontend/src/pages/MyListPage.tsx +0 -168
  44. frontend/src/pages/NotFound.tsx +29 -8
  45. frontend/src/pages/ProfilePage.tsx +0 -296
  46. frontend/src/pages/SearchPage.tsx +0 -199
  47. frontend/src/pages/SettingsPage.tsx +272 -0
  48. frontend/src/pages/SourcesPage.tsx +286 -0
  49. frontend/src/pages/TvShowDetailPage.tsx +0 -469
  50. 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/b1d069d7-7ee0-4ec6-854e-a62a24d11834
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/b1d069d7-7ee0-4ec6-854e-a62a24d11834) and start prompting.
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/b1d069d7-7ee0-4ec6-854e-a62a24d11834) and click on Share -> Publish.
66
 
67
  ## Can I connect a custom domain to my Lovable project?
68
 
69
- Yes it is!
70
 
71
  To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
72
 
 
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:b55cef577ab4a57c26cbf146d3ba017d6f2c4c23119a968772d52f013c37f119
3
- size 200043
 
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>streamwave-vista-project</title>
7
  <meta name="description" content="Lovable Generated Project" />
8
  <meta name="author" content="Lovable" />
9
 
10
- <meta property="og:title" content="streamwave-vista-project" />
11
  <meta property="og:description" content="Lovable Generated Project" />
12
  <meta property="og:type" content="website" />
13
  <meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
 
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 React from 'react';
3
- import { Routes, Route, BrowserRouter } from 'react-router-dom';
4
- import Index from './pages/Index';
5
- import MainLayout from './pages/MainLayout';
6
- import HomePage from './pages/HomePage';
7
- import MoviesPage from './pages/MoviesPage';
8
- import TvShowsPage from './pages/TvShowsPage';
9
- import SearchPage from './pages/SearchPage';
10
- import MovieDetailPage from './pages/MovieDetailPage';
11
- import TvShowDetailPage from './pages/TvShowDetailPage';
12
- import MoviePlayerPage from './pages/MoviePlayerPage';
13
- import TvShowPlayerPage from './pages/TvShowPlayerPage';
14
- import ProfilePage from './pages/ProfilePage';
15
- import MyListPage from './pages/MyListPage';
16
- import NotFound from './pages/NotFound';
17
 
18
- function App() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  return (
20
- <BrowserRouter>
21
- <Routes>
22
- <Route path="/" element={<Index />} />
23
-
24
- <Route path="/" element={<MainLayout />}>
25
- <Route path="/home" element={<HomePage />} />
26
- <Route path="/movies" element={<MoviesPage />} />
27
- <Route path="/tv-shows" element={<TvShowsPage />} />
28
- <Route path="/search" element={<SearchPage />} />
29
- <Route path="/movie/:title" element={<MovieDetailPage />} />
30
- <Route path="/tv-show/:title" element={<TvShowDetailPage />} />
31
- <Route path="/profile" element={<ProfilePage />} />
32
- <Route path="/my-list" element={<MyListPage />} />
33
- <Route path="*" element={<NotFound />} />
34
- </Route>
35
-
36
- {/* Full-Screen Pages */}
37
- <Route path="/movie/:title/watch" element={<MoviePlayerPage />} />
38
- <Route path="/tv-show/:title/watch" element={<TvShowPlayerPage />} />
39
- </Routes>
40
- </BrowserRouter>
41
  );
42
- }
43
 
44
  export default App;
 
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
- /* Base shadcn colors */
9
- --background: 0 0% 9%;
10
- --foreground: 0 0% 98%;
11
-
12
- --card: 0 0% 9%;
13
- --card-foreground: 0 0% 98%;
14
-
15
- --popover: 0 0% 9%;
16
- --popover-foreground: 0 0% 98%;
17
-
18
- --primary: 0 100% 48%;
19
- --primary-foreground: 0 0% 98%;
20
-
21
- --secondary: 0 0% 43%;
22
- --secondary-foreground: 0 0% 98%;
23
-
24
- --muted: 0 0% 15%;
25
- --muted-foreground: 0 0% 65%;
26
-
27
- --accent: 0 0% 15%;
28
- --accent-foreground: 0 0% 98%;
29
-
30
  --destructive: 0 84% 60%;
31
- --destructive-foreground: 0 0% 98%;
32
-
33
- --border: 0 0% 19%;
34
- --input: 0 0% 19%;
35
- --ring: 0 0% 83%;
36
-
37
- --radius: 0.5rem;
38
-
39
- /* Netflix-inspired theme */
40
- --theme-primary: #E50914;
41
- --theme-primary-hover: #B81D24;
42
- --theme-primary-light: #F5222D;
43
- --theme-secondary: #6D6D6D;
44
- --theme-background: #141414;
45
- --theme-background-dark: #0A0A0A;
46
- --theme-background-light: #181818;
47
- --theme-surface: #222222;
48
- --theme-text: #FFFFFF;
49
- --theme-text-secondary: #B3B3B3;
50
- --theme-border: #303030;
51
- --theme-divider: #2D2D2D;
52
- --theme-error: #FF574D;
53
- --theme-warning: #FFB01F;
54
- --theme-success: #48BB78;
55
- --theme-info: #38B2AC;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
  }
58
 
@@ -60,61 +82,218 @@
60
  * {
61
  @apply border-border;
62
  }
 
63
  body {
64
- @apply bg-background text-foreground;
65
- font-feature-settings: "rlig" 1, "calt" 1;
 
 
 
66
  }
67
- }
68
 
69
- @layer components {
70
- .content-container {
71
- @apply px-4 md:px-8 max-w-7xl mx-auto;
72
  }
73
 
74
- .section-padding {
75
- @apply py-6 md:py-12;
 
 
76
  }
77
 
78
- .btn-primary {
79
- @apply bg-theme-primary hover:bg-theme-primary-hover text-white py-2 px-4 rounded transition-colors duration-200;
80
  }
81
 
82
- .btn-secondary {
83
- @apply bg-theme-secondary hover:bg-theme-secondary/80 text-white py-2 px-4 rounded transition-colors duration-200;
 
84
  }
 
85
 
86
- .btn-outline {
87
- @apply border border-theme-border bg-transparent hover:bg-theme-surface text-white py-2 px-4 rounded transition-colors duration-200;
88
- }
 
89
 
90
- .card-hover {
91
- @apply transition-all duration-200 hover:scale-105 hover:z-10;
92
- }
 
 
93
 
94
- .card-surface {
95
- @apply bg-theme-surface rounded-md overflow-hidden shadow-md;
96
- }
 
97
 
98
- .glass {
99
- @apply bg-theme-background-light/30 backdrop-blur-md border border-white/10;
100
- }
101
 
102
- .video-card {
103
- @apply overflow-hidden rounded-md relative;
104
- }
105
 
106
- .video-card-overlay {
107
- @apply absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-0 transition-opacity hover:opacity-100;
108
- }
 
109
 
110
- .text-truncate {
111
- @apply overflow-hidden text-ellipsis whitespace-nowrap;
112
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
- .text-truncate-2 {
115
- @apply overflow-hidden;
116
- display: -webkit-box;
117
- -webkit-line-clamp: 2;
118
- -webkit-box-orient: vertical;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- * Save video watch progress
 
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
- * Get video watch progress
56
- */
57
- export const getVideoProgress = (
58
- type: 'movie' | 'tvshow',
59
- title: string,
60
- seasonEpisode: string | null
61
- ): WatchProgress | null => {
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
- * Get watch history
80
- */
81
- export const getWatchHistory = (limit: number = 0): Array<any> => {
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
- * Add item to My List
 
113
  */
114
- export const addToMyList = (item: MyListItem): void => {
115
- try {
116
- // Get existing my list
117
- const myListStr = localStorage.getItem('my-list') || '[]';
118
- const myList = JSON.parse(myListStr);
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
- // Save back to localStorage
133
- localStorage.setItem('my-list', JSON.stringify(myList));
 
 
 
 
 
 
 
 
 
 
 
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
- * Check if item is in My List
163
- */
164
- export const isInMyList = (title: string, type: 'movie' | 'tvshow'): boolean => {
165
- try {
166
- // Get existing my list
167
- const myListStr = localStorage.getItem('my-list') || '[]';
168
- const myList = JSON.parse(myListStr);
169
-
170
- // Check if item exists
171
- return myList.some((item: MyListItem) =>
172
- item.title === title && item.type === type
173
- );
174
- } catch (error) {
175
- console.error('Failed to check if item is in My List:', error);
176
- return false;
177
  }
178
- };
179
 
180
- /**
181
- * Get all items from My List
182
- */
183
- export const getAllFromMyList = (): Array<MyListItem> => {
184
- try {
185
- // Get existing my list
186
- const myListStr = localStorage.getItem('my-list') || '[]';
187
- return JSON.parse(myListStr);
188
- } catch (error) {
189
- console.error('Failed to get My List:', error);
190
- return [];
191
  }
192
- };
193
 
194
- /**
195
- * Clear My List
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
- // Example of a function that could be replaced with a database implementation
206
- // This can be swapped out with a DB implementation later
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
- localStorage.setItem(key, JSON.stringify(value));
225
- } catch (error) {
226
- console.error(`Failed to store ${key}:`, error);
227
- }
228
- },
229
-
230
- getItem: <T>(key: string, defaultValue: T): T => {
231
- try {
232
- const item = localStorage.getItem(key);
233
- return item ? JSON.parse(item) : defaultValue;
234
  } catch (error) {
235
- console.error(`Failed to retrieve ${key}:`, error);
236
- return defaultValue;
237
  }
238
- },
239
-
240
- removeItem: (key: string): void => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  try {
242
- localStorage.removeItem(key);
 
 
 
 
 
243
  } catch (error) {
244
- console.error(`Failed to remove ${key}:`, error);
 
245
  }
246
  }
247
- };
248
 
249
- export default storageService;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { createRoot } from 'react-dom/client';
3
- import App from './App.tsx';
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
- useEffect(() => {
14
- const fetchGenreData = async () => {
15
- try {
16
- // Fetch titles for the given genre and type
17
- const genreItems = await getGenresItems([genre], type, 10, 1);
18
- const titles =
19
- type === "movie"
20
- ? (genreItems && Array.isArray(genreItems.movies)
21
- ? genreItems.movies.map(item => item.title)
22
- : [])
23
- : (genreItems && Array.isArray(genreItems.series)
24
- ? genreItems.series.map(item => item.title)
25
- : []);
26
- if (titles.length === 0) {
27
- setLoading(false);
28
- return;
29
- }
30
 
31
- // For each title, fetch the card details
32
- const fetchCard = async (title) => {
33
- try {
34
- if (type === "movie") {
35
- const movieInfo = await getMovieCard(title);
36
- if (movieInfo) {
37
- return {
38
- type: 'movie',
39
- title,
40
- image: movieInfo.image,
41
- description: movieInfo.overview,
42
- genre: movieInfo.genres?.map((g) => g.name) || [],
43
- year: movieInfo.year
44
- };
45
- }
46
- } else {
47
- const showInfo = await getTvShowCard(title);
48
- if (showInfo) {
49
- return {
50
- type: 'tvshow',
51
- title,
52
- image: showInfo.image,
53
- description: showInfo.overview,
54
- genre: showInfo.genres?.map((g) => g.name) || [],
55
- year: showInfo.year
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
- const cardPromises = titles.map((title) => fetchCard(title));
67
- const cards = await Promise.all(cardPromises);
68
- setItems(cards.filter(item => item !== null));
 
 
 
 
 
 
 
 
 
 
 
 
69
  } catch (error) {
70
- console.error(`Error fetching ${type} items for genre ${genre}:`, error);
71
- toast({
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
- fetchGenreData();
82
- }, [genre, type, toast]);
83
 
84
- // While data is being fetched, show a simple loader in place of the row
85
- if (loading) {
86
- return (
87
- <div className="px-4 md:px-8 py-8">
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
- // If no items were found, render nothing
97
- if (items.length === 0) return null;
 
98
 
99
- return (
100
- <ContentRow title={`${genre} ${type === "movie" ? "Movies" : "Shows"}`} items={items} />
101
- );
102
- };
 
103
 
104
- const HomePage = () => {
105
- const [loading, setLoading] = useState(true);
106
- const [heroContent, setHeroContent] = useState(null);
107
- const [recentContent, setRecentContent] = useState([]);
108
- const [genres, setGenres] = useState([]);
109
- const { toast } = useToast();
 
 
 
 
 
 
 
 
 
 
110
 
111
  useEffect(() => {
112
- const fetchHomeData = async () => {
113
- try {
114
- setLoading(true);
 
 
 
 
 
 
 
 
115
 
116
- // Fetch recent items for hero and recent content row
117
- const recentItems = await getRecentItems(10);
118
- if (recentItems && recentItems.length > 0) {
119
- setHeroContent(recentItems[0]);
120
- setRecentContent(recentItems);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  }
 
 
122
 
123
- // Fetch all genre categories (movies and shows together)
124
- const genresRes = await getGenreCategories();
125
- console.log("Fetched genres:", genresRes);
126
-
127
- const allGenres = Array.isArray(genresRes)
128
- ? genresRes.map((g) => g.name)
129
- : genresRes.genres
130
- ? genresRes.genres.map((g) => g.name)
131
- : [];
132
- console.log("All genres:", allGenres);
133
- setGenres(allGenres);
134
- } catch (error) {
135
- console.error("Error fetching home page data:", error);
136
- toast({
137
- title: "Error loading content",
138
- description: "Please try again later",
139
- variant: "destructive"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  });
141
- } finally {
142
- setLoading(false);
143
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  };
145
 
146
- fetchHomeData();
147
- }, [toast]);
 
 
 
 
 
148
 
149
- if (loading) {
150
- return (
151
- <div className="flex items-center justify-center min-h-screen">
152
- <div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-netflix-red"></div>
153
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  );
155
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  return (
158
- <div>
159
- {/* Hero Section */}
160
- {heroContent && (
161
- <HeroSection
162
- type={heroContent.type}
163
- title={heroContent.title}
164
- description={heroContent.description}
165
- backdrop={heroContent.image}
166
- genre={heroContent.genre}
167
- year={heroContent.year}
168
- />
169
- )}
170
-
171
- <div className="mt-8">
172
- {/* Recent Content Row */}
173
- <ContentRow title="Recent Additions" items={recentContent} />
174
-
175
- {/* Render genre rows dynamically */}
176
- {genres.map((genre) => (
177
- <div key={genre}>
178
- <GenreRow genre={genre} type="movie" />
179
- <GenreRow genre={genre} type="series" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- import Navbar from '../components/Navbar';
2
- import Footer from '../components/Footer';
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 React from 'react';
3
- import { Link } from 'react-router-dom';
 
 
 
4
 
5
  const NotFound = () => {
 
 
 
 
 
 
 
 
 
6
  return (
7
- <div className="flex flex-col items-center justify-center min-h-screen bg-netflix-black">
8
- <h1 className="text-5xl font-bold mb-4">404</h1>
9
- <p className="text-netflix-gray text-xl mb-8">This page could not be found.</p>
10
- <Link to="/" className="bg-netflix-red px-6 py-2 rounded hover:bg-red-700 transition">
11
- Back to Home
12
- </Link>
 
 
 
 
 
 
 
 
 
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;