diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36d8d11a4f89c59c144f24795749086dd1..603688174f663c9a402b83a6561d022039118366 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +yarn.lock diff --git a/frontend/README.md b/frontend/README.md index de83b8d08e5d42d65ab0671b678f07bf9a80b3e2..12003b94735d54154e609ec4386e2651b8a842b7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -2,7 +2,7 @@ ## Project info -**URL**: https://lovable.dev/projects/b1d069d7-7ee0-4ec6-854e-a62a24d11834 +**URL**: https://lovable.dev/projects/00b88f68-b44c-4b47-af08-79dc9c075da6 ## How can I edit this code? @@ -10,7 +10,7 @@ There are several ways of editing your application. **Use Lovable** -Simply visit the [Lovable Project](https://lovable.dev/projects/b1d069d7-7ee0-4ec6-854e-a62a24d11834) and start prompting. +Simply visit the [Lovable Project](https://lovable.dev/projects/00b88f68-b44c-4b47-af08-79dc9c075da6) and start prompting. Changes made via Lovable will be committed automatically to this repo. @@ -62,11 +62,11 @@ This project is built with: ## How can I deploy this project? -Simply open [Lovable](https://lovable.dev/projects/b1d069d7-7ee0-4ec6-854e-a62a24d11834) and click on Share -> Publish. +Simply open [Lovable](https://lovable.dev/projects/00b88f68-b44c-4b47-af08-79dc9c075da6) and click on Share -> Publish. ## Can I connect a custom domain to my Lovable project? -Yes it is! +Yes, you can! To connect a domain, navigate to Project > Settings > Domains and click Connect Domain. diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 80bab84bbd2b6e6a904d1cf2312169c5973b4a72..373aab4180c693c200b1681d40bac4bb724b320d 100644 --- a/frontend/bun.lockb +++ b/frontend/bun.lockb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b55cef577ab4a57c26cbf146d3ba017d6f2c4c23119a968772d52f013c37f119 -size 200043 +oid sha256:a3c575fd4a99dc9d1d74a4a7a31b979d577c15f379bc5cb0dd7e823586e98c23 +size 198351 diff --git a/frontend/index.html b/frontend/index.html index e8a68d7b96ce4452653d3436d0317a855be50ed9..2735decb74b6249030c61028657b8d2c86ef48a0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,11 +3,11 @@ - streamwave-vista-project + ruling-insight-navigator - + diff --git a/frontend/netlify.toml b/frontend/netlify.toml deleted file mode 100644 index 8ed1b156324b870b14c02fdae3c20ebdbf4b566f..0000000000000000000000000000000000000000 --- a/frontend/netlify.toml +++ /dev/null @@ -1,6 +0,0 @@ -[build] -command = "npm run build" -publish = "dist" - -[dev] -command = "npm run dev" \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7ba2f121abb02359dcfe8bc8ce9b2299605d0703..a7cd7bb209c4ae4934deac98ae0681c58adc668b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "vite_react_shadcn_ts", "version": "0.0.0", "dependencies": { - "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^3.9.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", @@ -38,12 +37,12 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-query": "^5.56.2", + "@types/uuid": "^10.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", "embla-carousel-react": "^8.3.0", - "framer-motion": "^12.6.3", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -57,6 +56,7 @@ "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0", "vaul": "^0.9.3", "zod": "^3.23.8" }, @@ -751,15 +751,6 @@ "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", "license": "MIT" }, - "node_modules/@heroicons/react": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", - "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16 || ^19.0.0-rc" - } - }, "node_modules/@hookform/resolvers": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz", @@ -2959,6 +2950,12 @@ "@types/react": "*" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", @@ -4658,33 +4655,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/framer-motion": { - "version": "12.6.3", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.3.tgz", - "integrity": "sha512-2hsqknz23aloK85bzMc9nSR2/JP+fValQ459ZTVElFQ0xgwR2YqNjYSuDZdFBPOwVCt4Q9jgyTt6hg6sVOALzw==", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.6.3", - "motion-utils": "^12.6.3", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5651,21 +5621,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/motion-dom": { - "version": "12.6.3", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.6.3.tgz", - "integrity": "sha512-gRY08RjcnzgFYLemUZ1lo/e9RkBxR+6d4BRvoeZDSeArG4XQXERSPapKl3LNQRu22Sndjf1h+iavgY0O4NrYqA==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.6.3" - } - }, - "node_modules/motion-utils": { - "version": "12.6.3", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.6.3.tgz", - "integrity": "sha512-R/b3Ia2VxtTNZ4LTEO5pKYau1OUNHOuUfxuP0WFCTDYdHkeTBR9UtxR1cc8mDmKr8PEhmmfnTKGz3rSMjNRoRg==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7018,6 +6973,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vaul": { "version": "0.9.9", "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9c2004b86d1c9972410dfa54bea2d613928f1fb2..c639b4716b22d2df7fa68c57437d8708abb35b87 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,6 @@ "preview": "vite preview" }, "dependencies": { - "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^3.9.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", @@ -41,12 +40,12 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-query": "^5.56.2", + "@types/uuid": "^10.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", "embla-carousel-react": "^8.3.0", - "framer-motion": "^12.6.3", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", @@ -54,12 +53,18 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", + "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.3", "react-router-dom": "^6.26.2", "recharts": "^2.12.7", + "rehype-raw": "^7.0.0", + "remark-directive": "^4.0.0", + "remark-gfm": "^4.0.1", "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "unist-util-visit": "^5.0.0", + "uuid": "^11.1.0", "vaul": "^0.9.3", "zod": "^3.23.8" }, diff --git a/frontend/src/App.css b/frontend/src/App.css index aba52b00af6d2006367d7069c115916781dd0170..b9d355df2a5956b526c004531b7b0ffe412461e0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,10 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + .logo { height: 6em; padding: 1.5em; @@ -26,6 +33,10 @@ } } +.card { + padding: 2em; +} + .read-the-docs { color: #888; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9d709e4a376f0000a5f4d1b3438998597959224..17065b56029791d40beac46fc81734b2cb51539c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,44 +1,47 @@ -import React from 'react'; -import { Routes, Route, BrowserRouter } from 'react-router-dom'; -import Index from './pages/Index'; -import MainLayout from './pages/MainLayout'; -import HomePage from './pages/HomePage'; -import MoviesPage from './pages/MoviesPage'; -import TvShowsPage from './pages/TvShowsPage'; -import SearchPage from './pages/SearchPage'; -import MovieDetailPage from './pages/MovieDetailPage'; -import TvShowDetailPage from './pages/TvShowDetailPage'; -import MoviePlayerPage from './pages/MoviePlayerPage'; -import TvShowPlayerPage from './pages/TvShowPlayerPage'; -import ProfilePage from './pages/ProfilePage'; -import MyListPage from './pages/MyListPage'; -import NotFound from './pages/NotFound'; +import { Toaster } from "@/components/ui/toaster"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { useEffect } from "react"; +import MainLayout from "@/components/layout/MainLayout"; +import HomePage from "@/pages/HomePage"; +import SourcesPage from "@/pages/SourcesPage"; +import SettingsPage from "@/pages/SettingsPage"; +import NotFound from "@/pages/NotFound"; -function App() { +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 1, + }, + }, +}); + +const App = () => { + // Set the window title + useEffect(() => { + document.title = "Financial Insight System (FIS)"; + }, []); + return ( - - - } /> - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Full-Screen Pages */} - } /> - } /> - - + + + + + + + } /> + } /> + } /> + } /> + + + + ); -} +}; export default App; diff --git a/frontend/src/components/ContentCard.tsx b/frontend/src/components/ContentCard.tsx deleted file mode 100644 index b2bbdca51ba5f2b5f2e67aeff55dd37ffc87d396..0000000000000000000000000000000000000000 --- a/frontend/src/components/ContentCard.tsx +++ /dev/null @@ -1,435 +0,0 @@ - -import React, { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { Play, Info, Plus, Check, Clock, Loader2 } from 'lucide-react'; -import { getMovieCard, getTvShowCard } from '../lib/api'; -import { isInMyList, addToMyList, removeFromMyList } from '../lib/storage'; -import { useToast } from '@/hooks/use-toast'; - -// -- Common Trailer type -- -export interface Trailer { - id: number; - name: string; - url: string; - language: string; - runtime: number; -} - -// -- TV Show types -- -export interface TvShowPortrait { - id: number; - image: string; - thumbnail: string; - language: string; - type: number; - score: number; - width: number; - height: number; - includesText: boolean; - thumbnailWidth: number; - thumbnailHeight: number; - updatedAt: number; - status: { - id: number; - name: string | null; - }; - tagOptions: any; -} - -export interface TvShowBanner { - id: number; - image: string; - thumbnail: string; - language: string; - type: number; - score: number; - width: number; - height: number; - includesText: boolean; - thumbnailWidth: number; - thumbnailHeight: number; - updatedAt: number; - status: { - id: number; - name: string | null; - }; - tagOptions: any; -} - -export interface TvShowCardData { - title: string; - year: string; - image: string; - portrait: TvShowPortrait[]; - banner: TvShowBanner[]; - overview: string; - trailers: Trailer[]; - genres?: { name: string }[]; -} - -// -- Movie types -- -export interface MoviePortrait { - id: number; - image: string; - thumbnail: string; - language: string; - type: number; - score: number; - width: number; - height: number; - includesText: boolean; -} - -export interface MovieBanner { - id: number; - image: string; - thumbnail: string; - language: string | null; - type: number; - score: number; - width: number; - height: number; - includesText: boolean; -} - -export interface MovieCardData { - title: string; - year: string; - image: string; - portrait: MoviePortrait[]; - banner: MovieBanner[]; - overview: string; - trailers: Trailer[]; - genres?: { name: string }[]; -} - -interface ContentCardProps { - type: 'movie' | 'tvshow'; - title: string; - image?: string; - description?: string; - genre?: string[]; - year?: number | string; - prefetchData?: boolean; -} - -interface PlaybackProgress { - currentTime: number; - duration: number; - lastPlayed: string; - completed: boolean; -} - -const ContentCard: React.FC = ({ - type, - title, - image, - description: initialDescription, - genre: initialGenre, - year: initialYear, - prefetchData = true -}) => { - const [isHovered, setIsHovered] = useState(false); - const [progress, setProgress] = useState<{ percent: number, completed: boolean } | null>(null); - const [loading, setLoading] = useState(prefetchData); - const [cardData, setCardData] = useState(null); - const [inMyList, setInMyList] = useState(false); - const [addingToList, setAddingToList] = useState(false); - const [selectedImage, setSelectedImage] = useState(null); - const { toast } = useToast(); - - const fallbackImage = '/placeholder.svg'; - const path = type === 'movie' ? `/movie/${encodeURIComponent(title)}` : `/tv-show/${encodeURIComponent(title)}`; - - // Derived data with fallbacks - const description = cardData?.overview || initialDescription || ''; - const genre = (cardData?.genres?.map((g: any) => g.name) || initialGenre || []); - const year = cardData?.year || initialYear || ''; - - // Function to randomly select an image from available banners or portraits - const selectRandomImage = (cardData: MovieCardData | TvShowCardData | null) => { - if (!cardData) return null; - - // First try to get banner images (landscape) - if (cardData.banner && cardData.banner.length > 0) { - const randomIndex = Math.floor(Math.random() * cardData.banner.length); - return cardData.banner[randomIndex].image; - } - - // Fall back to portrait images if no banners - if (cardData.portrait && cardData.portrait.length > 0) { - const randomIndex = Math.floor(Math.random() * cardData.portrait.length); - return cardData.portrait[randomIndex].image; - } - - // Finally fall back to the default image - return cardData.image || image || fallbackImage; - }; - - // Check if item is in user's list - useEffect(() => { - const checkMyList = async () => { - const isInList = await isInMyList(title, type); - setInMyList(isInList); - }; - - checkMyList(); - }, [title, type]); - - // Toggle my list status - const toggleMyList = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - setAddingToList(true); - - try { - if (inMyList) { - await removeFromMyList(title, type); - setInMyList(false); - toast({ - title: "Removed from My List", - description: `${title} has been removed from your list` - }); - } else { - await addToMyList({ - type, - title, - addedAt: new Date().toISOString() - }); - setInMyList(true); - toast({ - title: "Added to My List", - description: `${title} has been added to your list` - }); - } - } catch (error) { - console.error('Error updating My List:', error); - toast({ - title: "Error", - description: "Failed to update your list", - variant: "destructive" - }); - } finally { - setAddingToList(false); - } - }; - - // Load content data - useEffect(() => { - if (!prefetchData) { - setLoading(false); - return; - } - - const fetchData = async () => { - try { - setLoading(true); - - let data; - if (type === 'movie') { - data = await getMovieCard(title); - } else { - data = await getTvShowCard(title); - // TV show data is nested in a data property - data = data?.data || data; - } - - if (data) { - setCardData(data); - const randomImage = selectRandomImage(data); - setSelectedImage(randomImage); - } - } catch (error) { - console.error(`Error fetching ${type} data:`, error); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [type, title, prefetchData, image]); - - // Load playback progress on mount - useEffect(() => { - try { - const progressKey = type === 'movie' ? `movie-progress-${title}` : `playback-${title}`; - const storedProgress = localStorage.getItem(progressKey); - - if (storedProgress) { - let maxProgress = 0; - let isCompleted = false; - - if (type === 'movie') { - const progressData = JSON.parse(storedProgress); - maxProgress = Math.min(100, Math.floor((progressData.currentTime / progressData.duration) * 100)); - isCompleted = progressData.completed; - } - // For TV shows, find the latest episode with progress - else { - const progressData = JSON.parse(storedProgress); - let latestPlaybackTime = 0; - - Object.values(progressData).forEach((item: PlaybackProgress) => { - if (new Date(item.lastPlayed).getTime() > latestPlaybackTime) { - latestPlaybackTime = new Date(item.lastPlayed).getTime(); - maxProgress = Math.min(100, Math.floor((item.currentTime / item.duration) * 100)); - isCompleted = item.completed; - } - }); - } - - if (maxProgress > 0 || isCompleted) { - setProgress({ percent: maxProgress, completed: isCompleted }); - } - } - } catch (error) { - console.error("Failed to load playback progress:", error); - } - }, [title, type]); - - const displayImage = selectedImage || image || fallbackImage; - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
- {/* Base card image */} - - {loading ? ( -
- -
- ) : ( - {title} { - const target = e.target as HTMLImageElement; - target.src = fallbackImage; - }} - /> - )} - - - {/* Progress indicator */} - {progress && progress.percent > 0 && !progress.completed && ( -
-
-
- )} - - {/* Title overlay (simple version when not hovered) */} -
-
-

{title}

- {progress?.completed && ( -
- -
- )} -
-
-
- {year && {year}} - {genre && genre.length > 0 && • {genre[0]}} -
- {progress && !progress.completed && progress.percent > 0 && ( -
- - {progress.percent}% -
- )} -
-
- - {/* Expanded hover overlay with detailed info and buttons */} -
- {/* Top section - title and info */} -
-
-

{title}

- {progress?.completed && ( -
- -
- )} -
- -
- {year && {year}} - {genre && genre.length > 0 && • {genre[0]}} -
- - {description && ( -

{description}

- )} - - {progress && !progress.completed && progress.percent > 0 && ( -
-
-
-
-

{progress.percent}% watched

-
- )} -
- - {/* Bottom section - action buttons */} -
-
- - - - - {progress && progress.percent > 0 && !progress.completed ? "Resume" : "Play"} - - - - - -
-
-
-
-
- ); -}; - -export default ContentCard; diff --git a/frontend/src/components/ContentGrid.tsx b/frontend/src/components/ContentGrid.tsx deleted file mode 100644 index 025822878319813a8ca8f97a11bdbae8618a27b1..0000000000000000000000000000000000000000 --- a/frontend/src/components/ContentGrid.tsx +++ /dev/null @@ -1,45 +0,0 @@ - -import React from 'react'; -import ContentCard from './ContentCard'; - -export interface ContentItem { - type: 'movie' | 'tvshow'; - title: string; - image?: string; - description?: string; - genre?: string[]; - year?: number | string; -} - -interface ContentGridProps { - items: ContentItem[]; - emptyMessage?: string; -} - -const ContentGrid: React.FC = ({ items, emptyMessage = "No content available" }) => { - if (!items || items.length === 0) { - return ( -
-

{emptyMessage}

-
- ); - } - - return ( -
- {items.map((item, index) => ( - - ))} -
- ); -}; - -export default ContentGrid; diff --git a/frontend/src/components/ContentRow.tsx b/frontend/src/components/ContentRow.tsx deleted file mode 100644 index 1f059da3b6dd82897743e104a91d93292acb1613..0000000000000000000000000000000000000000 --- a/frontend/src/components/ContentRow.tsx +++ /dev/null @@ -1,107 +0,0 @@ - -import React, { useState, useRef, useEffect } from 'react'; -import ContentCard from './ContentCard'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; - -interface ContentItem { - type: 'movie' | 'tvshow'; - title: string; - image: string; - description?: string; - genre?: string[]; - year?: number | string; -} - -interface ContentRowProps { - title: string; - items: ContentItem[]; -} - -const ContentRow: React.FC = ({ title, items }) => { - const rowRef = useRef(null); - const [showLeftButton, setShowLeftButton] = useState(false); - const [showRightButton, setShowRightButton] = useState(true); - - // Handle scroll events to show/hide buttons - const handleScroll = () => { - if (rowRef.current) { - const { scrollLeft, scrollWidth, clientWidth } = rowRef.current; - setShowLeftButton(scrollLeft > 20); - setShowRightButton(scrollLeft < scrollWidth - clientWidth - 20); - } - }; - - // Set up scroll listeners and initial state - useEffect(() => { - handleScroll(); - window.addEventListener('resize', handleScroll); - return () => window.removeEventListener('resize', handleScroll); - }, [items]); - - const scroll = (direction: 'left' | 'right') => { - if (rowRef.current) { - const card = rowRef.current.querySelector('.card-hover'); - const cardWidth = card ? card.clientWidth + 16 : 280; // Card width + margin - const scrollAmount = direction === 'left' ? -cardWidth * 3 : cardWidth * 3; - rowRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }); - } - }; - - // Don't render the row if there are no items - if (items.length === 0) { - return null; - } - - return ( -
-

{title}

- -
- {/* Left scroll button */} - - - {/* Content row */} -
- {items.map((item, index) => ( -
- -
- ))} -
- - {/* Right scroll button */} - -
-
- ); -}; - -export default ContentRow; diff --git a/frontend/src/components/EpisodesPanel.tsx b/frontend/src/components/EpisodesPanel.tsx deleted file mode 100644 index 807f213ba6129fcff64665fdfcc8627a32da92b5..0000000000000000000000000000000000000000 --- a/frontend/src/components/EpisodesPanel.tsx +++ /dev/null @@ -1,117 +0,0 @@ - -import React from 'react'; -import { Check, Play, X } from 'lucide-react'; - -interface Episode { - episode_number: number; - name: string; - fileName?: string; -} - -interface Season { - season_number: number; - name: string; - episodes: Episode[]; -} - -interface PlaybackProgress { - [key: string]: { - currentTime: number; - duration: number; - lastPlayed: string; - completed: boolean; - }; -} - -interface EpisodesPanelProps { - seasons: Season[]; - selectedSeason: string; - selectedEpisode: string; - playbackProgress: PlaybackProgress; - onSelectEpisode: (seasonName: string, episode: Episode) => void; - onClose: () => void; - showTitle?: string; -} - -const EpisodesPanel: React.FC = ({ - seasons, - selectedSeason, - selectedEpisode, - playbackProgress, - onSelectEpisode, - onClose, - showTitle = 'Episodes' -}) => { - // Helper function to get episode progress - const getEpisodeProgress = (seasonName: string, episodeFileName: string) => { - const episodeId = `${seasonName}-${episodeFileName}`; - return playbackProgress[episodeId] || null; - }; - - return ( -
-
-

{showTitle}

- -
- -
- {seasons.map((season) => ( -
-

{season.name}

-
- {season.episodes.map((episode) => { - const progress = getEpisodeProgress(season.name, episode.fileName || ''); - const progressPercent = progress - ? Math.min(100, Math.floor((progress.currentTime / progress.duration) * 100)) - : 0; - - return ( -
onSelectEpisode(season.name, episode)} - > -
- {selectedEpisode === episode.fileName ? ( -
- -
- ) : ( -
- {episode.episode_number} -
- )} -
-
-
-

{episode.name}

- {progress?.completed && ( - - )} -
-
-
-
-
-
- ); - })} -
-
- ))} -
-
- ); -}; - -export default EpisodesPanel; diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx deleted file mode 100644 index 612151a75b792a2a3b561ba093ca0021ddfa4937..0000000000000000000000000000000000000000 --- a/frontend/src/components/Footer.tsx +++ /dev/null @@ -1,58 +0,0 @@ - -import React from 'react'; -import { Link } from 'react-router-dom'; - -const Footer: React.FC = () => { - return ( -
-
-
-
-

Navigation

-
    -
  • Home
  • -
  • Movies
  • -
  • TV Shows
  • -
  • My List
  • -
-
- -
-

Categories

-
    -
  • Action
  • -
  • Comedy
  • -
  • Drama
  • -
  • Reality
  • -
-
- -
-

About

-
    -
  • About Us
  • -
  • Contact
  • -
  • Terms of Use
  • -
  • Privacy Policy
  • -
-
- -
-

Connect

- -
-
-
-

© 2025 Nexora. All rights reserved.

-
-
-
- ); -}; - -export default Footer; diff --git a/frontend/src/components/HeroSection.tsx b/frontend/src/components/HeroSection.tsx deleted file mode 100644 index 4b585f70c03278a3eaf4baadf275cad649329864..0000000000000000000000000000000000000000 --- a/frontend/src/components/HeroSection.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { getRecentItems } from '../lib/api'; -import { useNavigate } from 'react-router-dom'; -import { motion, AnimatePresence } from 'framer-motion'; -import { Play, Info, ChevronLeft, ChevronRight } from 'lucide-react'; -import { Link } from 'react-router-dom'; - -interface SlideItem { - id: string; - type: 'movie' | 'tvshow'; - title: string; - description: string; - backdrop: string; - genre?: string[]; - year?: string | number; -} - -interface DynamicHeroSlideshowProps { - slides: SlideItem[]; - autoplaySpeed?: number; -} - -const DynamicHeroSlideshow: React.FC = ({ - slides, - autoplaySpeed = 6000 -}) => { - const [currentIndex, setCurrentIndex] = useState(0); - const [isAutoplay, setIsAutoplay] = useState(true); - - const navigate = useNavigate(); - - useEffect(() => { - if (!slides.length) return; - - let interval: NodeJS.Timeout | null = null; - - if (isAutoplay) { - interval = setInterval(() => { - setCurrentIndex((prevIndex) => (prevIndex + 1) % slides.length); - }, autoplaySpeed); - } - - return () => { - if (interval) clearInterval(interval); - }; - }, [slides, isAutoplay, autoplaySpeed]); - - const handleNext = () => { - setIsAutoplay(false); - setCurrentIndex((prevIndex) => (prevIndex + 1) % slides.length); - }; - - const handlePrev = () => { - setIsAutoplay(false); - setCurrentIndex((prevIndex) => (prevIndex - 1 + slides.length) % slides.length); - }; - - const handleDotClick = (index: number) => { - setIsAutoplay(false); - setCurrentIndex(index); - }; - - if (!slides.length) return null; - - const currentSlide = slides[currentIndex]; - const path = currentSlide.type === 'movie' - ? `/movie/${encodeURIComponent(currentSlide.title)}` - : `/tv-show/${encodeURIComponent(currentSlide.title)}`; - - return ( -
- {/* Backdrop Slideshow */} - - - {currentSlide.title} { - const target = e.target as HTMLImageElement; - target.src = '/placeholder.svg'; - }} - /> -
-
- - - - {/* Navigation arrows */} - - - - - {/* Content */} - - -
-

{currentSlide.title}

- -
- {currentSlide.year && {currentSlide.year}} - {currentSlide.genre && currentSlide.genre.length > 0 && ( - {currentSlide.genre.slice(0, 3).join(' • ')} - )} - {currentSlide.type} -
- -

- {currentSlide.description} -

- -
- - Play - - - More Info - -
-
-
-
- - {/* Dots navigation */} -
- {slides.map((_, index) => ( -
-
- ); -}; - -interface HeroSectionProps { - // These props are still available if you need to override the API data, - // but the API data will be used as the primary slides. - type?: 'movie' | 'tvshow'; - title?: string; - description?: string; - backdrop?: string; - genre?: string[]; - year?: string | number; -} - -const HeroSection: React.FC = (props) => { - const [slides, setSlides] = useState([]); - const [isLoaded, setIsLoaded] = useState(false); - - useEffect(() => { - const fetchSlides = async () => { - try { - // Fetch recent items from the API (change the limit as needed) - const recentItems = await getRecentItems(5); - // Map recent items to the slide format expected by DynamicHeroSlideshow - const formattedSlides = recentItems.map((item: any, index: number) => ({ - id: item.id || index.toString(), - type: item.type, - title: item.title, - description: item.description, - backdrop: item.image, // assuming the API returns "image" to be used as backdrop - genre: item.genre || [], - year: item.year, - })); - setSlides(formattedSlides); - } catch (error) { - console.error('Error fetching recent items:', error); - } finally { - setIsLoaded(true); - } - }; - - fetchSlides(); - }, []); - - if (!isLoaded) { - return ( -
- ); - } - - return ; -}; - -export default HeroSection; diff --git a/frontend/src/components/MoviePlayer.tsx b/frontend/src/components/MoviePlayer.tsx deleted file mode 100644 index 6311e1fc0cf92e81db8a619e3ab36962235f0ff8..0000000000000000000000000000000000000000 --- a/frontend/src/components/MoviePlayer.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { getMovieLinkByTitle, getMovieCard } from '../lib/api'; -import { useToast } from '@/hooks/use-toast'; -import VideoPlayer from './VideoPlayer'; -import VideoPlayerControls from './VideoPlayerControls'; -import { Loader2, Play } from 'lucide-react'; -import { MovieCardData } from './ContentCard'; - -interface ProgressData { - status: string; - progress: number; - downloaded: number; - total: number; -} - -interface MoviePlayerProps { - movieTitle: string; - videoUrl?: string; - contentRatings?: any[]; - poster?: string; - startTime?: number; - onClosePlayer?: () => void; - onProgressUpdate?: (currentTime: number, duration: number) => void; - onVideoEnded?: () => void; - showNextButton?: boolean; -} - -const MoviePlayer: React.FC = ({ - movieTitle, - videoUrl, - contentRatings, - poster, - startTime = 0, - onClosePlayer, - onProgressUpdate, - onVideoEnded, - showNextButton = false -}) => { - const [videoUrlState, setVideoUrlState] = useState(videoUrl || null); - const [loading, setLoading] = useState(!videoUrl); - const [error, setError] = useState(null); - const [progress, setProgress] = useState(null); - const [videoFetched, setVideoFetched] = useState(!!videoUrl); - const [cardData, setCardData] = useState(null); - const [selectedImage, setSelectedImage] = useState(null); - const [imageLoaded, setImageLoaded] = useState(false); - const { toast } = useToast(); - - const pollingIntervalRef = useRef(null); - const timeoutRef = useRef(null); - const videoFetchedRef = useRef(!!videoUrl); - const [ratingInfo, setRatingInfo] = useState<{ rating: string; description: string } | null>(null); - const [currentTime, setCurrentTime] = useState(startTime); - const containerRef = useRef(null); - const videoRef = useRef(null); - - // Reset fade state when image changes - useEffect(() => { - setImageLoaded(false); - }, [selectedImage]); - - // Update progress and propagate up - const handleProgressUpdate = (time: number, duration: number) => { - setCurrentTime(time); - onProgressUpdate?.(time, duration); - }; - - // Seek handler - const handleSeek = (time: number) => { - if (videoRef.current) { - videoRef.current.currentTime = time; - setCurrentTime(time); - } - }; - - // Random image selector - const selectRandomImage = (card: MovieCardData | null) => { - if (!card) return null; - if (card.banner && card.banner.length > 0) { - return card.banner[Math.floor(Math.random() * card.banner.length)].image; - } - if (card.portrait && card.portrait.length > 0) { - return card.portrait[Math.floor(Math.random() * card.portrait.length)].image; - } - return card.image; - }; - - // Fetch movie link or start polling - const fetchMovieLink = async () => { - if (videoFetchedRef.current || videoUrlState) return; - - try { - const response = await getMovieLinkByTitle(movieTitle); - if (response.url) { - pollingIntervalRef.current && clearInterval(pollingIntervalRef.current); - setVideoUrlState(response.url); - setVideoFetched(true); - videoFetchedRef.current = true; - setLoading(false); - } else if (response.progress_url) { - if (!pollingIntervalRef.current) { - pollingIntervalRef.current = setInterval(async () => { - try { - const res = await fetch(response.progress_url!); - const data = await res.json(); - setProgress(data.progress); - if (data.progress.progress >= 100) { - clearInterval(pollingIntervalRef.current!); - timeoutRef.current = setTimeout(fetchMovieLink, 5000); - } - } catch (e) { - console.error(e); - } - }, 2000); - } - } else { - throw new Error('No URL or progress URL'); - } - } catch (e) { - console.error('Error fetching movie link:', e); - setError('Failed to load video'); - toast({ title: 'Error', description: 'Could not load the video', variant: 'destructive' }); - setLoading(false); - } - }; - - // Fetch card data & ratings - useEffect(() => { - const fetchCard = async () => { - try { - const movieData = await getMovieCard(movieTitle); - setCardData(movieData); - const img = selectRandomImage(movieData); - setSelectedImage(img); - // Poster fallback - if (!poster) { - poster = movieData.image || poster; - } - // Ratings - const ratings = contentRatings && contentRatings.length > 0 - ? contentRatings - : movieData.content_ratings || []; - if (ratings.length) { - const us = ratings.find((r: any) => r.country === 'usa') || ratings[0]; - setRatingInfo({ rating: us.name || 'NR', description: us.description || '' }); - } - } catch (e) { - console.error('Failed to fetch movie card:', e); - } - }; - fetchCard(); - }, [movieTitle, contentRatings, poster]); - - // Initial link fetch / cleanup - useEffect(() => { - if (!videoUrlState) { - fetchMovieLink(); - } else { - setVideoFetched(true); - videoFetchedRef.current = true; - setLoading(false); - } - return () => { - pollingIntervalRef.current && clearInterval(pollingIntervalRef.current); - timeoutRef.current && clearTimeout(timeoutRef.current); - }; - }, [movieTitle, videoUrlState]); - - // Sync loading state - useEffect(() => { - if (videoUrlState) setLoading(false); - }, [videoUrlState]); - - // Error UI - if (error) { - return ( -
-
😢
-

Error Playing Movie

-

{error}

- -
- ); - } - - // Loading / preparing UI with fade‑in backdrop - if (loading || !videoFetched || !videoUrlState) { - return ( - <> -
-
- setImageLoaded(true)} - onError={(e) => { - (e.target as HTMLImageElement).src = '/placeholder.svg'; - }} - className={`w-full h-full object-cover transition-opacity duration-700 ease-in-out ${ - imageLoaded ? 'opacity-100' : 'opacity-0' - }`} - /> -
-
-
-
-
-
-
- {poster ? ( - {movieTitle} - ) : ( -
- -
- )} -
-

- {progress && progress.progress < 100 - ? `Preparing "${movieTitle}"` - : `Loading "${movieTitle}"` - } -

- {progress ? ( - <> -

- {progress.progress < 5 - ? 'Initializing your stream...' - : progress.progress < 100 - ? 'Your stream is being prepared.' - : 'Almost ready! Starting playback soon...'} -

-
-
-
-

- {Math.round(progress.progress)}% complete -

- - ) : ( -
- -
- )} -
-
- - ); - } - - // Playback UI - return ( -
- - -
- ); -}; - -export default MoviePlayer; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx deleted file mode 100644 index 9f8af86ef2a11477e9f5e84390514178f627e3c7..0000000000000000000000000000000000000000 --- a/frontend/src/components/Navbar.tsx +++ /dev/null @@ -1,157 +0,0 @@ - -import React, { useState, useEffect, useRef } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { Search, Bell, User, Menu, X } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; - -const Navbar = () => { - const [isScrolled, setIsScrolled] = useState(false); - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [searchVisible, setSearchVisible] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const searchInputRef = useRef(null); - const navigate = useNavigate(); - - useEffect(() => { - const handleScroll = () => { - if (window.scrollY > 50) { - setIsScrolled(true); - } else { - setIsScrolled(false); - } - }; - - window.addEventListener('scroll', handleScroll); - return () => { - window.removeEventListener('scroll', handleScroll); - }; - }, []); - - // Focus the search input when it becomes visible - useEffect(() => { - if (searchVisible && searchInputRef.current) { - setTimeout(() => { - searchInputRef.current?.focus(); - }, 200); - } - }, [searchVisible]); - - const toggleMenu = () => { - setIsMenuOpen(!isMenuOpen); - }; - - const toggleSearch = () => { - setSearchVisible(!searchVisible); - }; - - const handleSearchSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (searchTerm.trim()) { - navigate(`/search?q=${encodeURIComponent(searchTerm)}`); - setSearchVisible(false); - setSearchTerm(''); - } - }; - - return ( - - ); -}; - -export default Navbar; diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx deleted file mode 100644 index 00c0241a1cdd1bfd557d626d5a43d2d0741ad83d..0000000000000000000000000000000000000000 --- a/frontend/src/components/PageHeader.tsx +++ /dev/null @@ -1,18 +0,0 @@ - -import React from 'react'; - -interface PageHeaderProps { - title: string; - subtitle?: string; -} - -const PageHeader: React.FC = ({ title, subtitle }) => { - return ( -
-

{title}

- {subtitle &&

{subtitle}

} -
- ); -}; - -export default PageHeader; diff --git a/frontend/src/components/TVShowPlayer.tsx b/frontend/src/components/TVShowPlayer.tsx deleted file mode 100644 index 53fbbe2755f14d2bb968fdafb796a2c9cd376888..0000000000000000000000000000000000000000 --- a/frontend/src/components/TVShowPlayer.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { getEpisodeLinkByTitle, getTvShowCard } from '../lib/api'; -import { useToast } from '@/hooks/use-toast'; -import { Film, GalleryVerticalEnd, Loader2, Play } from 'lucide-react'; -import VideoPlayer from './VideoPlayer'; -import { TvShowCardData } from './ContentCard'; - -interface ProgressData { - status: string; - progress: number; - downloaded: number; - total: number; -} - -interface ContentRating { - country: string; - name: string; - description: string; -} - -interface TVShowPlayerProps { - videoTitle: string; - season: string; - episode: string; - movieTitle: string; - contentRatings?: ContentRating[]; - poster?: string; - startTime?: number; - onClosePlayer?: () => void; - onProgressUpdate?: (currentTime: number, duration: number) => void; - onVideoEnded?: () => void; - onShowEpisodes?: () => void; -} - -const TVShowPlayer: React.FC = ({ - videoTitle, - season, - episode, - movieTitle, - contentRatings, - poster, - startTime = 0, - onClosePlayer, - onProgressUpdate, - onVideoEnded, - onShowEpisodes -}) => { - const [videoUrl, setVideoUrl] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [progress, setProgress] = useState(null); - const [videoFetched, setVideoFetched] = useState(false); - const [showData, setShowData] = useState(null); - const [selectedImage, setSelectedImage] = useState(); - const [imageLoaded, setImageLoaded] = useState(false); - const [ratingInfo, setRatingInfo] = useState<{ rating: string; description: string } | null>(null); - const containerRef = useRef(null); - const videoRef = useRef(null); - - const { toast } = useToast(); - const pollingInterval = useRef(null); - const timeoutRef = useRef(null); - const videoFetchedRef = useRef(false); - - // Reset imageLoaded whenever we pick a new image - useEffect(() => { - setImageLoaded(false); - }, [selectedImage]); - - // Parse episode info - const getEpisodeInfo = () => { - if (!episode) return { number: '1', title: 'Unknown Episode' }; - const match = episode.match(/E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i); - return { - number: match ? match[1] : '1', - title: match ? match[2].trim() : 'Unknown Episode' - }; - }; - const { number: episodeNumber, title: episodeTitle } = getEpisodeInfo(); - - // Random image selector with fallback - const selectRandomImage = (cardData: TvShowCardData) => { - if (cardData.banner?.length) { - return cardData.banner[Math.floor(Math.random() * cardData.banner.length)].image; - } - if (cardData.portrait?.length) { - return cardData.portrait[Math.floor(Math.random() * cardData.portrait.length)].image; - } - return cardData.image; - }; - - // Fetch or poll for the video URL - const fetchMovieLink = async () => { - if (videoFetchedRef.current) return; - try { - const response = await getEpisodeLinkByTitle(videoTitle, season, episode); - if (response.url) { - pollingInterval.current && clearInterval(pollingInterval.current); - setVideoUrl(response.url); - setVideoFetched(true); - videoFetchedRef.current = true; - setLoading(false); - } else if (response.progress_url) { - const poll = async () => { - try { - const res = await fetch(response.progress_url); - const data = await res.json(); - setProgress(data.progress); - if (data.progress.progress >= 100) { - pollingInterval.current && clearInterval(pollingInterval.current); - timeoutRef.current = setTimeout(fetchMovieLink, 5000); - } - } catch (e) { - console.error(e); - } - }; - pollingInterval.current = setInterval(poll, 2000); - } else { - throw new Error('No URL or progress URL'); - } - } catch (e) { - console.error(e); - setError('Failed to load episode'); - toast({ title: 'Error', description: 'Could not load the episode', variant: 'destructive' }); - setLoading(false); - } - }; - - // Main init: fetch TV show card, then fetch link - useEffect(() => { - if (!videoTitle || !season || !episode) { - setError('Missing required video information'); - setLoading(false); - return; - } - - setLoading(true); - setError(null); - setVideoUrl(null); - setVideoFetched(false); - videoFetchedRef.current = false; - setProgress(null); - - const init = async () => { - try { - const data = await getTvShowCard(videoTitle); - setShowData(data); - const img = selectRandomImage(data); - setSelectedImage(img); - const ratings = data.data?.contentRatings || contentRatings || []; - if (ratings.length) { - const us = ratings.find(r => r.country === 'usa') || ratings[0]; - setRatingInfo({ rating: us.name || 'NR', description: us.description || '' }); - } - } catch (e) { - console.error('Show card fetch error:', e); - setError('Failed to load show data'); - toast({ title: 'Error', description: 'Could not load show data', variant: 'destructive' }); - setLoading(false); - return; - } - await fetchMovieLink(); - }; - - init(); - - return () => { - pollingInterval.current && clearInterval(pollingInterval.current); - timeoutRef.current && clearTimeout(timeoutRef.current); - }; - }, [videoTitle, season, episode]); - - useEffect(() => { - if (videoUrl) setLoading(false); - }, [videoUrl]); - - if (error) { - return ( -
-
😢
-

Error Playing Episode

-

{error}

- -
- ); - } - - if (loading || !videoFetched || !videoUrl) { - return ( - <> - {/* Hero backdrop with fade-in */} -
-
- setImageLoaded(true)} - onError={(e) => { - const target = e.target as HTMLImageElement; - target.src = '/placeholder.svg'; - }} - className={`w-full h-full object-cover transition-opacity duration-700 ease-in-out ${ - imageLoaded ? 'opacity-100' : 'opacity-0' - }`} - /> -
-
-
-
-
-
-
- {poster ? ( - {movieTitle} - ) : ( -
- -
- )} -
- -

- {progress && progress.progress < 100 - ? `Preparing "${episodeTitle}"` - : `Loading "${episodeTitle}"` - } -

- - {progress ? ( - <> -

- {progress.progress < 5 - ? 'Initializing your stream...' - : progress.progress < 100 - ? 'Your stream is being prepared.' - : 'Almost ready! Starting playback soon...'} -

-
-
-
-

- {Math.round(progress.progress)}% complete -

- - ) : ( -
- -
- )} -
-
- - ); - } - - const tvShowOverlay = ( - <> -
-
-
- - - {videoTitle} - - - - {season} • Episode {episodeNumber} - -
-

{episodeTitle}

-
-
- -
- -
- - ); - - return ( -
- -
- ); -}; - -export default TVShowPlayer; diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx deleted file mode 100644 index 5c65bdeaec7adae78bce381e3c2666d3f6ee86b0..0000000000000000000000000000000000000000 --- a/frontend/src/components/VideoPlayer.tsx +++ /dev/null @@ -1,582 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { ArrowLeft, FastForward, Keyboard, Maximize, Minimize, Pause, Play, Rewind, SkipBack, SkipForward, Volume2, VolumeX, X } from 'lucide-react'; -import { formatTime } from '../lib/utils'; - -interface VideoPlayerProps { - url: string; - title?: string; - poster?: string; - startTime?: number; - onClose?: () => void; - onProgressUpdate?: (currentTime: number, duration: number) => void; - onVideoEnded?: () => void; - showNextButton?: boolean; - contentRating?: { rating: string, description: string } | null; - hideTitleInPlayer?: boolean; - showControls?: boolean; - containerRef?: React.RefObject; - videoRef?: React.RefObject; - customOverlay?: React.ReactNode; -} - -const VideoPlayer: React.FC = ({ - url, - title, - poster, - startTime = 0, - onClose, - onProgressUpdate, - onVideoEnded, - showNextButton = false, - contentRating, - hideTitleInPlayer = false, - showControls: initialShowControls = true, - containerRef, - videoRef: externalVideoRef, - customOverlay -}) => { - const internalVideoRef = useRef(null); - const videoRef = externalVideoRef || internalVideoRef; - - const [isPlaying, setIsPlaying] = useState(false); - const [volume, setVolume] = useState(1); - const [isMuted, setIsMuted] = useState(false); - const [progress, setProgress] = useState(startTime); - const [duration, setDuration] = useState(0); - const [showControls, setShowControls] = useState(initialShowControls); - const [isFullscreen, setIsFullscreen] = useState(false); - const [buffered, setBuffered] = useState(0); - const [showRating, setShowRating] = useState(true); - const [hoverTime, setHoverTime] = useState(null); - const [hoverPosition, setHoverPosition] = useState<{ x: number, y: number } | null>(null); - const [showKeyboardControls, setShowKeyboardControls] = useState(false); - const controlsTimerRef = useRef(null); - const playerContainerRef = useRef(null); - const progressBarRef = useRef(null); - const ratingTimerRef = useRef(null); - - // Format time manually (in case utils import fails) - const formatTimeBackup = (time: number): string => { - const hours = Math.floor(time / 3600); - const minutes = Math.floor((time % 3600) / 60); - const seconds = Math.floor(time % 60); - const minutesStr = minutes.toString().padStart(2, '0'); - const secondsStr = seconds.toString().padStart(2, '0'); - return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`; - }; - // Hide content rating after a few seconds - useEffect(() => { - if (showRating && contentRating) { - ratingTimerRef.current = setTimeout(() => { - setShowRating(false); - }, 8000); - } - - return () => { - if (ratingTimerRef.current) { - clearTimeout(ratingTimerRef.current); - } - }; - }, [showRating, contentRating]); - - useEffect(() => { - const videoElement = videoRef.current; - - if (videoElement) { - const handleLoadedMetadata = () => { - setDuration(videoElement.duration); - videoElement.currentTime = startTime; - setProgress(startTime); - }; - - const handleTimeUpdate = () => { - setProgress(videoElement.currentTime); - onProgressUpdate?.(videoElement.currentTime, videoElement.duration); - }; - - const handleEnded = () => { - setIsPlaying(false); - onVideoEnded?.(); - }; - - const handleBufferUpdate = () => { - if (videoElement.buffered.length > 0) { - setBuffered(videoElement.buffered.end(videoElement.buffered.length - 1)); - } - }; - - videoElement.addEventListener('loadedmetadata', handleLoadedMetadata); - videoElement.addEventListener('timeupdate', handleTimeUpdate); - videoElement.addEventListener('ended', handleEnded); - videoElement.addEventListener('progress', handleBufferUpdate); - - return () => { - videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata); - videoElement.removeEventListener('timeupdate', handleTimeUpdate); - videoElement.removeEventListener('ended', handleEnded); - videoElement.removeEventListener('progress', handleBufferUpdate); - }; - } - }, [url, startTime, onProgressUpdate, onVideoEnded, videoRef]); - - useEffect(() => { - if (isPlaying) { - videoRef.current?.play(); - } else { - videoRef.current?.pause(); - } - }, [isPlaying, videoRef]); - - useEffect(() => { - if (videoRef.current) { - videoRef.current.volume = isMuted ? 0 : volume; - } - }, [volume, isMuted, videoRef]); - - const hideControlsTimer = () => { - if (controlsTimerRef.current) { - clearTimeout(controlsTimerRef.current); - } - - controlsTimerRef.current = setTimeout(() => { - if (isPlaying && !showKeyboardControls) { - setShowControls(false); - } - }, 3000); - }; - - const handleMouseMove = () => { - setShowControls(true); - hideControlsTimer(); - }; - - useEffect(() => { - hideControlsTimer(); - return () => { - if (controlsTimerRef.current) { - clearTimeout(controlsTimerRef.current); - } - }; - }, [isPlaying, showKeyboardControls]); - - // Keyboard controls - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case ' ': - case 'k': - e.preventDefault(); - setIsPlaying(prev => !prev); - setShowControls(true); - break; - case 'ArrowRight': - e.preventDefault(); - skipForward(); - setShowControls(true); - break; - case 'ArrowLeft': - e.preventDefault(); - skipBackward(); - setShowControls(true); - break; - case 'f': - e.preventDefault(); - toggleFullscreen(); - break; - case 'm': - e.preventDefault(); - setIsMuted(prev => !prev); - setShowControls(true); - break; - case '?': - e.preventDefault(); - setShowKeyboardControls(prev => !prev); - setShowControls(true); - break; - case 'Escape': - if (showKeyboardControls) { - setShowKeyboardControls(false); - } else if (isFullscreen) { - document.exitFullscreen(); - } else if (onClose) { - onClose(); - } - break; - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [isFullscreen, onClose, showKeyboardControls]); - - // Fullscreen handlers - useEffect(() => { - const handleFullScreenChange = () => { - setIsFullscreen(document.fullscreenElement === (containerRef?.current || playerContainerRef.current)); - }; - - document.addEventListener('fullscreenchange', handleFullScreenChange); - return () => document.removeEventListener('fullscreenchange', handleFullScreenChange); - }, [containerRef]); - - const toggleFullscreen = async () => { - const fullscreenElement = containerRef?.current || playerContainerRef.current; - if (!fullscreenElement) return; - - if (!isFullscreen) { - await fullscreenElement.requestFullscreen(); - } else { - await document.exitFullscreen(); - } - }; - - // Player control handlers - const handlePlayPause = () => { - setIsPlaying(!isPlaying); - }; - - const handleMute = () => { - setIsMuted(!isMuted); - }; - - const handleVolumeChange = (e: React.ChangeEvent) => { - const newVolume = parseFloat(e.target.value); - setVolume(newVolume); - if (newVolume === 0) { - setIsMuted(true); - } else if (isMuted) { - setIsMuted(false); - } - }; - - const handleProgressChange = (e: React.ChangeEvent) => { - const newTime = parseFloat(e.target.value); - setProgress(newTime); - if (videoRef.current) { - videoRef.current.currentTime = newTime; - } - }; - - // Direct progress bar click handler - const handleProgressBarClick = (e: React.MouseEvent) => { - if (!progressBarRef.current || !duration) return; - - const rect = progressBarRef.current.getBoundingClientRect(); - const clickPosition = (e.clientX - rect.left) / rect.width; - const newTime = duration * clickPosition; - - if (videoRef.current) { - videoRef.current.currentTime = newTime; - setProgress(newTime); - } - }; - - // Progress bar hover handler for time preview - const handleProgressBarHover = (e: React.MouseEvent) => { - if (!progressBarRef.current || !duration) return; - - const rect = progressBarRef.current.getBoundingClientRect(); - const hoverPosition = (e.clientX - rect.left) / rect.width; - const hoverTimeValue = duration * hoverPosition; - - setHoverTime(hoverTimeValue); - setHoverPosition({ x: e.clientX, y: rect.top }); - }; - - const handleProgressBarLeave = () => { - setHoverTime(null); - setHoverPosition(null); - }; - - // Use the imported formatTime function with a fallback - const formatTimeDisplay = formatTime || formatTimeBackup; - - const skipForward = () => { - if (videoRef.current) { - videoRef.current.currentTime = Math.min( - videoRef.current.duration, - videoRef.current.currentTime + 10 - ); - } - }; - - const skipBackward = () => { - if (videoRef.current) { - videoRef.current.currentTime = Math.max( - 0, - videoRef.current.currentTime - 10 - ); - } - }; - - const toggleKeyboardControls = () => { - setShowKeyboardControls(prev => !prev); - setShowControls(true); - }; - - return ( -
- {/* Content rating overlay - only shown briefly */} - {contentRating && showRating && ( -
-
- {contentRating.rating} -
- | -
- {contentRating.description} -
-
- )} - -
- ); -}; - -export default VideoPlayer; diff --git a/frontend/src/components/VideoPlayerControls.tsx b/frontend/src/components/VideoPlayerControls.tsx deleted file mode 100644 index 452a311c7edf757c6735ea72bf5189fe3b3b6806..0000000000000000000000000000000000000000 --- a/frontend/src/components/VideoPlayerControls.tsx +++ /dev/null @@ -1,34 +0,0 @@ - -import React, { useState } from 'react'; -import WatchTogether from './WatchTogether'; - -interface VideoPlayerControlsProps { - title: string; - currentTime: number; - duration: number; - onSeek?: (time: number) => void; - showWatchTogether?: boolean; -} - -const VideoPlayerControls: React.FC = ({ - title, - currentTime, - duration, - onSeek, - showWatchTogether = true -}) => { - return ( - <> - {showWatchTogether && ( - - )} - - ); -}; - -export default VideoPlayerControls; diff --git a/frontend/src/components/WatchTogether.tsx b/frontend/src/components/WatchTogether.tsx deleted file mode 100644 index 70337c8ebaa5a7232f65ecb83ef64f066398fca9..0000000000000000000000000000000000000000 --- a/frontend/src/components/WatchTogether.tsx +++ /dev/null @@ -1,314 +0,0 @@ - -import React, { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Users, Link, Copy, CheckCircle, Send } from 'lucide-react'; -import { useToast } from '@/hooks/use-toast'; - -interface WatchTogetherProps { - title: string; - currentTime: number; - duration: number; - onSeek?: (time: number) => void; -} - -interface Message { - id: string; - name: string; - text: string; - timestamp: number; - type: 'chat' | 'system' | 'timestamp'; -} - -const WatchTogether: React.FC = ({ title, currentTime, duration, onSeek }) => { - const [isOpen, setIsOpen] = useState(false); - const [roomId, setRoomId] = useState(''); - const [userName, setUserName] = useState(''); - const [message, setMessage] = useState(''); - const [messages, setMessages] = useState([]); - const [userCount, setUserCount] = useState(1); - const [isHost, setIsHost] = useState(true); - const [linkCopied, setLinkCopied] = useState(false); - - const { toast } = useToast(); - - // Generate room ID on mount - useEffect(() => { - const id = `room-${Math.random().toString(36).substring(2, 8)}`; - setRoomId(id); - - // If no username set, use a default - if (!userName) { - setUserName(`User${Math.floor(Math.random() * 10000)}`); - } - - // Initial system message - addSystemMessage(`Watch Party started for "${title}"`); - }, [title]); - - // Function to add system message - const addSystemMessage = (text: string) => { - const newMessage: Message = { - id: `sys-${Date.now()}`, - name: 'System', - text, - timestamp: Date.now(), - type: 'system' - }; - setMessages(prev => [...prev, newMessage]); - }; - - // Function to add user message - const addUserMessage = () => { - if (!message.trim()) return; - - // If message starts with '/seek ', treat as seek command - if (message.startsWith('/seek ')) { - const seekTime = parseInt(message.replace('/seek ', '')); - if (!isNaN(seekTime) && seekTime >= 0 && seekTime <= duration) { - handleSeek(seekTime); - setMessage(''); - return; - } - } - - // Regular message - const newMessage: Message = { - id: `msg-${Date.now()}`, - name: userName, - text: message, - timestamp: Date.now(), - type: 'chat' - }; - - setMessages(prev => [...prev, newMessage]); - setMessage(''); - }; - - // Function to handle seeking - const handleSeek = (time: number) => { - if (onSeek) { - onSeek(time); - - // Add timestamp message - const newMessage: Message = { - id: `time-${Date.now()}`, - name: userName, - text: `Seeked to ${formatTime(time)}`, - timestamp: Date.now(), - type: 'timestamp' - }; - setMessages(prev => [...prev, newMessage]); - } - }; - - // Function to share current timestamp - const shareCurrentTime = () => { - const newMessage: Message = { - id: `time-${Date.now()}`, - name: userName, - text: `Current position: ${formatTime(currentTime)}`, - timestamp: Date.now(), - type: 'timestamp' - }; - setMessages(prev => [...prev, newMessage]); - }; - - // Function to format time - const formatTime = (timeInSeconds: number) => { - const minutes = Math.floor(timeInSeconds / 60); - const seconds = Math.floor(timeInSeconds % 60); - return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; - }; - - // Function to copy invite link - const copyInviteLink = () => { - const inviteLink = `${window.location.href}?room=${roomId}&host=false`; - navigator.clipboard.writeText(inviteLink); - setLinkCopied(true); - - toast({ - title: "Link Copied", - description: "Share this link with friends to watch together", - }); - - setTimeout(() => setLinkCopied(false), 2000); - }; - - // Simulate someone joining after a delay - useEffect(() => { - if (isOpen && isHost) { - const timer = setTimeout(() => { - setUserCount(2); - addSystemMessage("Alice has joined the watch party"); - }, 5000); - - return () => clearTimeout(timer); - } - }, [isOpen, isHost]); - - // Add mock message after some delays - useEffect(() => { - if (isOpen && userCount > 1) { - const timer1 = setTimeout(() => { - setMessages(prev => [ - ...prev, - { - id: `msg-alice-1`, - name: "Alice", - text: "Hey, thanks for inviting me!", - timestamp: Date.now(), - type: 'chat' - } - ]); - }, 3000); - - const timer2 = setTimeout(() => { - setMessages(prev => [ - ...prev, - { - id: `msg-alice-2`, - name: "Alice", - text: "I love this part coming up!", - timestamp: Date.now(), - type: 'chat' - } - ]); - }, 15000); - - return () => { - clearTimeout(timer1); - clearTimeout(timer2); - }; - } - }, [isOpen, userCount]); - - return ( - - - - - - - - Watch Together - - -
-
- - {userCount} {userCount === 1 ? 'viewer' : 'viewers'} -
- -
- - -
-
- - {/* Chat messages */} -
- {messages.map((msg) => ( -
- {msg.type === 'system' ? ( -
- {msg.text} -
- ) : msg.type === 'timestamp' ? ( -
{ - const timeMatch = msg.text.match(/(\d+):(\d+)/); - if (timeMatch) { - const minutes = parseInt(timeMatch[1]); - const seconds = parseInt(timeMatch[2]); - const totalSeconds = minutes * 60 + seconds; - onSeek?.(totalSeconds); - } - }} - > - {msg.text} -
- ) : ( - <> - - {msg.name === userName ? 'You' : msg.name} - -
-

{msg.text}

-
- - )} -
- ))} -
- - {/* Share current timestamp button */} - - - {/* Chat input */} -
- setMessage(e.target.value)} - className="bg-gray-800 border-gray-700 text-white" - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - addUserMessage(); - } - }} - /> - -
- -

- Pro tip: Type '/seek 10' to jump to 10 seconds, or click on any shared timestamp to seek. -

-
-
- ); -}; - -export default WatchTogether; diff --git a/frontend/src/components/chat/ChatBubble.tsx b/frontend/src/components/chat/ChatBubble.tsx new file mode 100644 index 0000000000000000000000000000000000000000..616b5bfe39eb01ede5c975b674563e567e7aeef1 --- /dev/null +++ b/frontend/src/components/chat/ChatBubble.tsx @@ -0,0 +1,121 @@ + +import { useState } from "react"; +import { ArrowRight, RefreshCcw, Copy, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { Avatar, AvatarFallback } from "../ui/avatar"; +import { ChatMessage } from "./ChatMessage"; +import { toast } from '../ui/sonner' + +interface Message { + id: string; + content: string; + sender: "user" | "system"; + timestamp: Date; + isLoading?: boolean; + error?: boolean; + result?: any; +} + +interface ChatBubbleProps { + message: Message; + onViewSearchResults?: (messageId: string) => void; + onRetry?: (messageId: string) => void; +} + + +export const ChatBubble = ({ message, onViewSearchResults, onRetry }: ChatBubbleProps) => { + const [copied, setCopied] = useState(false) + const isSystem = message.sender === "system"; + const showCopyButton = true + const copyToClipboard = () => { + navigator.clipboard.writeText(message.content) + setCopied(true) + toast.success('Copied to clipboard!') + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+ + + {isSystem ? "AI" : "You"} + + + +
+
+ {message.isLoading ? ( +
+ + + +
+ ) : ( + + )} + +
+ {/* Chat bubble footer */} +
+ {/* Time */} +
+ {format(message.timestamp, "h:mm a")} +
+ {/* Controls */} +
+ {/* Retry button for failed messages */} + {message.error && onRetry && ( +
+ +
+ )} + {/* Copy */} + {showCopyButton && ( +
+ +
+ )} +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a55556b600c95ff8483819befcddb71b1456b584 --- /dev/null +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -0,0 +1,72 @@ +// src/components/ChatMessage.tsx +import React, { useState, useMemo } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeRaw from 'rehype-raw' +import { cn } from '@/lib/utils' +import { toast } from '../ui/sonner' + +interface ChatMessageProps { + content: string + className?: string +} + +export const ChatMessage: React.FC = ({ + content, + className, +}) => { + + // ←––– THIS MEMO does the magic replace + const mdWithBadges = useMemo(() => { + return content.replace( + //g, + (_match, path) => { + const filename = path + .split('/') + .pop()! + .replace(/\.[^/.]+$/, '') + // embed your ExternalLink SVG inline so you get the icon + return ` + ${filename} + + + + ` + } + ) + }, [content]) + + return ( +
+ + href && href.endsWith('.md') ? ( + + {children} + + ) : ( + + {children} + + ), + }} + > + {mdWithBadges} + +
+ ) +} diff --git a/frontend/src/components/layout/MainLayout.tsx b/frontend/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3f6d5ce981472059293fd8cbe6a279e4bab9be1f --- /dev/null +++ b/frontend/src/components/layout/MainLayout.tsx @@ -0,0 +1,24 @@ + +import { ReactNode } from "react"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { useLocation } from "react-router-dom"; + +interface MainLayoutProps { + children: ReactNode; +} + +const MainLayout = ({ children }: MainLayoutProps) => { + const isMobile = useIsMobile(); + const location = useLocation(); + const isHomePage = location.pathname === "/"; + + return ( +
+
+ {children} +
+
+ ); +}; + +export default MainLayout; diff --git a/frontend/src/components/layout/ModeToggle.tsx b/frontend/src/components/layout/ModeToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..db8648d04dcafcac568dfdfd18fa77d91b0652e9 --- /dev/null +++ b/frontend/src/components/layout/ModeToggle.tsx @@ -0,0 +1,38 @@ + +import { Moon, Sun } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function ModeToggle() { + const [theme, setTheme] = useState(() => { + // Check local storage or default to system preference + const storedTheme = localStorage.getItem("theme"); + if (storedTheme) return storedTheme; + + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + }); + + useEffect(() => { + const root = window.document.documentElement; + + if (theme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + + localStorage.setItem("theme", theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(theme === "light" ? "dark" : "light"); + }; + + return ( + + ); +} diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx index 991f56ecb117e96284bf0f6cad3b14ea2fdf5264..1b502d1067744009859e49dc6a4bcf3b79374e35 100644 --- a/frontend/src/components/ui/avatar.tsx +++ b/frontend/src/components/ui/avatar.tsx @@ -1,3 +1,4 @@ + import * as React from "react" import * as AvatarPrimitive from "@radix-ui/react-avatar" diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 1128edfceec064e590995e7e10251c658290ee2d..35418149c0656c2257baf517e85496ef759677f5 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -1,5 +1,5 @@ import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" +import { Toaster as Sonner, toast } from "sonner" type ToasterProps = React.ComponentProps @@ -26,4 +26,4 @@ const Toaster = ({ ...props }: ToasterProps) => { ) } -export { Toaster } +export { Toaster, toast } diff --git a/frontend/src/index.css b/frontend/src/index.css index 8a9c81fb7e409034fb3bb44b2d97fdde75b2d967..50107546dd05360b84a309804c91ab8d3f6a2b9f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,58 +1,80 @@ +@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'); + @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { - /* Base shadcn colors */ - --background: 0 0% 9%; - --foreground: 0 0% 98%; - - --card: 0 0% 9%; - --card-foreground: 0 0% 98%; - - --popover: 0 0% 9%; - --popover-foreground: 0 0% 98%; - - --primary: 0 100% 48%; - --primary-foreground: 0 0% 98%; - - --secondary: 0 0% 43%; - --secondary-foreground: 0 0% 98%; - - --muted: 0 0% 15%; - --muted-foreground: 0 0% 65%; - - --accent: 0 0% 15%; - --accent-foreground: 0 0% 98%; - + --background: 222 45% 98%; + --foreground: 222 47% 20%; + + --card: 0 0% 100%; + --card-foreground: 222 47% 20%; + + --popover: 0 0% 100%; + --popover-foreground: 222 47% 20%; + + --primary: 252 62% 64%; + --primary-foreground: 210 40% 98%; + + --secondary: 220 25% 95%; + --secondary-foreground: 222 47% 20%; + + --muted: 220 25% 95%; + --muted-foreground: 222 20% 40%; + + --accent: 252 62% 64%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; - --destructive-foreground: 0 0% 98%; - - --border: 0 0% 19%; - --input: 0 0% 19%; - --ring: 0 0% 83%; - - --radius: 0.5rem; - - /* Netflix-inspired theme */ - --theme-primary: #E50914; - --theme-primary-hover: #B81D24; - --theme-primary-light: #F5222D; - --theme-secondary: #6D6D6D; - --theme-background: #141414; - --theme-background-dark: #0A0A0A; - --theme-background-light: #181818; - --theme-surface: #222222; - --theme-text: #FFFFFF; - --theme-text-secondary: #B3B3B3; - --theme-border: #303030; - --theme-divider: #2D2D2D; - --theme-error: #FF574D; - --theme-warning: #FFB01F; - --theme-success: #48BB78; - --theme-info: #38B2AC; + --destructive-foreground: 210 40% 98%; + + --border: 214 20% 90%; + --input: 214 20% 90%; + --ring: 252 62% 64%; + + --radius: 0.75rem; + + --sidebar-background: 260 73% 56%; + --sidebar-foreground: 0 0% 100%; + --sidebar-primary: 252 62% 64%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 224 47% 20%; + --sidebar-accent-foreground: 0 0% 100%; + --sidebar-border: 224 47% 40%; + --sidebar-ring: 252 62% 64%; + } + + .dark { + --background: 225 30% 8%; + --foreground: 210 40% 98%; + + --card: 225 30% 12%; + --card-foreground: 210 40% 98%; + + --popover: 225 30% 12%; + --popover-foreground: 210 40% 98%; + + --primary: 252 62% 64%; + --primary-foreground: 222 47% 20%; + + --secondary: 225 25% 16%; + --secondary-foreground: 210 40% 98%; + + --muted: 225 25% 16%; + --muted-foreground: 220 20% 70%; + + --accent: 252 62% 64%; + --accent-foreground: 222 47% 20%; + + --destructive: 0 80% 52%; + --destructive-foreground: 210 40% 98%; + + --border: 225 25% 20%; + --input: 225 25% 20%; + --ring: 252 62% 64%; } } @@ -60,61 +82,218 @@ * { @apply border-border; } + body { - @apply bg-background text-foreground; - font-feature-settings: "rlig" 1, "calt" 1; + @apply bg-background text-foreground font-sans; + background-image: + radial-gradient(at 50% 0%, rgba(var(--accent) / 0.08) 0px, transparent 75%), + radial-gradient(at 100% 0%, rgba(var(--accent) / 0.08) 0px, transparent 50%); + background-attachment: fixed; } -} -@layer components { - .content-container { - @apply px-4 md:px-8 max-w-7xl mx-auto; + h1, h2, h3, h4 { + @apply font-heading; } - .section-padding { - @apply py-6 md:py-12; + /* Glass effect styles */ + .glass-effect { + @apply bg-white/70 dark:bg-card/70 backdrop-blur-md border border-white/20 dark:border-white/10; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); } - .btn-primary { - @apply bg-theme-primary hover:bg-theme-primary-hover text-white py-2 px-4 rounded transition-colors duration-200; + .glass-input { + @apply bg-white/50 dark:bg-card/50 backdrop-blur-md border-white/20 dark:border-white/10; } - .btn-secondary { - @apply bg-theme-secondary hover:bg-theme-secondary/80 text-white py-2 px-4 rounded transition-colors duration-200; + /* Text gradient effect */ + .text-gradient { + @apply bg-gradient-to-r from-financial-accent to-financial-light-accent bg-clip-text text-transparent; } +} - .btn-outline { - @apply border border-theme-border bg-transparent hover:bg-theme-surface text-white py-2 px-4 rounded transition-colors duration-200; - } +.search-container { + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); + @apply backdrop-blur-md; +} - .card-hover { - @apply transition-all duration-200 hover:scale-105 hover:z-10; - } +.result-card { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); + transition: transform 0.2s ease, box-shadow 0.25s ease; + @apply backdrop-blur-md; +} - .card-surface { - @apply bg-theme-surface rounded-md overflow-hidden shadow-md; - } +.result-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1); +} - .glass { - @apply bg-theme-background-light/30 backdrop-blur-md border border-white/10; - } +.source-card { + border-left: 3px solid theme('colors.financial.accent'); +} - .video-card { - @apply overflow-hidden rounded-md relative; - } +.dashboard-card { + transition: all 0.3s ease-in-out; +} - .video-card-overlay { - @apply absolute inset-0 bg-gradient-to-t from-black via-transparent to-transparent opacity-0 transition-opacity hover:opacity-100; - } +.dashboard-card:hover { + transform: translateY(-3px); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.12); +} - .text-truncate { - @apply overflow-hidden text-ellipsis whitespace-nowrap; - } +.nav-item { + position: relative; +} + +.nav-item::after { + content: ''; + position: absolute; + width: 0; + height: 2px; + bottom: -4px; + left: 0; + background: linear-gradient(to right, theme('colors.financial.accent'), theme('colors.financial.light-accent')); + transition: width 0.3s ease; + border-radius: 2px; +} + +.nav-item:hover::after, +.nav-item.active::after { + width: 100%; +} + +.chat-container { + height: calc(100vh - 64px); +} + +.message-bubble { + position: relative; +} - .text-truncate-2 { - @apply overflow-hidden; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; +.message-bubble-ai { + border-top-left-radius: 4px; +} + +.message-bubble-user { + border-top-right-radius: 4px; +} + +.typing-indicator span { + @apply inline-block h-2 w-2 rounded-full bg-current; + animation: typing-bounce 1.4s infinite ease-in-out both; +} + +.typing-indicator span:nth-child(1) { + animation-delay: -0.32s; +} + +.typing-indicator span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes typing-bounce { + 0%, 80%, 100% { + transform: scale(0.6); + } + 40% { + transform: scale(1.0); } } + +/* Enhanced glass effect */ +.neo-glass { + @apply bg-white/80 dark:bg-card/80 backdrop-blur-xl; + box-shadow: + 0 4px 24px -6px rgba(0, 0, 0, 0.12), + 0 12px 48px -4px rgba(0, 0, 0, 0.1); +} + +.input-container { + @apply border border-border dark:border-border backdrop-blur-md rounded-xl; + background: linear-gradient(to bottom right, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7)); + box-shadow: + 0 2px 10px rgba(0, 0, 0, 0.03), + 0 0 0 1px rgba(255, 255, 255, 0.8); +} + +.dark .input-container { + background: linear-gradient(to bottom right, rgba(30, 41, 59, 0.7), rgba(30, 41, 59, 0.5)); + box-shadow: + 0 2px 10px rgba(0, 0, 0, 0.2), + 0 0 0 1px rgba(255, 255, 255, 0.1); +} + +/* Enhanced message styles */ +.message-container { + @apply transition-all duration-300; +} + +.message-bubble { + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08); +} + +/* Glow effect */ +.glow-accent { + box-shadow: 0 0 15px rgba(var(--accent), 0.5); +} + +/* Code block styling */ +pre code { + @apply p-4 rounded-lg text-sm block overflow-x-auto; +} + +.code-block { + @apply relative; +} + +.code-block-header { + @apply flex items-center justify-between px-3 py-1.5 bg-muted/70 border-b border-border rounded-t-lg; +} + +.retry-button { + @apply transition-transform hover:scale-105 active:scale-95; +} + +/* High-tech progress bars */ +.progress-bar { + @apply h-1.5 bg-muted/50 rounded-full overflow-hidden; +} + +.progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, theme('colors.financial.accent'), theme('colors.financial.light-accent')); + border-radius: inherit; + transition: width 0.5s ease; +} + +/* Modern pulse animation */ +@keyframes pulse-glow { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent), 0.7); } + 70% { box-shadow: 0 0 0 10px rgba(var(--accent), 0); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent), 0); } +} + +.pulse-accent { + animation: pulse-glow 2s infinite; +} + +/* High-tech button styles */ +.tech-button { + @apply relative overflow-hidden; + transition: all 0.3s; +} + +.tech-button::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(120deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transform: translateX(-100%); +} + +.tech-button:hover::before { + transform: translateX(100%); + transition: transform 0.5s; +} diff --git a/frontend/src/lib/LoadBalancerAPI.js b/frontend/src/lib/LoadBalancerAPI.js deleted file mode 100644 index 9d9d8411f5e0dc0867d99d6060cd8efe4bf3ae0d..0000000000000000000000000000000000000000 --- a/frontend/src/lib/LoadBalancerAPI.js +++ /dev/null @@ -1,181 +0,0 @@ - -class LoadBalancerAPI { - constructor(baseURL) { - this.baseURL = baseURL; - this.cache = { - filmStore: null, - tvStore: null, - allMovies: null, - allSeries: null, - movieMetadata: new Map(), - seriesMetadata: new Map(), - }; - } - - async getInstances() { - return await this._get('/api/get/instances'); - } - - async getInstancesHealth() { - return await this._get('/api/get/instances/health'); - } - - async getMovieByTitle(title) { - return await this._get(`/api/get/movie/${encodeURIComponent(title)}`); - } - - async getSeriesEpisode(title, season, episode) { - return await this._get(`/api/get/series/${encodeURIComponent(title)}/${season}/${episode}`); - } - - async getSeriesStore() { - if (!this.cache.tvStore) { - this.cache.tvStore = await this._get('/api/get/series/store'); - } - return this.cache.tvStore || {}; - } - - async getMovieStore() { - if (!this.cache.filmStore) { - this.cache.filmStore = await this._get('/api/get/movie/store'); - } - return this.cache.filmStore || {}; - } - - async getMovieMetadataByTitle(title) { - if (!this.cache.movieMetadata.has(title)) { - const metadata = await this._get(`/api/get/movie/metadata/${encodeURIComponent(title)}`); - this.cache.movieMetadata.set(title, metadata); - } - return this.cache.movieMetadata.get(title); - } - - async getMovieCard(title) { - return await this._get(`/api/get/movie/card/${encodeURIComponent(title)}`); - } - - async getSeriesMetadataByTitle(title) { - if (!this.cache.seriesMetadata.has(title)) { - const metadata = await this._get(`/api/get/series/metadata/${encodeURIComponent(title)}`); - this.cache.seriesMetadata.set(title, metadata); - } - return this.cache.seriesMetadata.get(title); - } - - async getSeriesCard(title) { - return await this._get(`/api/get/series/card/${encodeURIComponent(title)}`); - } - - async getSeasonMetadataByTitleAndSeason(title, season) { - return await this._get(`/api/get/series/metadata/${encodeURIComponent(title)}/${encodeURIComponent(season)}`); - } - - async getSeasonMetadataBySeriesId(series_id, season) { - return await this._get(`/api/get/series/metadata/${series_id}/${season}`); - } - - async getAllMovies() { - if (!this.cache.allMovies) { - this.cache.allMovies = await this._get('/api/get/movie/all'); - } - return this.cache.allMovies; - } - - async getAllSeriesShows() { - if (!this.cache.allSeries) { - this.cache.allSeries = await this._get('/api/get/series/all'); - } - return this.cache.allSeries; - } - - async getRecent(limit = 10) { - return await this._get(`/api/get/recent?limit=${limit}`); - } - - async getGenreCategories(mediaType) { - const url = mediaType - ? `/api/get/genre_categories?media_type=${encodeURIComponent(mediaType)}` - : '/api/get/genre_categories'; - return await this._get(url); - } - - async getGenreItems(genres, mediaType, limit = 5, page = 1) { - if (!Array.isArray(genres)) { - throw new Error("The 'genres' parameter must be an array."); - } - const params = new URLSearchParams(); - genres.forEach(genre => params.append('genre', genre)); - params.append('limit', limit); - params.append('page', page); - if (mediaType) { - params.append('media_type', mediaType); - } - try { - const response = await this._get(`/api/get/genre?${params.toString()}`); - console.debug(response); - return response; - } catch (error) { - console.debug("Error fetching genre items:", error); - throw error; - } - } - - async getDownloadProgress(url) { - return await this._getNoBase(url); - } - - async _get(endpoint) { - return await this._request(`${this.baseURL}${endpoint}`, { method: 'GET' }); - } - - async _getNoBase(url) { - return await this._request(url, { method: 'GET' }); - } - - async _post(endpoint, body) { - return await this._request(`${this.baseURL}${endpoint}`, { - method: 'POST', - body: JSON.stringify(body) - }); - } - - async _request(url, options) { - try { - const response = await fetch(url, { - headers: { 'Content-Type': 'application/json' }, - ...options, - }); - console.log(`API Request: ${url} with options: ${JSON.stringify(options)}`); - return await this._handleResponse(response); - } catch (error) { - console.debug(`Request error for ${url}:`, error); - throw error; - } - } - - async _handleResponse(response) { - if (!response.ok) { - const errorDetails = await response.text(); - throw new Error(`HTTP Error ${response.status}: ${errorDetails}`); - } - try { - return await response.json(); - } catch (error) { - console.debug('Error parsing JSON response:', error); - throw error; - } - } - - clearCache() { - this.cache = { - filmStore: null, - tvStore: null, - allMovies: null, - allSeries: null, - movieMetadata: new Map(), - seriesMetadata: new Map(), - }; - } -} - -export { LoadBalancerAPI }; diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js deleted file mode 100644 index 0b9fddeb2244f74599d600b94df3cf916a646037..0000000000000000000000000000000000000000 --- a/frontend/src/lib/api.js +++ /dev/null @@ -1,159 +0,0 @@ - -import { LoadBalancerAPI } from "./LoadBalancerAPI"; - -const lb = new LoadBalancerAPI("https://hans-den-load-balancer.hf.space"); - -export async function getRecentItems(limit = 5) { - const recentData = await lb.getRecent(limit); - console.debug("Raw recent data:", recentData); - - const slides = []; - - // Process movies and format them as slide objects - if (recentData.movies && Array.isArray(recentData.movies)) { - recentData.movies.forEach(movie => { - const [title, year, description, image, genres] = movie; - slides.push({ - type: 'movie', - title, - genre: genres.map(g => g.name), // returns an array of genre names - image, - description, - year, - }); - }); - } - - // Process series and format them as slide objects with type "tvshow" - if (recentData.series && Array.isArray(recentData.series)) { - recentData.series.forEach(series => { - const [title, year, description, image, genres] = series; - slides.push({ - type: 'tvshow', - title, - genre: genres.map(g => g.name), // returns an array of genre names - image, - description, - year, - }); - }); - } - console.debug(slides); - return slides; -} - -export async function getNewContents(limit = 5) { - const recentData = await lb.getRecent(limit); - console.debug("Raw recent data:", recentData); - - const movies = []; - const tvshows = []; - - // Process movies - if (Array.isArray(recentData.movies)) { - recentData.movies.forEach(([title, year, description, image, genres]) => { - movies.push({ - title, - genre: genres.map(g => g.name), - image, - description, - year, - }); - }); - } - - // Process TV shows - if (Array.isArray(recentData.series)) { - recentData.series.forEach(([title, year, description, image, genres]) => { - tvshows.push({ - title, - genre: genres.map(g => g.name), - image, - description, - year, - }); - }); - } - - console.debug({ movies, tvshows }); - return { movies, tvshows }; -} - -export async function getAllMovies(){ - const movies = await lb.getAllMovies(); - console.debug(movies); - - const formattedMovies = movies.map(title => ({ - title: title.replace('films/', '') - })); - return formattedMovies; -} - -export async function getAllTvShows() { - const tvshows = await lb.getAllSeriesShows(); - - // Transform the response to return TV show names with episode count - const formattedTvShows = Object.entries(tvshows).map(([title, episodes]) => ({ - title, - episodeCount: episodes.length - })); - - return formattedTvShows; -} - -export async function getMovieLinkByTitle(title){ - const response = await lb.getMovieByTitle(title); - console.debug(response); - return response; -} - -export async function getEpisodeLinkByTitle(title, season, episode){ - const response = await lb.getSeriesEpisode(title, season, episode); - console.debug(response); - return response; -} - -export async function getMovieCard(title){ - const movie = await lb.getMovieCard(title); - console.debug(movie); - return movie; -} - -export async function getTvShowCard(title){ - const tvshow = await lb.getSeriesCard(title); - console.debug(tvshow); - return tvshow; -} - -export async function getMovieMetadata(title){ - const movie = await lb.getMovieMetadataByTitle(title); - console.debug(movie); - return movie; -} - -export async function getTvShowMetadata(title){ - const tvshow = await lb.getSeriesMetadataByTitle(title); - console.debug(tvshow); - return tvshow; -} - -export async function getSeasonMetadata(title, season){ - const data = await lb.getSeasonMetadataByTitleAndSeason(title, season); - console.debug(data); - return data; -} - -export async function getGenreCategories(mediaType){ - const gc = await lb.getGenreCategories(mediaType); - console.debug(gc); - if (gc.genres) - return gc.genres; - else - return []; -} - -export async function getGenresItems(genres, mediaType, limit = 10, page = 1){ - const genresRes = await lb.getGenreItems(genres, mediaType, limit, page); - console.debug(genresRes); - return genresRes; -} diff --git a/frontend/src/lib/remarkSource.ts b/frontend/src/lib/remarkSource.ts new file mode 100644 index 0000000000000000000000000000000000000000..d496db1550ce619f4ce00d90c51509eb468241ae --- /dev/null +++ b/frontend/src/lib/remarkSource.ts @@ -0,0 +1,21 @@ +import { visit } from 'unist-util-visit' +import type { Plugin } from 'unified' +import type { Root, HTML } from 'mdast' + +const remarkSource: Plugin = () => (tree: Root) => { + visit(tree, 'html', (node: HTML, index, parent) => { + if (node.value.startsWith('/) + if (!match || !parent) return + const [, path] = match + parent.children.splice(index, 1, { + type: 'source', + path, + data: {}, + } as any) + }) +} + +export default remarkSource diff --git a/frontend/src/lib/search-api.ts b/frontend/src/lib/search-api.ts deleted file mode 100644 index 7a5c922b08250802d99515b67e57d77b3b39eaf5..0000000000000000000000000000000000000000 --- a/frontend/src/lib/search-api.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Client for the search API - -type RawSearchResponse = { - films: string[]; - series: string[]; - episodes: { - series: string; - title: string; - path: string; - season: string; - }[]; -}; - -const API_BASE_URL = 'https://hans-den-search.hf.space'; // Change this to your actual API URL - -export const searchAPI = { - search: async (query: string): Promise => { - try { - const response = await fetch(`${API_BASE_URL}/api/search`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - - if (!response.ok) { - throw new Error(`Search API returned ${response.status}`); - } - - const data = await response.json(); - console.log('Search API response:', data); - return data; - } catch (error) { - console.error('Error searching:', error); - return { films: [], series: [], episodes: [] }; - } - }, - - healthCheck: async (): Promise => { - try { - const response = await fetch(`${API_BASE_URL}/health`); - return response.ok; - } catch (error) { - console.error('API health check failed:', error); - return false; - } - }, - - getData: async () => { - try { - const response = await fetch(`${API_BASE_URL}/api/data`); - - if (!response.ok) { - throw new Error(`API returned ${response.status}`); - } - - return await response.json(); - } catch (error) { - console.error('Error fetching API data:', error); - return null; - } - } -}; diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index b4e683003bfc65e0e42fc868d2373fd6b779452e..7476b58a623a8dd17ec9ff65afeb52111c98ad6d 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,249 +1,137 @@ - -// Updated storage utility with additional functions - -// Type for storing watch history and progress -export interface WatchProgress { - currentTime: number; - duration: number; - lastPlayed: string; - completed: boolean; -} - -// Type for my list items -export interface MyListItem { - title: string; - type: 'movie' | 'tvshow'; - addedAt: string; - posterPath?: string; - backdropPath?: string; -} - /** - * Save video watch progress + * Storage Library for Financial Insight System + * Provides a unified interface for data storage with potential for external storage integration */ -export const saveVideoProgress = ( - type: 'movie' | 'tvshow', - title: string, - seasonEpisode: string | null, - progress: WatchProgress -): void => { - try { - // Create a unique identifier for the content - const id = seasonEpisode ? `${type}-${title}-${seasonEpisode}` : `${type}-${title}`; - - // Get existing watch history - const historyStr = localStorage.getItem('watch-history') || '{}'; - const history = JSON.parse(historyStr); - - // Update the history with new progress - history[id] = { - ...progress, - title, - type, - seasonEpisode, - updatedAt: new Date().toISOString() - }; - - // Save back to localStorage - localStorage.setItem('watch-history', JSON.stringify(history)); - } catch (error) { - console.error('Failed to save video progress:', error); - } -}; -/** - * Get video watch progress - */ -export const getVideoProgress = ( - type: 'movie' | 'tvshow', - title: string, - seasonEpisode: string | null -): WatchProgress | null => { - try { - // Create a unique identifier for the content - const id = seasonEpisode ? `${type}-${title}-${seasonEpisode}` : `${type}-${title}`; - - // Get existing watch history - const historyStr = localStorage.getItem('watch-history') || '{}'; - const history = JSON.parse(historyStr); - - // Return the progress if it exists - return history[id] || null; - } catch (error) { - console.error('Failed to get video progress:', error); - return null; - } -}; +// Define storage keys for better type safety and avoid string duplication +export const STORAGE_KEYS = { + CHATS: 'fis-chats', + SETTINGS: 'fis-settings', + API_ENDPOINT: 'apiEndpoint', + THEME: 'fis-theme', + SOURCES: 'fis-sources', +} as const; -/** - * Get watch history - */ -export const getWatchHistory = (limit: number = 0): Array => { - try { - // Get existing watch history - const historyStr = localStorage.getItem('watch-history') || '{}'; - const history = JSON.parse(historyStr); - - // Convert object to array and sort by updatedAt (most recent first) - const historyArray = Object.values(history).sort((a: any, b: any) => { - return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); - }); - - // Return all or limited number of items - return limit > 0 ? historyArray.slice(0, limit) : historyArray; - } catch (error) { - console.error('Failed to get watch history:', error); - return []; - } -}; +// Types for our storage +export interface StorageOptions { + ttl?: number; // Time to live in milliseconds +} -/** - * Clear watch history - */ -export const clearWatchHistory = (): void => { - try { - localStorage.removeItem('watch-history'); - } catch (error) { - console.error('Failed to clear watch history:', error); - } -}; +export type StorageValue = string | object | number | boolean | null | undefined; /** - * Add item to My List + * Storage service that provides unified interface for storing and retrieving data + * Currently uses localStorage, but can be extended to use external storage in the future */ -export const addToMyList = (item: MyListItem): void => { - try { - // Get existing my list - const myListStr = localStorage.getItem('my-list') || '[]'; - const myList = JSON.parse(myListStr); - - // Check if item already exists - const exists = myList.some((listItem: MyListItem) => - listItem.title === item.title && listItem.type === item.type - ); - - // Add item if it doesn't exist - if (!exists) { - myList.push({ - ...item, - addedAt: new Date().toISOString() - }); +class StorageService { + // Get item from storage with automatic parsing + get(key: string): T | null { + try { + const item = localStorage.getItem(key); + if (!item) return null; - // Save back to localStorage - localStorage.setItem('my-list', JSON.stringify(myList)); + // Parse the stored value + const { value, expires } = JSON.parse(item); + + // Check if the value has expired + if (expires && expires < Date.now()) { + this.remove(key); + return null; + } + + return value as T; + } catch (error) { + console.error(`Error getting item from storage: ${key}`, error); + return null; } - } catch (error) { - console.error('Failed to add item to My List:', error); - } -}; - -/** - * Remove item from My List - */ -export const removeFromMyList = (title: string, type: 'movie' | 'tvshow'): void => { - try { - // Get existing my list - const myListStr = localStorage.getItem('my-list') || '[]'; - const myList = JSON.parse(myListStr); - - // Filter out the item - const newList = myList.filter((item: MyListItem) => - !(item.title === title && item.type === type) - ); - - // Save back to localStorage - localStorage.setItem('my-list', JSON.stringify(newList)); - } catch (error) { - console.error('Failed to remove item from My List:', error); } -}; -/** - * Check if item is in My List - */ -export const isInMyList = (title: string, type: 'movie' | 'tvshow'): boolean => { - try { - // Get existing my list - const myListStr = localStorage.getItem('my-list') || '[]'; - const myList = JSON.parse(myListStr); - - // Check if item exists - return myList.some((item: MyListItem) => - item.title === title && item.type === type - ); - } catch (error) { - console.error('Failed to check if item is in My List:', error); - return false; + // Set item in storage with optional TTL + set(key: string, value: StorageValue, options: StorageOptions = {}): boolean { + try { + const storageItem = { + value, + expires: options.ttl ? Date.now() + options.ttl : null + }; + + localStorage.setItem(key, JSON.stringify(storageItem)); + return true; + } catch (error) { + console.error(`Error setting item in storage: ${key}`, error); + return false; + } } -}; -/** - * Get all items from My List - */ -export const getAllFromMyList = (): Array => { - try { - // Get existing my list - const myListStr = localStorage.getItem('my-list') || '[]'; - return JSON.parse(myListStr); - } catch (error) { - console.error('Failed to get My List:', error); - return []; + // Remove item from storage + remove(key: string): boolean { + try { + localStorage.removeItem(key); + return true; + } catch (error) { + console.error(`Error removing item from storage: ${key}`, error); + return false; + } } -}; -/** - * Clear My List - */ -export const clearMyList = (): void => { - try { - localStorage.removeItem('my-list'); - } catch (error) { - console.error('Failed to clear My List:', error); + // Check if key exists in storage + has(key: string): boolean { + return localStorage.getItem(key) !== null; } -}; -// Example of a function that could be replaced with a database implementation -// This can be swapped out with a DB implementation later -export const storageService = { - // Video progress functions - saveVideoProgress, - getVideoProgress, - getWatchHistory, - clearWatchHistory, - - // My list functions - addToMyList, - removeFromMyList, - isInMyList, - getAllFromMyList, - clearMyList, - - // Generic storage functions that could be replaced - setItem: (key: string, value: any): void => { + // Clear all storage for the application + clear(): boolean { try { - localStorage.setItem(key, JSON.stringify(value)); - } catch (error) { - console.error(`Failed to store ${key}:`, error); - } - }, - - getItem: (key: string, defaultValue: T): T => { - try { - const item = localStorage.getItem(key); - return item ? JSON.parse(item) : defaultValue; + // Only clear keys that start with our application prefix (fis-) + Object.keys(localStorage).forEach(key => { + if (key.startsWith('fis-')) { + localStorage.removeItem(key); + } + }); + return true; } catch (error) { - console.error(`Failed to retrieve ${key}:`, error); - return defaultValue; + console.error('Error clearing storage', error); + return false; } - }, - - removeItem: (key: string): void => { + } + + /** + * Export all application data for keys defined in STORAGE_KEYS as a JSON object. + * Returns an object with key-value pairs. + */ + export(): Record { + const exported: Record = {}; + Object.values(STORAGE_KEYS).forEach(key => { + try { + const item = localStorage.getItem(key); + if (item) { + exported[key] = JSON.parse(item); + } + } catch (error) { + console.error(`Error exporting key: ${key}`, error); + } + }); + return exported; + } + + /** + * Import data from an external source into local storage. + * Accepts an object with key-value pairs (as produced by export()). + * Overwrites existing keys, but only those defined in STORAGE_KEYS. + */ + import(data: Record): boolean { try { - localStorage.removeItem(key); + Object.entries(data).forEach(([key, value]) => { + if (Object.values(STORAGE_KEYS).includes(key as any)) { + localStorage.setItem(key, JSON.stringify(value)); + } + }); + return true; } catch (error) { - console.error(`Failed to remove ${key}:`, error); + console.error('Error importing data into storage', error); + return false; } } -}; +} -export default storageService; +// Create and export a singleton instance +export const storage = new StorageService(); diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index efff1e59de4f2aa6bc5e511acd504242f516e1c7..bd0c391ddd1088e9067844c48835bf4abcd61783 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,16 +1,6 @@ - import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } - -export const formatTime = (time: number): string => { - const hours = Math.floor(time / 3600); - const minutes = Math.floor((time % 3600) / 60); - const seconds = Math.floor(time % 60); - const minutesStr = minutes.toString().padStart(2, '0'); - const secondsStr = seconds.toString().padStart(2, '0'); - return hours > 0 ? `${hours}:${minutesStr}:${secondsStr}` : `${minutesStr}:${secondsStr}`; -}; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index cf93e50d21777a7f82f5378d751437af14d38ca2..719464e3da4bc77d3adebed4b6c12d3327f5b89f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,5 @@ - -import { createRoot } from 'react-dom/client'; -import App from './App.tsx'; -import './index.css'; +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' createRoot(document.getElementById("root")!).render(); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d892d9d58164d7e30c6ecbb5e83d7d5c3dadcc30..7f356f3639afa52320d966bf28d94c2766118c85 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,184 +1,741 @@ -import { useEffect, useState } from 'react'; -import HeroSection from '../components/HeroSection'; -import ContentRow from '../components/ContentRow'; -import { getRecentItems, getGenreCategories, getGenresItems, getMovieCard, getTvShowCard } from '../lib/api'; -import { useToast } from '@/hooks/use-toast'; - -// GenreRow component for dynamic loading of a genre row -const GenreRow = ({ genre, type }) => { - const [loading, setLoading] = useState(true); - const [items, setItems] = useState([]); - const { toast } = useToast(); - useEffect(() => { - const fetchGenreData = async () => { - try { - // Fetch titles for the given genre and type - const genreItems = await getGenresItems([genre], type, 10, 1); - const titles = - type === "movie" - ? (genreItems && Array.isArray(genreItems.movies) - ? genreItems.movies.map(item => item.title) - : []) - : (genreItems && Array.isArray(genreItems.series) - ? genreItems.series.map(item => item.title) - : []); - if (titles.length === 0) { - setLoading(false); - return; - } +import { useState, useRef, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { Send, Plus, CornerDownLeft, TrashIcon, RefreshCcw, Bot, Sparkles, Menu } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { rulingService, Message as APIMessage } from "@/services/rulingService"; +import { toast } from "@/components/ui/sonner"; +import { ChatBubble } from "@/components/chat/ChatBubble"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { storage, STORAGE_KEYS } from "@/lib/storage"; +import { format } from "date-fns"; +import { ModeToggle } from "@/components/layout/ModeToggle"; +import { Link } from "react-router-dom"; +import { FileText, MessageCircle, Settings, PanelLeft } from "lucide-react"; - // For each title, fetch the card details - const fetchCard = async (title) => { - try { - if (type === "movie") { - const movieInfo = await getMovieCard(title); - if (movieInfo) { - return { - type: 'movie', - title, - image: movieInfo.image, - description: movieInfo.overview, - genre: movieInfo.genres?.map((g) => g.name) || [], - year: movieInfo.year - }; - } - } else { - const showInfo = await getTvShowCard(title); - if (showInfo) { - return { - type: 'tvshow', - title, - image: showInfo.image, - description: showInfo.overview, - genre: showInfo.genres?.map((g) => g.name) || [], - year: showInfo.year - }; - } - } - } catch (error) { - console.error(`Error fetching card for ${title}:`, error); - return null; - } - return null; - }; +interface Message { + id: string; + content: string; + sender: "user" | "system"; + timestamp: Date; + isLoading?: boolean; + error?: boolean; + result?: any; +} + +interface Chat { + id: string; + title: string; + messages: Message[]; + createdAt: Date; + updatedAt: Date; +} + +const WELCOME_MESSAGE = "Hello! I'm Insight AI. How can I help you today?"; + +const generateId = () => Math.random().toString(36).substring(2, 11); + +const navItems = [ + { name: "Conversations", path: "/", icon: MessageCircle }, + { name: "Sources", path: "/sources", icon: FileText } +]; - const cardPromises = titles.map((title) => fetchCard(title)); - const cards = await Promise.all(cardPromises); - setItems(cards.filter(item => item !== null)); +const HomePage = () => { + const [chats, setChats] = useState(() => { + const savedChats = storage.get(STORAGE_KEYS.CHATS); + if (savedChats) { + try { + return savedChats.map((chat: any) => ({ + ...chat, + messages: chat.messages.map((msg: any) => ({ + ...msg, + timestamp: new Date(msg.timestamp), + sender: msg.sender as "user" | "system" + })), + createdAt: new Date(chat.createdAt), + updatedAt: new Date(chat.updatedAt) + })); } catch (error) { - console.error(`Error fetching ${type} items for genre ${genre}:`, error); - toast({ - title: `Error loading ${type} items`, - description: `Failed to load ${genre} ${type} items`, - variant: "destructive" - }); - } finally { - setLoading(false); + console.error("Failed to parse saved chats:", error); + return []; } + } + return []; + }); + + const [activeChat, setActiveChat] = useState(() => { + if (chats.length > 0) { + return chats[0]; + } + + // Create initial chat if none exists + const initialChat: Chat = { + id: generateId(), + title: "New Chat", + messages: [ + { + id: generateId(), + content: WELCOME_MESSAGE, + sender: "system" as const, + timestamp: new Date() + } + ], + createdAt: new Date(), + updatedAt: new Date() }; - fetchGenreData(); - }, [genre, type, toast]); + return initialChat; + }); - // While data is being fetched, show a simple loader in place of the row - if (loading) { - return ( -
-

{genre} {type === "movie" ? "Movies" : "Shows"}

-
-
-
-
- ); - } + const [inputValue, setInputValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); - // If no items were found, render nothing - if (items.length === 0) return null; + const navigate = useNavigate(); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); - return ( - - ); -}; + useEffect(() => { + // Save chats to storage whenever they change + if (activeChat && !chats.find(chat => chat.id === activeChat.id)) { + setChats([activeChat, ...chats]); + } -const HomePage = () => { - const [loading, setLoading] = useState(true); - const [heroContent, setHeroContent] = useState(null); - const [recentContent, setRecentContent] = useState([]); - const [genres, setGenres] = useState([]); - const { toast } = useToast(); + const allChats = activeChat + ? [ + activeChat, + ...chats.filter(chat => chat.id !== activeChat.id) + ] + : chats; + + storage.set(STORAGE_KEYS.CHATS, allChats); + + // Dispatch a custom event to notify storage updates + window.dispatchEvent(new Event("storage-updated")); + }, [chats, activeChat]); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; useEffect(() => { - const fetchHomeData = async () => { - try { - setLoading(true); + scrollToBottom(); + }, [activeChat?.messages]); + + useEffect(() => { + // Focus input when component mounts or when loading ends + if (!isLoading) { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, [isLoading]); - // Fetch recent items for hero and recent content row - const recentItems = await getRecentItems(10); - if (recentItems && recentItems.length > 0) { - setHeroContent(recentItems[0]); - setRecentContent(recentItems); + // Event listeners for custom events + useEffect(() => { + const handleNewChat = () => { + const newChat: Chat = { + id: generateId(), + title: "New Chat", + messages: [ + { + id: generateId(), + content: WELCOME_MESSAGE, + sender: "system" as const, + timestamp: new Date() + } + ], + createdAt: new Date(), + updatedAt: new Date() + }; + + setActiveChat(newChat); + setChats(prev => [newChat, ...prev]); + setIsSidebarOpen(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }; + + const handleSelectChat = (e: Event) => { + const customEvent = e as CustomEvent; + const chatId = customEvent.detail?.chatId; + if (chatId) { + const selectedChat = chats.find(chat => chat.id === chatId); + if (selectedChat) { + setActiveChat(selectedChat); + setIsSidebarOpen(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); } + } + }; - // Fetch all genre categories (movies and shows together) - const genresRes = await getGenreCategories(); - console.log("Fetched genres:", genresRes); - - const allGenres = Array.isArray(genresRes) - ? genresRes.map((g) => g.name) - : genresRes.genres - ? genresRes.genres.map((g) => g.name) - : []; - console.log("All genres:", allGenres); - setGenres(allGenres); - } catch (error) { - console.error("Error fetching home page data:", error); - toast({ - title: "Error loading content", - description: "Please try again later", - variant: "destructive" + const handleDeleteChat = (e: Event) => { + const customEvent = e as CustomEvent; + const chatId = customEvent.detail?.chatId; + if (chatId) { + const updatedChats = chats.filter(chat => chat.id !== chatId); + setChats(updatedChats); + + // If we're deleting the active chat, switch to another one + if (activeChat?.id === chatId) { + setActiveChat(updatedChats.length > 0 ? updatedChats[0] : null); + + // If no chats left, create a new one + if (updatedChats.length === 0) { + handleNewChat(); + } + } + } + }; + + document.addEventListener("insight:new-chat", handleNewChat); + document.addEventListener("insight:select-chat", handleSelectChat); + document.addEventListener("insight:delete-chat", handleDeleteChat); + + return () => { + document.removeEventListener("insight:new-chat", handleNewChat); + document.removeEventListener("insight:select-chat", handleSelectChat); + document.removeEventListener("insight:delete-chat", handleDeleteChat); + }; + }, [chats, activeChat]); + + const generateChatTitle = async (query: string) => { + if (!activeChat) return; + + // Only generate titles for new chats + if (activeChat.title !== "New Chat") return; + + setIsGeneratingTitle(true); + + try { + const response = await rulingService.generateTitle(query); + + if (response.title) { + // Update the existing chat object directly + setActiveChat(prevChat => { + if (!prevChat) return null; + + const updatedChat = { + ...prevChat, + title: response.title + }; + + // Update chats list + setChats(prevChats => + prevChats.map(chat => + chat.id === updatedChat.id ? updatedChat : chat + ) + ); + + return updatedChat; + }); + } + } catch (error) { + console.error("Error generating chat title:", error); + // Fallback to using query as title if title generation fails + if (activeChat.title === "New Chat") { + setActiveChat(prevChat => { + if (!prevChat) return null; + + const updatedChat = { + ...prevChat, + title: query.slice(0, 30) + (query.length > 30 ? '...' : '') + }; + + // Update chats list + setChats(prevChats => + prevChats.map(chat => + chat.id === updatedChat.id ? updatedChat : chat + ) + ); + + return updatedChat; }); - } finally { - setLoading(false); } + } finally { + setIsGeneratingTitle(false); + } + }; + + const handleSendMessage = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); + + if (!inputValue.trim() || !activeChat) return; + + const userMessage: Message = { + id: generateId(), + content: inputValue, + sender: "user", + timestamp: new Date() }; - fetchHomeData(); - }, [toast]); + const loadingMessage: Message = { + id: generateId(), + content: "", + sender: "system", + timestamp: new Date(), + isLoading: true + }; - if (loading) { - return ( -
-
-
+ // Update active chat with new messages + const updatedChat = { + ...activeChat, + messages: [...activeChat.messages, userMessage, loadingMessage], + updatedAt: new Date() + }; + + setActiveChat(updatedChat); + setInputValue(""); + setIsLoading(true); + + try { + // Prepare chat history for the API + const chatHistory: APIMessage[] = updatedChat.messages + .filter(msg => !msg.isLoading && msg.content) // filter out loading messages and empty messages + .slice(0, -1) // exclude the loading message we just added + .map(msg => ({ + role: msg.sender === "user" ? "user" : "assistant", + content: msg.content + })); + + const response = await rulingService.queryRulings({ + query: userMessage.content, + chat_history: chatHistory + }); + + // Replace loading message with actual response + const updatedMessages = updatedChat.messages.map(msg => + msg.id === loadingMessage.id + ? { + ...msg, + content: response.answer, + isLoading: false, + result: response.retrieved_sources + } + : msg + ); + + const finalChat = { + ...updatedChat, + messages: updatedMessages, + }; + + setActiveChat(finalChat); + + // Update chats list + setChats(prevChats => + prevChats.map(chat => + chat.id === finalChat.id ? finalChat : chat + ) + ); + + // Generate title if this is a new chat + if (updatedChat.title === "New Chat" && updatedChat.messages.length <= 3) { + generateChatTitle(userMessage.content); + } + + } catch (error) { + console.error("Error querying rulings:", error); + + // Replace loading message with error + const updatedMessages = updatedChat.messages.map(msg => + msg.id === loadingMessage.id + ? { + ...msg, + content: "I'm sorry, I couldn't process your request. Please try again.", + isLoading: false, + error: true + } + : msg + ); + + setActiveChat({ + ...updatedChat, + messages: updatedMessages + }); + + toast.error("Failed to process your request"); + } finally { + setIsLoading(false); + // Refocus the input after sending message + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }; + + const handleViewSearchResults = (messageId: string) => { + const message = activeChat?.messages.find(msg => msg.id === messageId); + if (message && message.content) { + navigate(`/sources`); + } + }; + + const handleNewChat = () => { + const newChat: Chat = { + id: generateId(), + title: "New Chat", + messages: [ + { + id: generateId(), + content: WELCOME_MESSAGE, + sender: "system" as const, + timestamp: new Date() + } + ], + createdAt: new Date(), + updatedAt: new Date() + }; + + setActiveChat(newChat); + setChats(prev => [newChat, ...prev]); + inputRef.current?.focus(); + setIsSidebarOpen(false); + }; + + const handleSelectChat = (chatId: string) => { + const selectedChat = chats.find(chat => chat.id === chatId); + if (selectedChat) { + setActiveChat(selectedChat); + setIsSidebarOpen(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }; + + const handleDeleteChat = (chatId: string, e: React.MouseEvent) => { + e.stopPropagation(); + + const updatedChats = chats.filter(chat => chat.id !== chatId); + setChats(updatedChats); + + // If we're deleting the active chat, switch to another one + if (activeChat?.id === chatId) { + setActiveChat(updatedChats.length > 0 ? updatedChats[0] : null); + + // If no chats left, create a new one + if (updatedChats.length === 0) { + handleNewChat(); + } + } + }; + + const handleRetryMessage = (messageId: string) => { + if (!activeChat) return; + + // Find the failed message + const failedMessageIndex = activeChat.messages.findIndex( + msg => msg.id === messageId && msg.error ); - } + + if (failedMessageIndex < 0) return; + + // Get the last user message before this failed message + let userMessageContent = ""; + for (let i = failedMessageIndex - 1; i >= 0; i--) { + if (activeChat.messages[i].sender === "user") { + userMessageContent = activeChat.messages[i].content; + break; + } + } + + if (!userMessageContent) return; + + // Remove the failed message + const updatedMessages = [...activeChat.messages]; + updatedMessages[failedMessageIndex] = { + ...updatedMessages[failedMessageIndex], + isLoading: true, + error: false, + content: "" + }; + + const updatedChat = { + ...activeChat, + messages: updatedMessages + }; + + setActiveChat(updatedChat); + setIsLoading(true); + + // Prepare chat history for the API + const chatHistory: APIMessage[] = updatedChat.messages + .filter(msg => !msg.isLoading && msg.content && updatedChat.messages.indexOf(msg) < failedMessageIndex - 1) + .map(msg => ({ + role: msg.sender === "user" ? "user" : "assistant", + content: msg.content + })); + + // Retry the query + rulingService.queryRulings({ + query: userMessageContent, + chat_history: chatHistory + }) + .then(response => { + const finalMessages = [...updatedMessages]; + finalMessages[failedMessageIndex] = { + ...finalMessages[failedMessageIndex], + content: response.answer, + isLoading: false, + error: false, + result: response.retrieved_sources + }; + + const finalChat = { + ...updatedChat, + messages: finalMessages + }; + + setActiveChat(finalChat); + + // Update chats list + setChats(prevChats => + prevChats.map(chat => + chat.id === finalChat.id ? finalChat : chat + ) + ); + }) + .catch(error => { + console.error("Error retrying query:", error); + + const finalMessages = [...updatedMessages]; + finalMessages[failedMessageIndex] = { + ...finalMessages[failedMessageIndex], + content: "I'm sorry, I couldn't process your request. Please try again.", + isLoading: false, + error: true + }; + + setActiveChat({ + ...updatedChat, + messages: finalMessages + }); + + toast.error("Failed to process your request"); + }) + .finally(() => { + setIsLoading(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }); + }; + + const toggleSidebar = () => { + setIsSidebarOpen(!isSidebarOpen); + }; return ( -
- {/* Hero Section */} - {heroContent && ( - - )} - -
- {/* Recent Content Row */} - - - {/* Render genre rows dynamically */} - {genres.map((genre) => ( -
- - +
+ {/* Chat Sidebar */} +
+
+
+
+ +
+ AI +
+

+ Insight AI +

+ + +
+ + {/* navigation */} +
+ {navItems.map((item) => ( + setIsSidebarOpen(false)} + > + + {item.name} + + ))} +
+
+
+ + Recent Chats +
+ +
+
+ +
+ {chats.length === 0 ? ( +
+ No conversations yet. Start a new one! +
+ ) : ( + chats.map(chat => ( +
handleSelectChat(chat.id)} + className={cn( + "flex items-center justify-between p-1 px-4 rounded-lg cursor-pointer group transition-all", + activeChat?.id === chat.id + ? "bg-financial-accent/20 border border-financial-accent/30" + : "hover:bg-muted/50 border border-transparent" + )} + > +
+
+ + {chat.title} + {chat.id === activeChat?.id && isGeneratingTitle && ( + + )} +
+
+ {chat.messages.filter(m => m.sender === "user").length} messages • {format(new Date(chat.updatedAt), "MMM d")} +
+
+ +
+ )) + )} +
+ + {/* Sidebar Footer */} +
+ + + Settings + + + +
+
+
+ + {/* Chat Main Area */} +
+ {/* Mobile Header */} +
+ +
+ + {activeChat?.title || "New Chat"}
- ))} + +
+ + {/* Chat Area */} +
+ {!activeChat ? ( +
+
+ +

Welcome to Insight AI

+

+ Ask me anything, and I'll do my best to help you +

+ +
+
+ ) : ( + <> +
+
+ {activeChat.messages.map((message) => ( + + ))} +
+
+
+ + {/* Input Area */} +
+
+
+
+ setInputValue(e.target.value)} + 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" + disabled={isLoading} + /> + +
+ +
+
+ +
+ Insight AI may produce inaccurate information. Verify important details. +
+
+
+
+ + )} +
); diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index 2e86f115a51610d58fa12a89b12056bfcbb8ae52..4d506b3d736fd166076cebb251ec5a10d942389e 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -1,17 +1,8 @@ -import Navbar from '../components/Navbar'; -import Footer from '../components/Footer'; -import HomePage from './HomePage'; + +import { Navigate } from "react-router-dom"; const Index = () => { - return ( -
- -
- -
-
-
- ); + return ; }; export default Index; diff --git a/frontend/src/pages/MainLayout.tsx b/frontend/src/pages/MainLayout.tsx deleted file mode 100644 index 813e94589c5360594c45e0fba5a94b3ad6738181..0000000000000000000000000000000000000000 --- a/frontend/src/pages/MainLayout.tsx +++ /dev/null @@ -1,18 +0,0 @@ - -import Navbar from '../components/Navbar'; -import Footer from '../components/Footer'; -import { Outlet } from 'react-router-dom'; - -const MainLayout = () => { - return ( -
- -
- -
-
-
- ); -}; - -export default MainLayout; diff --git a/frontend/src/pages/MovieDetailPage.tsx b/frontend/src/pages/MovieDetailPage.tsx deleted file mode 100644 index c1ee8403b5e5c26a6d7e5dd302a7644a0991ae9b..0000000000000000000000000000000000000000 --- a/frontend/src/pages/MovieDetailPage.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useParams, Link } from 'react-router-dom'; -import { Play, Plus, ThumbsUp, Share2 } from 'lucide-react'; -import { getMovieMetadata, getGenresItems, getMovieCard } from '../lib/api'; -import ContentRow from '../components/ContentRow'; -import { useToast } from '@/hooks/use-toast'; - -const MovieDetailPage = () => { - const { title } = useParams<{ title: string }>(); - const [movie, setMovie] = useState(null); - const [loading, setLoading] = useState(true); - const [similarMovies, setSimilarMovies] = useState([]); - const { toast } = useToast(); - - useEffect(() => { - const fetchMovieData = async () => { - if (!title) return; - - try { - setLoading(true); - const data = await getMovieMetadata(title); - setMovie(data); - const movieData = data.data; - console.log(movieData); - - // Fetch similar movies based on individual genres - if (movieData.genres && movieData.genres.length > 0) { - const currentMovieName = movieData.name; - const moviesByGenre = await Promise.all( - movieData.genres.map(async (genre: any) => { - // Pass a single genre name for each call - const genreResult = await getGenresItems([genre.name], 'movie', 10, 1); - if (genreResult.movies && Array.isArray(genreResult.movies)) { - return genreResult.movies.map((movieItem: any) => { - const { title: similarTitle } = movieItem; - // Skip current movie - if (similarTitle === currentMovieName) return null; - return { - type: 'movie', - title: similarTitle, - }; - }); - } - return []; - }) - ); - // Flatten the array of arrays and remove null results - const flattenedMovies = moviesByGenre.flat().filter(Boolean); - // Remove duplicates based on the title - const uniqueMovies = Array.from( - new Map(flattenedMovies.map(movie => [movie.title, movie])).values() - ); - setSimilarMovies(uniqueMovies); - } - } catch (error) { - console.error(`Error fetching movie details for ${title}:`, error); - toast({ - title: "Error loading movie details", - description: "Please try again later", - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - fetchMovieData(); - }, [title, toast]); - - if (loading) { - return ( -
-
-
- ); - } - - if (!movie) { - return ( -
-

Movie Not Found

-

We couldn't find the movie you're looking for.

- - Back to Movies - -
- ); - } - - // Use movieData fields from the new structure - const movieData = movie.data; - const runtime = movieData.runtime - ? `${Math.floor(movieData.runtime / 60)}h ${movieData.runtime % 60}m` - : ''; - const releaseYear = movieData.year || ''; - const movieName = (movieData.translations?.nameTranslations?.find((t: any) => t.language === 'eng')?.name || movieData.name || ''); - const overview = - movieData.overview || - (movieData.translations?.overviewTranslations?.find((t: any) => t.language === 'eng')?.overview || ''); - - return ( -
- {/* Hero backdrop */} -
-
- {movieName} { - const target = e.target as HTMLImageElement; - target.src = '/placeholder.svg'; - }} - /> -
-
-
-
- - {/* Movie details */} -
-
- {/* Poster */} -
- {movieName} { - const target = e.target as HTMLImageElement; - target.src = '/placeholder.svg'; - }} - /> -
- - {/* Details */} -
-

{movieName}

- -
- {releaseYear && {releaseYear}} - {runtime && {runtime}} - {movieData.contentRatings && movieData.contentRatings.length > 0 && ( - - {movieData.contentRatings[0].name}+ - - )} -
- -
- {movieData.genres && - movieData.genres.map((genre: any) => ( - - {genre.name} - - ))} -
- -

{overview}

- -
- - Play - - - - - - - -
- - {/* Additional details */} -
- {movieData.translations?.nameTranslations?.find((t: any) => t.isPrimary) && ( -

- "{movieData.translations.nameTranslations.find((t: any) => t.isPrimary).tagline}" -

- )} - -
- {movieData.production_companies && movieData.production_companies.length > 0 && ( -
-

Production

-

- {movieData.production_companies.map((company: any) => company.name).join(', ')} -

-
- )} - - {movieData.spoken_languages && movieData.spoken_languages.length > 0 && ( -
-

Languages

-

{movieData.spoken_languages.join(', ')}

-
- )} -
-
-
-
- - {/* Similar Movies */} - {similarMovies.length > 0 && ( -
- -
- )} -
-
- ); -}; - -export default MovieDetailPage; diff --git a/frontend/src/pages/MoviePlayerPage.tsx b/frontend/src/pages/MoviePlayerPage.tsx deleted file mode 100644 index 91e317d949966b8a556dc3f7623337575f341ba9..0000000000000000000000000000000000000000 --- a/frontend/src/pages/MoviePlayerPage.tsx +++ /dev/null @@ -1,66 +0,0 @@ - -import React from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import MoviePlayer from '../components/MoviePlayer'; - -const MoviePlayerPage = () => { - const { title } = useParams<{ title: string }>(); - const navigate = useNavigate(); - - const handleBack = () => { - navigate(`/movie/${encodeURIComponent(title || '')}`); - }; - - // Save playback progress to localStorage - const savePlaybackProgress = (currentTime: number, duration: number) => { - if (!title) return; - - try { - const progressKey = `movie-progress-${title}`; - const isCompleted = (currentTime / duration) > 0.9; // Mark as completed if 90% watched - - localStorage.setItem(progressKey, JSON.stringify({ - currentTime, - duration, - lastPlayed: new Date().toISOString(), - completed: isCompleted - })); - } catch (error) { - console.error('Error saving playback progress:', error); - } - }; - - // Fetch stored playback progress from localStorage - const getPlaybackProgress = () => { - if (!title) return 0; - - try { - const progressKey = `movie-progress-${title}`; - const storedProgress = localStorage.getItem(progressKey); - - if (storedProgress) { - const progress = JSON.parse(storedProgress); - if (progress && progress.currentTime && !progress.completed) { - return progress.currentTime; - } - } - } catch (error) { - console.error('Error reading playback progress:', error); - } - - return 0; - }; - - return ( -
- -
- ); -}; - -export default MoviePlayerPage; diff --git a/frontend/src/pages/MoviesPage.tsx b/frontend/src/pages/MoviesPage.tsx deleted file mode 100644 index 3ecf1764baa02ec2b82c5aeeb050557948272139..0000000000000000000000000000000000000000 --- a/frontend/src/pages/MoviesPage.tsx +++ /dev/null @@ -1,92 +0,0 @@ - -import React, { useEffect, useState } from 'react'; -import PageHeader from '../components/PageHeader'; -import ContentGrid from '../components/ContentGrid'; -import { getAllMovies, getMovieCard } from '../lib/api'; -import { useToast } from '@/hooks/use-toast'; -import { useSearchParams } from 'react-router-dom'; - -const MoviesPage = () => { - const [loading, setLoading] = useState(true); - const [movies, setMovies] = useState([]); - const [searchParams] = useSearchParams(); - const genreFilter = searchParams.get('genre'); - const { toast } = useToast(); - - useEffect(() => { - const fetchMovies = async () => { - try { - setLoading(true); - const allMovies = await getAllMovies(); - - // For each movie, get its card info for display - const moviePromises = allMovies.slice(0, 30).map(async (movie: any) => { - try { - const movieInfo = await getMovieCard(movie.title); - if (movieInfo) { - return { - type: 'movie', - title: movie.title, - image: movieInfo.image, - description: movieInfo.overview, - genre: movieInfo.genres?.map((g: any) => g.name) || [], - year: movieInfo.year - }; - } - return null; - } catch (error) { - console.error(`Error fetching movie info for ${movie.title}:`, error); - return null; - } - }); - - let moviesData = await Promise.all(moviePromises); - moviesData = moviesData.filter(movie => movie !== null); - - // Apply genre filter if present - if (genreFilter) { - moviesData = moviesData.filter(movie => - movie.genre.some((g: string) => g.toLowerCase() === genreFilter.toLowerCase()) - ); - } - - setMovies(moviesData); - } catch (error) { - console.error('Error fetching movies:', error); - toast({ - title: "Error loading movies", - description: "Please try again later", - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - fetchMovies(); - }, [genreFilter, toast]); - - return ( -
- - - {loading ? ( -
-
-
- ) : ( - - )} -
- ); -}; - -export default MoviesPage; diff --git a/frontend/src/pages/MyListPage.tsx b/frontend/src/pages/MyListPage.tsx deleted file mode 100644 index fd0fc53ff259da9f7f41709c0521b5911ef29aae..0000000000000000000000000000000000000000 --- a/frontend/src/pages/MyListPage.tsx +++ /dev/null @@ -1,168 +0,0 @@ - -import React, { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import PageHeader from '../components/PageHeader'; -import ContentGrid, { ContentItem } from '../components/ContentGrid'; -import { getAllFromMyList, removeFromMyList } from '../lib/storage'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Plus, TrashIcon } from 'lucide-react'; -import { useToast } from '@/hooks/use-toast'; - -interface MyListItem { - type: 'movie' | 'tvshow'; - title: string; - addedAt: string; -} - -const MyListPage = () => { - const [myListItems, setMyListItems] = useState([]); - const [showRemoveButtons, setShowRemoveButtons] = useState(false); - const [activeTab, setActiveTab] = useState('all'); - const [isLoading, setIsLoading] = useState(true); - const navigate = useNavigate(); - const { toast } = useToast(); - - useEffect(() => { - const fetchMyList = async () => { - try { - setIsLoading(true); - const items = await getAllFromMyList(); - // Sort by most recently added - items.sort((a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()); - setMyListItems(items); - } catch (error) { - console.error("Error loading My List:", error); - } finally { - setIsLoading(false); - } - }; - - fetchMyList(); - }, []); - - const handleRemoveItem = async (title: string, type: 'movie' | 'tvshow') => { - try { - await removeFromMyList(title, type); - setMyListItems(prev => prev.filter(item => !(item.title === title && item.type === type))); - toast({ - title: "Removed from My List", - description: `"${title}" has been removed from your list`, - }); - } catch (error) { - console.error("Error removing item from My List:", error); - toast({ - title: "Error", - description: "Failed to remove item from your list", - variant: "destructive" - }); - } - }; - - const toggleRemoveButtons = () => { - setShowRemoveButtons(!showRemoveButtons); - }; - - const getFilteredItems = (filter: string): ContentItem[] => { - let filtered = myListItems; - if (filter === 'movies') { - filtered = myListItems.filter(item => item.type === 'movie'); - } else if (filter === 'tvshows') { - filtered = myListItems.filter(item => item.type === 'tvshow'); - } - - // Convert to ContentItem format - return filtered.map(item => ({ - type: item.type, - title: item.title, - image: undefined // ContentCard will fetch the image if not provided - })); - }; - - const allItems = getFilteredItems('all'); - const movieItems = getFilteredItems('movies'); - const tvShowItems = getFilteredItems('tvshows'); - - if (isLoading) { - return ( -
-
-
- {[...Array(10)].map((_, i) => ( -
- ))} -
-
- ); - } - - return ( -
-
- - -
- {myListItems.length > 0 && ( - - )} - - -
-
- - {myListItems.length === 0 ? ( -
-
🎬
-

Your list is empty

-

Start adding movies and shows to create your watchlist.

- -
- ) : ( - - - All ({allItems.length}) - Movies ({movieItems.length}) - TV Shows ({tvShowItems.length}) - - - - - - - - - - - - - - - )} -
- ); -}; - -export default MyListPage; diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx index c962034bfa945ea137e773bee642e285501fb572..cff73c27ef29cb206cedac93a0e5ece752f33f31 100644 --- a/frontend/src/pages/NotFound.tsx +++ b/frontend/src/pages/NotFound.tsx @@ -1,15 +1,36 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import { useLocation } from "react-router-dom"; +import { useEffect } from "react"; +import { FileQuestion } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Link } from "react-router-dom"; const NotFound = () => { + const location = useLocation(); + + useEffect(() => { + console.error( + "404 Error: User attempted to access non-existent route:", + location.pathname + ); + }, [location.pathname]); + return ( -
-

404

-

This page could not be found.

- - Back to Home - +
+
+
+ +
+

404

+

+ The page you are looking for could not be found. +

+
+ +
+
); }; diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx deleted file mode 100644 index 7d1d56acdb1b929c1e5d9aa9b4d259f514339a32..0000000000000000000000000000000000000000 --- a/frontend/src/pages/ProfilePage.tsx +++ /dev/null @@ -1,296 +0,0 @@ - -import React, { useState, useEffect } from 'react'; -import PageHeader from '../components/PageHeader'; -import ContentGrid, { ContentItem } from '../components/ContentGrid'; -import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useToast } from '@/hooks/use-toast'; -import { Trash2, DownloadCloud, Upload } from 'lucide-react'; -import { getAllFromMyList } from '../lib/storage'; - -interface WatchHistoryItem { - type: 'movie' | 'tvshow'; - title: string; - lastWatched: string; - progress: number; - completed: boolean; -} - -const ProfilePage = () => { - const [watchHistory, setWatchHistory] = useState([]); - const [myListItems, setMyListItems] = useState([]); - const [activeTab, setActiveTab] = useState('history'); - const { toast } = useToast(); - - // Load watch history from localStorage - useEffect(() => { - const loadWatchHistory = () => { - try { - const history: WatchHistoryItem[] = []; - - // Scan localStorage for movie progress - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key?.startsWith('movie-progress-')) { - const title = key.replace('movie-progress-', ''); - const data = JSON.parse(localStorage.getItem(key) || '{}'); - - if (data && data.lastPlayed) { - history.push({ - type: 'movie', - title, - lastWatched: data.lastPlayed, - progress: Math.round((data.currentTime / data.duration) * 100) || 0, - completed: data.completed || false - }); - } - } - - // Scan for TV show progress - if (key?.startsWith('playback-')) { - const showTitle = key.replace('playback-', ''); - const showData = JSON.parse(localStorage.getItem(key) || '{}'); - - let lastEpisodeDate = ''; - let lastEpisodeProgress = 0; - let anyEpisodeCompleted = false; - - // Find the most recently watched episode - Object.entries(showData).forEach(([_, value]) => { - const episodeData = value as { - lastPlayed: string; - currentTime: number; - duration: number; - completed: boolean; - }; - - if (!lastEpisodeDate || new Date(episodeData.lastPlayed) > new Date(lastEpisodeDate)) { - lastEpisodeDate = episodeData.lastPlayed; - lastEpisodeProgress = Math.round((episodeData.currentTime / episodeData.duration) * 100) || 0; - if (episodeData.completed) anyEpisodeCompleted = true; - } - }); - - if (lastEpisodeDate) { - history.push({ - type: 'tvshow', - title: showTitle, - lastWatched: lastEpisodeDate, - progress: lastEpisodeProgress, - completed: anyEpisodeCompleted - }); - } - } - } - - // Sort by most recently watched - history.sort((a, b) => - new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime() - ); - - setWatchHistory(history); - } catch (error) { - console.error('Error loading watch history:', error); - } - }; - - loadWatchHistory(); - }, []); - - // Load My List items - useEffect(() => { - const loadMyList = async () => { - try { - const items = await getAllFromMyList(); - const contentItems: ContentItem[] = items.map(item => ({ - type: item.type, - title: item.title, - image: undefined // ContentCard component will fetch the image - })); - setMyListItems(contentItems); - } catch (error) { - console.error('Error loading my list:', error); - } - }; - - loadMyList(); - }, []); - - const clearWatchHistory = () => { - // Filter localStorage keys related to watch history - const keysToRemove: string[] = []; - - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && (key.startsWith('movie-progress-') || key.startsWith('playback-'))) { - keysToRemove.push(key); - } - } - - // Remove the keys - keysToRemove.forEach(key => localStorage.removeItem(key)); - - // Update state - setWatchHistory([]); - - toast({ - title: "Watch History Cleared", - description: "Your watch history has been successfully cleared.", - }); - }; - - const exportUserData = () => { - try { - const userData = { - watchHistory: {}, - myList: {} - }; - - // Export all localStorage data - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key) continue; - - if (key.startsWith('movie-progress-') || key.startsWith('playback-')) { - userData.watchHistory[key] = JSON.parse(localStorage.getItem(key) || '{}'); - } - - if (key === 'myList') { - userData.myList = JSON.parse(localStorage.getItem(key) || '[]'); - } - } - - // Create downloadable JSON - const dataStr = JSON.stringify(userData, null, 2); - const blob = new Blob([dataStr], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - - // Create temporary link and trigger download - const a = document.createElement('a'); - a.href = url; - a.download = `streamflix-user-data-${new Date().toISOString().slice(0, 10)}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - toast({ - title: "Export Successful", - description: "Your data has been exported successfully.", - }); - } catch (error) { - console.error('Error exporting user data:', error); - toast({ - title: "Export Failed", - description: "There was an error exporting your data.", - variant: "destructive" - }); - } - }; - - const renderWatchHistoryItems = (): ContentItem[] => { - return watchHistory.map(item => ({ - type: item.type, - title: item.title, - image: undefined // ContentCard will fetch the image - })); - }; - - return ( -
- - -
- - - Watch History - My List - Settings - - - -
-

Watch History

- {watchHistory.length > 0 && ( - - )} -
- - {watchHistory.length === 0 ? ( -
-

You have no watch history yet.

-

Start watching movies and shows to build your history.

-
- ) : ( - - )} -
- - -
-

My List

-
- - {myListItems.length === 0 ? ( -
-

You haven't added anything to your list yet.

-

Browse content and click the "+" icon to add titles to your list.

-
- ) : ( - - )} -
- - -
-
-

Data Management

- -
-
-

Export Your Data

-

Download your watch history and list data as a JSON file.

- -
- -
-

Import Your Data

-

Restore previously exported data (coming soon)

- -
-
-
- -
-

Account Settings

-

Account management features coming soon.

-
-
-
-
-
-
- ); -}; - -export default ProfilePage; diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx deleted file mode 100644 index fad7599482b26065ced5499f995a68c58aaa76ca..0000000000000000000000000000000000000000 --- a/frontend/src/pages/SearchPage.tsx +++ /dev/null @@ -1,199 +0,0 @@ - -import React, { useState, useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { useToast } from '@/hooks/use-toast'; -import { searchAPI } from '../lib/search-api'; -import PageHeader from '../components/PageHeader'; -import ContentGrid from '../components/ContentGrid'; -import ContentCard from '../components/ContentCard'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Loader2 } from 'lucide-react'; - -const SearchPage = () => { - const [searchParams] = useSearchParams(); - const query = searchParams.get('q') || ''; - const [searchResults, setSearchResults] = useState<{ - movies: string[]; - shows: string[]; - episodes: { - series: string; - title: string; - path: string; - season: string; - }[]; - }>({ - movies: [], - shows: [], - episodes: [] - }); - const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState('all'); - const { toast } = useToast(); - - useEffect(() => { - const fetchSearchResults = async () => { - if (!query) return; - - try { - setLoading(true); - const results = await searchAPI.search(query); - setSearchResults({ - movies: results.films || [], - shows: results.series || [], - episodes: results.episodes || [] - }); - } catch (error) { - console.error('Search error:', error); - toast({ - title: "Search Failed", - description: "Unable to perform search. Please try again.", - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - fetchSearchResults(); - }, [query, toast]); - - const getTotalResultsCount = () => { - return searchResults.movies.length + searchResults.shows.length + searchResults.episodes.length; - }; - - const renderMovieResults = () => { - if (searchResults.movies.length === 0) { - return

No movies found matching "{query}"

; - } - - return ( -
- {searchResults.movies.map((title, index) => ( - - ))} -
- ); - }; - - const renderShowResults = () => { - if (searchResults.shows.length === 0) { - return

No TV shows found matching "{query}"

; - } - - return ( -
- {searchResults.shows.map((title, index) => ( - - ))} -
- ); - }; - - const renderEpisodeResults = () => { - if (searchResults.episodes.length === 0) { - return

No episodes found matching "{query}"

; - } - - return ( -
- {searchResults.episodes.map((episode, index) => ( -
-

{episode.title}

-

- {episode.series} • Season {episode.season} -

-
- ))} -
- ); - }; - - return ( -
- - - {loading ? ( -
- -
- ) : query ? ( - - - - All Results ({getTotalResultsCount()}) - - - Movies ({searchResults.movies.length}) - - - TV Shows ({searchResults.shows.length}) - - - Episodes ({searchResults.episodes.length}) - - - - - {getTotalResultsCount() === 0 ? ( -

No results found matching "{query}"

- ) : ( - <> - {searchResults.movies.length > 0 && ( -
-

Movies

- {renderMovieResults()} -
- )} - - {searchResults.shows.length > 0 && ( -
-

TV Shows

- {renderShowResults()} -
- )} - - {searchResults.episodes.length > 0 && ( -
-

Episodes

- {renderEpisodeResults()} -
- )} - - )} -
- - - {renderMovieResults()} - - - - {renderShowResults()} - - - - {renderEpisodeResults()} - -
- ) : ( -
-

Enter a search term to find movies, TV shows, and episodes.

-
- )} -
- ); -}; - -export default SearchPage; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c3b04d0d2c5f057e3e2baeb483df621659768afb --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,272 @@ +import { useState, useRef } from "react"; +import { Settings, Moon, Sun, Globe, Shield, Database, Cloud } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; +import { storage, STORAGE_KEYS } from "@/lib/storage"; + +const SettingsPage = () => { + const [theme, setTheme] = useState(() => { + return storage.get(STORAGE_KEYS.THEME) || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + }); + + const [apiEndpoint, setApiEndpoint] = useState( + storage.get(STORAGE_KEYS.API_ENDPOINT) || "http://localhost:8000" + ); + + const fileInputRef = useRef(null); + + const handleThemeChange = (newTheme: string) => { + setTheme(newTheme); + + const root = window.document.documentElement; + + if (newTheme === "dark") { + root.classList.add("dark"); + } else if (newTheme === "light") { + root.classList.remove("dark"); + } else { + // System theme + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + } + + storage.set(STORAGE_KEYS.THEME, newTheme); + toast.success("Theme updated successfully"); + }; + + const handleSaveEndpoint = () => { + storage.set(STORAGE_KEYS.API_ENDPOINT, apiEndpoint); + toast.success("API endpoint saved successfully"); + }; + + const handleClearChats = () => { + storage.set(STORAGE_KEYS.CHATS, []); + toast.success("Chat history cleared successfully"); + }; + + const handleClearSources = () => { + storage.set(STORAGE_KEYS.SOURCES, []); + toast.success("Sources cleared successfully"); + }; + + const handleResetSettings = () => { + storage.remove(STORAGE_KEYS.THEME); + storage.remove(STORAGE_KEYS.API_ENDPOINT); + + setApiEndpoint("http://localhost:8000"); + setTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + + toast.success("Settings reset to defaults"); + }; + + const handleExportStorage = () => { + const data = storage.export(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = "insight-storage-export.json"; + a.click(); + URL.revokeObjectURL(url); + + toast.success("Storage exported successfully"); + }; + + const handleImportStorage = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + try { + const result = e.target?.result as string; + const data = JSON.parse(result); + if (storage.import(data)) { + toast.success("Storage imported successfully. Please refresh the page."); + } else { + toast.error("Failed to import storage."); + } + } catch { + toast.error("Invalid file format."); + } + }; + reader.readAsText(file); + event.target.value = ""; + }; + + return ( +
+
+

+ + System Settings +

+ +
+ + + + + Appearance + + + Customize how Insight AI looks + + + + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + + + API Configuration + + + Configure connection to the financial rulings database + + + +
+ +
+ setApiEndpoint(e.target.value)} + placeholder="http://localhost:8000" + className="glass-input" + /> + +
+
+
+
+ + + + + + Privacy & Data + + + Manage your data and privacy settings + + + +
+

Data Management

+
+ + + + + +
+
+
+
+ + + + + + Storage Export / Import + + + Backup or restore your application data + + + +
+ + + +
+
+ Export will download your data as a JSON file. Import will overwrite your current data with the file contents. +
+
+
+
+
+
+ ); +}; + +export default SettingsPage; diff --git a/frontend/src/pages/SourcesPage.tsx b/frontend/src/pages/SourcesPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1f9b99109ecb1bf50eda738c3a09db93826ecbed --- /dev/null +++ b/frontend/src/pages/SourcesPage.tsx @@ -0,0 +1,286 @@ + +import { useState, useEffect } from "react"; +import { FileText, Search, Filter, Calendar, ArrowUpDown, ChevronDown, ChevronUp, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar as CalendarComponent } from "@/components/ui/calendar"; +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { format } from "date-fns"; +import { DateRange } from "@/types"; +import { RetrievedSource } from "@/services/rulingService"; +import { storage, STORAGE_KEYS } from "@/lib/storage"; + +const SourcesPage = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedSource, setSelectedSource] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(""); + const [date, setDate] = useState({ + from: undefined, + to: undefined, + }); + const [sortBy, setSortBy] = useState("date-desc"); + const [sources, setSources] = useState([]); + const [sourceTypes, setSourceTypes] = useState([]); + const [categories, setCategories] = useState([]); + + // Load sources from storage + useEffect(() => { + const storedSources = storage.get(STORAGE_KEYS.SOURCES) || []; + setSources(storedSources); + + // Extract unique source types and categories + const types = Array.from(new Set(storedSources.map(s => s.metadata?.source).filter(Boolean))); + setSourceTypes(types as string[]); + + const cats = Array.from(new Set(storedSources.map(s => { + // For this example, we'll use the first word of the content as a mock category + // In a real app, you'd use a proper category field from metadata + const firstWord = s.content_snippet.split(' ')[0]; + return firstWord.length > 3 ? firstWord : "General"; + }))); + setCategories(cats); + }, []); + + // Filter sources based on search term, source, category, and date + const filteredSources = sources.filter(source => { + const matchesSearch = !searchTerm || + source.content_snippet.toLowerCase().includes(searchTerm.toLowerCase()) || + (source.metadata?.source || "").toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesSource = !selectedSource || source.metadata?.source === selectedSource; + + // Mock category matching based on first word of content + const sourceCategory = source.content_snippet.split(' ')[0].length > 3 ? + source.content_snippet.split(' ')[0] : "General"; + const matchesCategory = !selectedCategory || sourceCategory === selectedCategory; + + let matchesDate = true; + if (date.from && source.metadata?.ruling_date) { + matchesDate = matchesDate && new Date(source.metadata.ruling_date) >= date.from; + } + if (date.to && source.metadata?.ruling_date) { + matchesDate = matchesDate && new Date(source.metadata.ruling_date) <= date.to; + } + + return matchesSearch && matchesSource && matchesCategory && matchesDate; + }); + + // Sort sources + const sortedSources = [...filteredSources].sort((a, b) => { + if (sortBy === "date-desc") { + return new Date(b.metadata?.ruling_date || "").getTime() - + new Date(a.metadata?.ruling_date || "").getTime(); + } else if (sortBy === "date-asc") { + return new Date(a.metadata?.ruling_date || "").getTime() - + new Date(b.metadata?.ruling_date || "").getTime(); + } else if (sortBy === "relevance-desc") { + return b.content_snippet.length - a.content_snippet.length; + } + return 0; + }); + + const resetFilters = () => { + setSearchTerm(""); + setSelectedSource(""); + setSelectedCategory(""); + setDate({ from: undefined, to: undefined }); + }; + + const [expandedSources, setExpandedSources] = useState>({}); + + const toggleSource = (index: number) => { + setExpandedSources(prev => ({ + ...prev, + [index]: !prev[index] + })); + }; + + return ( +
+
+

+ Financial Sources +

+ +
+ +
+
+ +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10 glass-input" + /> +
+ + + + + + + + + + + { + if (value) setDate(value); + }} + className={cn("p-3 pointer-events-auto")} + /> + + + + +
+
+ +
+ {sortedSources.length > 0 ? ( + sortedSources.map((source, index) => ( + + +
+
+ + {source.metadata?.source || "Financial Ruling"} + +
+ + {source.metadata?.source || "Unknown Source"} + {source.metadata?.ruling_date && ( + <> + + + {new Date(source.metadata.ruling_date).toLocaleDateString()} + + )} +
+
+ +
+
+ +

+ {expandedSources[index] + ? source.content_snippet + : source.content_snippet.substring(0, 150) + "..."} +

+ {!expandedSources[index] && ( +
+ )} +
+ {expandedSources[index] && ( + + + + )} +
+ )) + ) : ( +
+
+ +
+

No sources found

+

+ {sources.length > 0 + ? "No sources match your current search criteria. Try adjusting your filters." + : "Chat with Insight AI to get information with source citations."} +

+ +
+ )} +
+
+ ); +}; + +export default SourcesPage; diff --git a/frontend/src/pages/TvShowDetailPage.tsx b/frontend/src/pages/TvShowDetailPage.tsx deleted file mode 100644 index 44346ac940bd1a5b19fa51df3609e18a5beea172..0000000000000000000000000000000000000000 --- a/frontend/src/pages/TvShowDetailPage.tsx +++ /dev/null @@ -1,469 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useParams, Link } from 'react-router-dom'; -import { Play, Plus, ThumbsUp, Share2, ChevronDown } from 'lucide-react'; -import { getTvShowMetadata, getGenresItems } from '../lib/api'; -import ContentRow from '../components/ContentRow'; -import { useToast } from '@/hooks/use-toast'; - -interface Episode { - episode_number: number; - name: string; - overview: string; - still_path: string; - air_date: string; - runtime: number; - fileName?: string; // The actual file name with extension -} - -interface Season { - season_number: number; - name: string; - overview: string; - poster_path: string; - air_date: string; - episodes: Episode[]; -} - -interface FileStructureItem { - type: string; - path: string; - contents?: FileStructureItem[]; - size?: number; -} - -const TvShowDetailPage = () => { - const { title } = useParams<{ title: string }>(); - const [tvShow, setTvShow] = useState(null); - const [seasons, setSeasons] = useState([]); - const [selectedSeason, setSelectedSeason] = useState(1); - const [episodes, setEpisodes] = useState([]); - const [loading, setLoading] = useState(true); - const [seasonsLoading, setSeasonsLoading] = useState(false); - const [similarShows, setSimilarShows] = useState([]); - const [expandedSeasons, setExpandedSeasons] = useState(false); - const { toast } = useToast(); - - // Helper function to extract episode info from file path - const extractEpisodeInfoFromPath = (filePath: string): Episode | null => { - // Get the actual file name (with extension) from the full file path - const fileName = filePath.split('/').pop() || filePath; - // For file names like "Nanbaka - S01E02 - The Inmates Are Stupid! The Guards Are Kind of Stupid, Too! SDTV.mp4" - const episodeRegex = /S(\d+)E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i; - const match = fileName.match(episodeRegex); - - if (match) { - const episodeNumber = parseInt(match[2], 10); - const episodeName = match[3].trim(); - - // Determine quality from the file name - const isHD = fileName.toLowerCase().includes('720p') || - fileName.toLowerCase().includes('1080p') || - fileName.toLowerCase().includes('hdtv'); - - return { - episode_number: episodeNumber, - name: episodeName, - overview: '', // No overview available from file path - still_path: '/placeholder.svg', // Use placeholder image - air_date: '', // No air date available - runtime: isHD ? 24 : 22, // Approximate runtime based on quality - fileName: fileName // Store only the file name with extension - }; - } - - return null; - }; - - // Helper function to extract season number and name from directory path - const getSeasonInfoFromPath = (path: string): { number: number, name: string } => { - const seasonRegex = /Season\s*(\d+)/i; - const specialsRegex = /Specials/i; - - if (specialsRegex.test(path)) { - return { number: 0, name: 'Specials' }; - } - - const match = path.match(seasonRegex); - if (match) { - return { - number: parseInt(match[1], 10), - name: `Season ${match[1]}` - }; - } - - return { number: 1, name: 'Season 1' }; // Default if no match - }; - - // Process the file structure to extract seasons and episodes - const processTvShowFileStructure = (fileStructure: any): Season[] => { - if (!fileStructure || !fileStructure.contents) { - return []; - } - - const extractedSeasons: Season[] = []; - - // Find season directories - const seasonDirectories = fileStructure.contents.filter( - (item: FileStructureItem) => item.type === 'directory' - ); - - seasonDirectories.forEach((seasonDir: FileStructureItem) => { - if (!seasonDir.contents) return; - - const seasonInfo = getSeasonInfoFromPath(seasonDir.path); - const episodesArr: Episode[] = []; - - // Process files in this season directory - seasonDir.contents.forEach((item: FileStructureItem) => { - if (item.type === 'file') { - const episode = extractEpisodeInfoFromPath(item.path); - if (episode) { - episodesArr.push(episode); - } - } - }); - - // Sort episodes by episode number - episodesArr.sort((a, b) => a.episode_number - b.episode_number); - - if (episodesArr.length > 0) { - extractedSeasons.push({ - season_number: seasonInfo.number, - name: seasonInfo.name, - overview: '', // No overview available - poster_path: tvShow?.data?.image || '/placeholder.svg', - air_date: tvShow?.data?.year || '', - episodes: episodesArr - }); - } - }); - - // Sort seasons by season number - extractedSeasons.sort((a, b) => a.season_number - b.season_number); - return extractedSeasons; - }; - - useEffect(() => { - const fetchTvShowData = async () => { - if (!title) return; - - try { - setLoading(true); - const data = await getTvShowMetadata(title); - setTvShow(data); - - if (data && data.file_structure) { - const processedSeasons = processTvShowFileStructure(data.file_structure); - setSeasons(processedSeasons); - - // Select the first season by default (Specials = 0, Season 1 = 1) - if (processedSeasons.length > 0) { - setSelectedSeason(processedSeasons[0].season_number); - } - } - - - // Fetch similar shows based on individual genres - if (data.data && data.data.genres && data.data.genres.length > 0) { - const currentShowName = data.data.name; - const showsByGenre = await Promise.all( - data.data.genres.map(async (genre: any) => { - // Pass a single genre name for each call - const genreResult = await getGenresItems([genre.name], 'series', 10, 1); - console.log('Genre result:', genreResult); - if (genreResult.series && Array.isArray(genreResult.series)) { - return genreResult.series.map((showItem: any) => { - const { title: similarTitle } = showItem; - console.log('Similar show:', showItem); - // Skip current show - if (similarTitle === currentShowName) return null; - return { - type: 'tvshow', - title: similarTitle, - }; - }); - } - return []; - }) - ); - - // Flatten the array of arrays and remove null results - const flattenedShows = showsByGenre.flat().filter(Boolean); - // Remove duplicates based on the title - const uniqueShows = Array.from( - new Map(flattenedShows.map(show => [show.title, show])).values() - ); - setSimilarShows(uniqueShows); - } - } catch (error) { - console.error(`Error fetching TV show details for ${title}:`, error); - toast({ - title: "Error loading TV show details", - description: "Please try again later", - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - fetchTvShowData(); - }, [title, toast]); - - // Update episodes when selectedSeason or seasons change - useEffect(() => { - if (seasons.length > 0) { - const season = seasons.find(s => s.season_number === selectedSeason); - if (season) { - setEpisodes(season.episodes); - } else { - setEpisodes([]); - } - } - }, [selectedSeason, seasons]); - - const toggleExpandSeasons = () => { - setExpandedSeasons(!expandedSeasons); - }; - - if (loading) { - return ( -
-
-
- ); - } - - if (!tvShow) { - return ( -
-

TV Show Not Found

-

We couldn't find the TV show you're looking for.

- - Back to TV Shows - -
- ); - } - - const tvShowData = tvShow.data; - const airYears = tvShowData.year; - const language = tvShowData.originalLanguage; - const showName = (tvShowData.translations?.nameTranslations?.find((t: any) => t.language === 'eng')?.name || tvShowData.name || ''); - const overview = - tvShowData.translations?.overviewTranslations?.find((t: any) => t.language === 'eng')?.overview || - tvShowData.translations?.overviewTranslations?.[0]?.overview || - tvShowData.overview || - 'No overview available.'; - - // Get the current season details - const currentSeason = seasons.find(s => s.season_number === selectedSeason); - const currentSeasonName = currentSeason?.name || `Season ${selectedSeason}`; - - return ( -
- {/* Hero backdrop */} -
-
- {showName} { - const target = e.target as HTMLImageElement; - target.src = '/placeholder.svg'; - }} - /> -
-
-
-
- - {/* TV Show details */} -
-
- {/* Poster */} -
- {showName} { - const target = e.target as HTMLImageElement; - target.src = '/placeholder.svg'; - }} - /> -
- - {/* Details */} -
-

{showName}

- -
- {airYears && {airYears}} - {tvShowData.vote_average && ( - - {tvShowData.vote_average.toFixed(1)} - - )} - {seasons.length > 0 && ( - {seasons.length} Season{seasons.length !== 1 ? 's' : ''} - )} -
- -
- {tvShowData.genres && tvShowData.genres.map((genre: any, index: number) => ( - - {genre.name || genre} - - ))} -
- -

{overview}

- -
- - Play - - - - - - - -
- - {/* Additional details */} -
- {language && ( -
-

Language

-

{language}

-
- )} - {tvShowData.translations?.nameTranslations?.find((t: any) => t.isPrimary) && ( -
-

Tagline

-

- "{tvShowData.translations.nameTranslations.find((t: any) => t.isPrimary).tagline || ''}" -

-
- )} -
-
-
- - {/* Episodes */} -
-
-
-

Episodes

- -
- - - {expandedSeasons && ( -
- {seasons.map((season) => ( - - ))} -
- )} -
-
-
- -
- {seasonsLoading ? ( -
-
-
- ) : episodes.length === 0 ? ( -
- No episodes available for this season. -
- ) : ( - episodes.map((episode) => ( -
- -
- {episode.name} { - const target = e.target as HTMLImageElement; - target.src = '/placeholder.svg'; - }} - /> -
- -
-
- {episode.runtime ? `${episode.runtime} min` : '--'} -
-
- -
-
-

- {episode.episode_number}. {episode.name} -

- - {episode.air_date ? new Date(episode.air_date).toLocaleDateString() : ''} - -
-

- {episode.overview || 'No description available.'} -

-
- -
- )) - )} -
-
- - {/* Similar Shows */} - {similarShows.length > 0 && ( -
- -
- )} -
-
- ); -}; - -export default TvShowDetailPage; diff --git a/frontend/src/pages/TvShowPlayerPage.tsx b/frontend/src/pages/TvShowPlayerPage.tsx deleted file mode 100644 index 771872d8380c96da2a5a7f99d8baff34003681b1..0000000000000000000000000000000000000000 --- a/frontend/src/pages/TvShowPlayerPage.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; -import { getTvShowMetadata } from '../lib/api'; -import { useToast } from '@/hooks/use-toast'; -import TVShowPlayer from '../components/TVShowPlayer'; -import EpisodesPanel from '../components/EpisodesPanel'; - -interface FileStructureItem { - type: string; - path: string; - contents?: FileStructureItem[]; - size?: number; -} - -interface Episode { - episode_number: number; - name: string; - overview: string; - still_path: string; - air_date: string; - runtime: number; - fileName?: string; // The actual file name with extension -} - -interface Season { - season_number: number; - name: string; - episodes: Episode[]; -} - -interface PlaybackProgress { - [key: string]: { - currentTime: number; - duration: number; - lastPlayed: string; - completed: boolean; - }; -} - -const TvShowPlayerPage = () => { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [showInfo, setShowInfo] = useState(null); - const [showName, setShowName] = useState(''); - const [seasons, setSeasons] = useState([]); - const [selectedSeason, setSelectedSeason] = useState(''); - const [selectedEpisode, setSelectedEpisode] = useState(''); - const [activeEpisodeIndex, setActiveEpisodeIndex] = useState(0); - const [activeSeasonIndex, setActiveSeasonIndex] = useState(0); - const [showEpisodeSelector, setShowEpisodeSelector] = useState(false); - const [playbackProgress, setPlaybackProgress] = useState({}); - const [needsReload, setNeedsReload] = useState(false); // Flag to trigger video reload - - const { title } = useParams<{ title: string }>(); - const [searchParams] = useSearchParams(); - const seasonParam = searchParams.get('season'); - const episodeParam = searchParams.get('episode'); - - const navigate = useNavigate(); - const { toast } = useToast(); - - // Helper function to extract episode info from file path - const extractEpisodeInfoFromPath = (filePath: string): Episode | null => { - const fileName = filePath.split('/').pop() || filePath; - const episodeRegex = /S(\d+)E(\d+)\s*-\s*(.+?)(?=\.\w+$)/i; - const match = fileName.match(episodeRegex); - - if (match) { - const episodeNumber = parseInt(match[2], 10); - const episodeName = match[3].trim(); - const isHD = fileName.toLowerCase().includes('720p') || - fileName.toLowerCase().includes('1080p') || - fileName.toLowerCase().includes('hdtv'); - - return { - episode_number: episodeNumber, - name: episodeName, - overview: '', - still_path: '/placeholder.svg', - air_date: '', - runtime: isHD ? 24 : 22, - fileName: fileName - }; - } - - return null; - }; - - // Helper function to extract season info from directory path - const getSeasonInfoFromPath = (path: string): { number: number, name: string } => { - const seasonRegex = /Season\s*(\d+)/i; - const specialsRegex = /Specials/i; - - if (specialsRegex.test(path)) { - return { number: 0, name: 'Specials' }; - } - - const match = path.match(seasonRegex); - if (match) { - return { - number: parseInt(match[1], 10), - name: `Season ${match[1]}` - }; - } - - return { number: 1, name: 'Season 1' }; - }; - - // Process the file structure to extract seasons and episodes - const processTvShowFileStructure = (fileStructure: any): Season[] => { - if (!fileStructure || !fileStructure.contents) { - return []; - } - - const extractedSeasons: Season[] = []; - - // Find season directories - const seasonDirectories = fileStructure.contents.filter( - (item: FileStructureItem) => item.type === 'directory' - ); - - seasonDirectories.forEach((seasonDir: FileStructureItem) => { - if (!seasonDir.contents) return; - - const seasonInfo = getSeasonInfoFromPath(seasonDir.path); - const episodesArr: Episode[] = []; - - // Process files in this season directory - seasonDir.contents.forEach((item: FileStructureItem) => { - if (item.type === 'file') { - const episode = extractEpisodeInfoFromPath(item.path); - if (episode) { - episodesArr.push(episode); - } - } - }); - - // Sort episodes by episode number - episodesArr.sort((a, b) => a.episode_number - b.episode_number); - - if (episodesArr.length > 0) { - extractedSeasons.push({ - season_number: seasonInfo.number, - name: seasonInfo.name, - episodes: episodesArr - }); - } - }); - - // Sort seasons by season number - extractedSeasons.sort((a, b) => a.season_number - b.season_number); - return extractedSeasons; - }; - - // Select first available episode when none is specified - const selectFirstAvailableEpisode = (seasons: Season[]) => { - if (seasons.length === 0) return; - - // First try to find Season 1 - const regularSeason = seasons.find(s => s.season_number === 1); - // If not available, use the first available season (could be Specials/Season 0) - const firstSeason = regularSeason || seasons[0]; - - if (firstSeason && firstSeason.episodes.length > 0) { - setSelectedSeason(firstSeason.name); - setSelectedEpisode(firstSeason.episodes[0].fileName || ''); - setActiveSeasonIndex(seasons.indexOf(firstSeason)); - setActiveEpisodeIndex(0); - } - }; - - // Load playback progress from localStorage - const loadPlaybackProgress = () => { - try { - const storedProgress = localStorage.getItem(`playback-${title}`); - if (storedProgress) { - setPlaybackProgress(JSON.parse(storedProgress)); - } - } catch (error) { - console.error("Failed to load playback progress:", error); - } - }; - - // Save playback progress to localStorage - const savePlaybackProgress = (episodeId: string, currentTime: number, duration: number, completed: boolean = false) => { - try { - const newProgress = { - ...playbackProgress, - [episodeId]: { - currentTime, - duration, - lastPlayed: new Date().toISOString(), - completed - } - }; - - localStorage.setItem(`playback-${title}`, JSON.stringify(newProgress)); - setPlaybackProgress(newProgress); - } catch (error) { - console.error("Failed to save playback progress:", error); - } - }; - - // Function to load the next episode and reset the video - const loadNextEpisode = () => { - if (!seasons.length) return; - - const currentSeason = seasons[activeSeasonIndex]; - if (!currentSeason) return; - - // If there's another episode in the current season - if (activeEpisodeIndex < currentSeason.episodes.length - 1) { - const nextEpisode = currentSeason.episodes[activeEpisodeIndex + 1]; - setSelectedEpisode(nextEpisode.fileName || ''); - setActiveEpisodeIndex(activeEpisodeIndex + 1); - setNeedsReload(true); // Flag to reload the video - - // Update URL without page reload - navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(currentSeason.name)}&episode=${encodeURIComponent(nextEpisode.fileName || '')}`, { replace: true }); - - toast({ - title: "Playing Next Episode", - description: `${nextEpisode.name}`, - }); - } - // If there's another season available - else if (activeSeasonIndex < seasons.length - 1) { - const nextSeason = seasons[activeSeasonIndex + 1]; - if (nextSeason.episodes.length > 0) { - const firstEpisode = nextSeason.episodes[0]; - setSelectedSeason(nextSeason.name); - setSelectedEpisode(firstEpisode.fileName || ''); - setActiveSeasonIndex(activeSeasonIndex + 1); - setActiveEpisodeIndex(0); - setNeedsReload(true); // Flag to reload the video - - // Update URL without page reload - navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(nextSeason.name)}&episode=${encodeURIComponent(firstEpisode.fileName || '')}`, { replace: true }); - - toast({ - title: "Starting Next Season", - description: `${nextSeason.name}: ${firstEpisode.name}`, - }); - } - } else { - toast({ - title: "End of Series", - description: "You've watched all available episodes.", - }); - } - }; - - // Watch for changes in seasons or episodes selection - useEffect(() => { - if (seasons.length && selectedSeason && selectedEpisode) { - // Find active season and episode indexes - const seasonIndex = seasons.findIndex(s => s.name === selectedSeason); - if (seasonIndex >= 0) { - setActiveSeasonIndex(seasonIndex); - - const episodeIndex = seasons[seasonIndex].episodes.findIndex( - e => e.fileName === selectedEpisode - ); - if (episodeIndex >= 0) { - setActiveEpisodeIndex(episodeIndex); - } - } - } - }, [seasons, selectedSeason, selectedEpisode]); - - useEffect(() => { - const fetchData = async () => { - if (!title) return; - - try { - setLoading(true); - setError(null); - - // Load saved playback progress - loadPlaybackProgress(); - - // Get TV show metadata first - const showData = await getTvShowMetadata(title); - setShowInfo(showData); - console.log('TV Show Metadata:', showData); - setShowName(showInfo?.data?.translations?.nameTranslations?.find((t: any) => t.language === 'eng')?.name || showInfo?.name || ''); - // Process seasons and episodes from file structure - if (showData && showData.file_structure) { - const processedSeasons = processTvShowFileStructure(showData.file_structure); - setSeasons(processedSeasons); - - // Set selected season and episode from URL params or select first available - if (seasonParam && episodeParam) { - setSelectedSeason(seasonParam); - setSelectedEpisode(episodeParam); - } else { - selectFirstAvailableEpisode(processedSeasons); - } - } - } catch (error) { - console.error(`Error fetching metadata for ${title}:`, error); - setError('Failed to load episode data'); - toast({ - title: "Error Loading Data", - description: "Please try again later", - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - fetchData(); - }, [title, seasonParam, episodeParam, toast]); - - const handleBack = () => { - navigate(`/tv-show/${encodeURIComponent(title || '')}`); - }; - - const getEpisodeNumber = () => { - if (!selectedEpisode) return "1"; - - const episodeMatch = selectedEpisode.match(/E(\d+)/i); - return episodeMatch ? episodeMatch[1] : "1"; - }; - - const handleSelectEpisode = (seasonName: string, episode: Episode) => { - // Only reload if we're changing episodes - if (selectedEpisode !== episode.fileName) { - setSelectedSeason(seasonName); - setSelectedEpisode(episode.fileName || ''); - setNeedsReload(true); - - // Update URL - navigate(`/tv-show/${encodeURIComponent(title || '')}/watch?season=${encodeURIComponent(seasonName)}&episode=${encodeURIComponent(episode.fileName || '')}`, { replace: true }); - } - - setShowEpisodeSelector(false); - }; - - const handleProgressUpdate = (currentTime: number, duration: number) => { - if (!selectedEpisode || !title) return; - - const episodeId = `${selectedSeason}-${selectedEpisode}`; - const isCompleted = (currentTime / duration) > 0.9; // Mark as completed if watched 90% - - savePlaybackProgress(episodeId, currentTime, duration, isCompleted); - }; - - const getStartTime = () => { - if (!selectedSeason || !selectedEpisode) return 0; - - const episodeId = `${selectedSeason}-${selectedEpisode}`; - const progress = playbackProgress[episodeId]; - - if (progress && !progress.completed) { - return progress.currentTime; - } - return 0; - }; - - const episodeTitle = showInfo - ? `${showInfo.data?.name} ${selectedSeason}E${getEpisodeNumber()}` - : `Episode`; - // Reset needs reload flag when video has been updated - useEffect(() => { - if (needsReload) { - setNeedsReload(false); - } - }, [selectedEpisode, selectedSeason]); - - if (loading) { - return ( -
-
-
- ); - } - - // To force reload of video component when changing episodes - const tvShowPlayerKey = `${selectedSeason}-${selectedEpisode}-${needsReload ? 'reload' : 'loaded'}`; - - return ( -
- {/* Episodes panel */} - {showEpisodeSelector && ( -
setShowEpisodeSelector(false)}> -
e.stopPropagation()} - > - setShowEpisodeSelector(false)} - showTitle={showName || 'Episodes'} - /> -
-
- )} - - {/* TV Show Player component with key to force reload */} - setShowEpisodeSelector(true)} - /> -
- ); -}; - -export default TvShowPlayerPage; diff --git a/frontend/src/pages/TvShowsPage.tsx b/frontend/src/pages/TvShowsPage.tsx deleted file mode 100644 index fcbf60f80a520412fd6f054e37bf11ed142a5df0..0000000000000000000000000000000000000000 --- a/frontend/src/pages/TvShowsPage.tsx +++ /dev/null @@ -1,93 +0,0 @@ - -import React, { useEffect, useState } from 'react'; -import PageHeader from '../components/PageHeader'; -import ContentGrid from '../components/ContentGrid'; -import { getAllTvShows, getTvShowCard } from '../lib/api'; -import { useToast } from '@/hooks/use-toast'; -import { useSearchParams } from 'react-router-dom'; - -const TvShowsPage = () => { - const [loading, setLoading] = useState(true); - const [tvShows, setTvShows] = useState([]); - const [searchParams] = useSearchParams(); - const genreFilter = searchParams.get('genre'); - const { toast } = useToast(); - - useEffect(() => { - const fetchTvShows = async () => { - try { - setLoading(true); - const allTvShows = await getAllTvShows(); - - // For each tv show, get its card info for display - const tvShowPromises = allTvShows.slice(0, 30).map(async (show: any) => { - try { - const showInfo = await getTvShowCard(show.title); - if (showInfo) { - return { - type: 'tvshow', - title: show.title, - image: showInfo.image, - description: showInfo.overview, - genre: showInfo.genres?.map((g: any) => g.name) || [], - year: showInfo.year, - episodeCount: show.episodeCount - }; - } - return null; - } catch (error) { - console.error(`Error fetching tv show info for ${show.title}:`, error); - return null; - } - }); - - let tvShowsData = await Promise.all(tvShowPromises); - tvShowsData = tvShowsData.filter(show => show !== null); - - // Apply genre filter if present - if (genreFilter) { - tvShowsData = tvShowsData.filter(show => - show.genre.some((g: string) => g.toLowerCase() === genreFilter.toLowerCase()) - ); - } - - setTvShows(tvShowsData); - } catch (error) { - console.error('Error fetching TV shows:', error); - toast({ - title: "Error loading TV shows", - description: "Please try again later", - variant: "destructive" - }); - } finally { - setLoading(false); - } - }; - - fetchTvShows(); - }, [genreFilter, toast]); - - return ( -
- - - {loading ? ( -
-
-
- ) : ( - - )} -
- ); -}; - -export default TvShowsPage; diff --git a/frontend/src/services/rulingService.ts b/frontend/src/services/rulingService.ts new file mode 100644 index 0000000000000000000000000000000000000000..4662633933e4998ac083ede59c460828558cb973 --- /dev/null +++ b/frontend/src/services/rulingService.ts @@ -0,0 +1,136 @@ + +import { toast } from "@/components/ui/sonner"; +import { storage, STORAGE_KEYS } from "@/lib/storage"; + +// Define types matching our API +export interface SourceMetadata { + source?: string; + ruling_date?: string; + [key: string]: string | undefined; +} + +export interface RetrievedSource { + content_snippet: string; + metadata?: SourceMetadata; +} + +export interface QueryResponse { + answer: string; + retrieved_sources?: RetrievedSource[]; +} + +export interface TitleResponse { + title: string; +} + +export interface Message { + role: "user" | "assistant" | "system"; + content: string; +} + +export interface QueryRequest { + query: string; + chat_history?: Message[]; + filters?: Record; +} + +// Add a delay function for better UX when showing loading states +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export const rulingService = { + async getApiUrl(): Promise { + // Get API URL from storage or use default + let API_URL = "http://localhost:8000"; + const storedUrl = storage.get(STORAGE_KEYS.API_ENDPOINT); + + if (storedUrl) { + API_URL = storedUrl; + } else { + // Set default if not found + storage.set(STORAGE_KEYS.API_ENDPOINT, API_URL); + } + + return API_URL; + }, + + async queryRulings(request: QueryRequest): Promise { + try { + // Add a slight delay to make loading states more visible for demo purposes + await delay(1000); + + const API_URL = await this.getApiUrl(); + + const response = await fetch(`${API_URL}/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.detail || `Error: ${response.status} ${response.statusText}`; + toast.error(errorMessage); + throw new Error(errorMessage); + } + + const result = await response.json(); + + // Store sources in storage for later use on sources page + if (result.retrieved_sources && result.retrieved_sources.length > 0) { + // Get existing sources or initialize empty array + const existingSources = storage.get(STORAGE_KEYS.SOURCES) || []; + + // Merge new sources, avoid duplicates based on content + const updatedSources = [...existingSources]; + + result.retrieved_sources.forEach((source: RetrievedSource) => { + const exists = existingSources.some( + existing => existing.content_snippet === source.content_snippet + ); + + if (!exists) { + updatedSources.push(source); + } + }); + + // Store updated sources + storage.set(STORAGE_KEYS.SOURCES, updatedSources); + } + + return result; + } catch (error) { + console.error("Failed to query rulings:", error); + const errorMessage = error instanceof Error ? error.message : "Failed to query rulings"; + toast.error(errorMessage); + throw error; + } + }, + + async generateTitle(query: string): Promise { + try { + const API_URL = await this.getApiUrl(); + + const response = await fetch(`${API_URL}/generate-title`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.detail || `Error: ${response.status} ${response.statusText}`; + throw new Error(errorMessage); + } + + return await response.json(); + } catch (error) { + console.error("Failed to generate title:", error); + // Return a default title instead of throwing + return { title: "New Chat" }; + } + } +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..26da8c27c6893c38e8184055c2aa9b05c6a8cb74 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,20 @@ + +// Filter types +export interface DateRange { + from: Date | undefined; + to: Date | undefined; +} + +export interface SearchFilters { + rulingDate?: DateRange; + source?: string; + [key: string]: any; +} + +// Query history type +export interface QueryHistoryItem { + id: string; + query: string; + timestamp: Date; + filters?: SearchFilters; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 15bde57be1a013b045fd226fdcb5204823edd50c..0d038668ff074d92d28f9672001171c9b3bb2689 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -1,135 +1,166 @@ -import type { Config } from 'tailwindcss'; +import type { Config } from "tailwindcss"; -const config = { - darkMode: ["class"], - content: [ - './pages/**/*.{ts,tsx}', - './components/**/*.{ts,tsx}', - './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', - ], - prefix: "", - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - // Netflix-inspired palette - "netflix-red": "#E50914", - "netflix-black": "#141414", - "netflix-dark-gray": "#181818", - "netflix-gray": "#808080", - "netflix-light-gray": "#b3b3b3", - - // Theme colors (can be customized) - "theme-primary": "var(--theme-primary, #E50914)", - "theme-primary-hover": "var(--theme-primary-hover, #B81D24)", - "theme-primary-light": "var(--theme-primary-light, #F5222D)", - "theme-secondary": "var(--theme-secondary, #6D6D6D)", - "theme-background": "var(--theme-background, #141414)", - "theme-background-dark": "var(--theme-background-dark, #0A0A0A)", - "theme-background-light": "var(--theme-background-light, #181818)", - "theme-surface": "var(--theme-surface, #222222)", - "theme-text": "var(--theme-text, #FFFFFF)", - "theme-text-secondary": "var(--theme-text-secondary, #B3B3B3)", - "theme-border": "var(--theme-border, #303030)", - "theme-divider": "var(--theme-divider, #2D2D2D)", - "theme-error": "var(--theme-error, #FF574D)", - "theme-warning": "var(--theme-warning, #FFB01F)", - "theme-success": "var(--theme-success, #48BB78)", - "theme-info": "var(--theme-info, #38B2AC)" - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, - "fade-in": { - "0%": { opacity: "0", transform: "translateY(10px)" }, - "100%": { opacity: "1", transform: "translateY(0)" } - }, - "fade-out": { - "0%": { opacity: "1", transform: "translateY(0)" }, - "100%": { opacity: "0", transform: "translateY(10px)" } - }, - "scale-in": { - "0%": { transform: "scale(0.95)", opacity: "0" }, - "100%": { transform: "scale(1)", opacity: "1" } - }, - "scale-out": { - from: { transform: "scale(1)", opacity: "1" }, - to: { transform: "scale(0.95)", opacity: "0" } - }, - "slide-in": { - "0%": { transform: "translateX(100%)" }, - "100%": { transform: "translateX(0)" } - }, - "slide-out": { - "0%": { transform: "translateX(0)" }, - "100%": { transform: "translateX(100%)" } - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - "fade-in": "fade-in 0.3s ease-out forwards", - "fade-out": "fade-out 0.3s ease-out forwards", - "scale-in": "scale-in 0.2s ease-out", - "scale-out": "scale-out 0.2s ease-out", - "slide-in": "slide-in 0.3s ease-out", - "slide-out": "slide-out 0.3s ease-out", - }, - }, - }, - plugins: [require("tailwindcss-animate")], +export default { + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + ], + prefix: "", + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + sidebar: { + DEFAULT: 'hsl(var(--sidebar-background))', + foreground: 'hsl(var(--sidebar-foreground))', + primary: 'hsl(var(--sidebar-primary))', + 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', + accent: 'hsl(var(--sidebar-accent))', + 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', + border: 'hsl(var(--sidebar-border))', + ring: 'hsl(var(--sidebar-ring))' + }, + financial: { + 'navy': '#1e2b47', + 'blue': '#3a5a80', + 'light-blue': '#7aa0c9', + 'gray': '#f5f7fa', + 'accent': '#7868e6', + 'light-accent': '#b8b5ff', + 'dark-gray': '#2c3142', + 'cool-gray': '#9ca3af' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + heading: ['Space Grotesk', 'sans-serif'] + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + }, + 'fade-in': { + '0%': { + opacity: '0', + transform: 'translateY(10px)' + }, + '100%': { + opacity: '1', + transform: 'translateY(0)' + } + }, + 'pulse-subtle': { + '0%, 100%': { + opacity: '1' + }, + '50%': { + opacity: '0.8' + } + }, + 'slide-in': { + '0%': { + transform: 'translateY(20px)', + opacity: '0' + }, + '100%': { + transform: 'translateY(0)', + opacity: '1' + } + }, + 'bounce-in': { + '0%': { + transform: 'scale(0.95)', + opacity: '0' + }, + '70%': { + transform: 'scale(1.05)' + }, + '100%': { + transform: 'scale(1)', + opacity: '1' + } + }, + 'shimmer': { + '100%': { + transform: 'translateX(100%)' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + 'fade-in': 'fade-in 0.5s ease-out forwards', + 'pulse-subtle': 'pulse-subtle 2s ease-in-out infinite', + 'slide-in': 'slide-in 0.3s ease-out forwards', + 'bounce-in': 'bounce-in 0.4s ease-out', + 'shimmer': 'shimmer 2s infinite linear' + }, + backdropFilter: { + 'none': 'none', + 'blur': 'blur(20px)' + } + } + }, + plugins: [require("tailwindcss-animate")], } satisfies Config; - -export default config;