diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index eb155e3..fb0fca2 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 6cf7293..95ef260 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -7,6 +7,11 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
+ {
+ href: '/watch-hub',
+ icon: icon.mdiPlayCircle,
+ label: 'Watch Hub',
+ },
{
href: '/users/users-list',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 542c6a1..59df183 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,166 +1,145 @@
-
-import React, { useEffect, useState } from 'react';
-import type { ReactElement } from 'react';
+import {
+ mdiBookmarkOutline,
+ mdiCheckCircleOutline,
+ mdiFire,
+ mdiPlayCircle,
+ mdiStarOutline,
+ mdiTelevisionClassic,
+} from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
+import type { ReactElement } from 'react';
+import React from 'react';
import BaseButton from '../components/BaseButton';
-import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
+import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
-import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+const featureCards = [
+ {
+ icon: mdiPlayCircle,
+ title: 'Browse & search titles',
+ description: 'Explore a movie and TV catalog with a cinematic layout built for quick discovery.',
+ },
+ {
+ icon: mdiBookmarkOutline,
+ title: 'Build personal watchlists',
+ description: 'Save titles into your own queue and keep a clear next-up list instead of scattered notes.',
+ },
+ {
+ icon: mdiCheckCircleOutline,
+ title: 'Track watching progress',
+ description: 'Move titles from plan to watch, to watching, to watched with a clean, focused workflow.',
+ },
+];
+
+const highlightPills = [
+ { icon: mdiFire, label: 'Trending rails' },
+ { icon: mdiStarOutline, label: 'Recommendations' },
+ { icon: mdiTelevisionClassic, label: 'Movies + TV shows' },
+];
export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('video');
- const [contentPosition, setContentPosition] = useState('left');
- const textColor = useAppSelector((state) => state.style.linkColor);
-
- const title = 'Movies & TV Tracker'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
-
return (
-
+ <>
-
{getPageTitle('Starter Page')}
+
{getPageTitle('Movies & TV Tracker')}
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
-
This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-
For guides and documentation please check
- your local README.md and the Flatlogic documentation
+
+
+
-
-
+
+
+
+
+ Your own movie & TV app starts here
+
+
+ Track what to watch next with a dark, youthful, cinema-inspired product shell.
+
+
+ Turn the seed SaaS app into a viewer-facing experience: discover titles, save them to a personal watchlist,
+ mark progress, and keep a polished admin back office for managing the catalog.
+
+
+
+
+
+
+
+
+ {highlightPills.map((pill) => (
+
+
+ {pill.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
First MVP slice
+
Watch hub
+
+
Live workflow
+
+
+
+ {featureCards.map((feature, index) => (
+
+
+
+
+
+
+
Step {index + 1}
+
{feature.title}
+
+
+
{feature.description}
+
+ ))}
+
+
+
+
+
+
+
+ Built for movie nights, TV binges, and a cleaner personal watch queue.
+
+
+ Privacy Policy
+
+
+ Terms of Use
+
+
+
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
-
-
+ >
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
return {page} ;
};
-
diff --git a/frontend/src/pages/watch-hub.tsx b/frontend/src/pages/watch-hub.tsx
new file mode 100644
index 0000000..98f8da9
--- /dev/null
+++ b/frontend/src/pages/watch-hub.tsx
@@ -0,0 +1,1115 @@
+import {
+ mdiBookmarkOutline,
+ mdiCheckCircleOutline,
+ mdiClockOutline,
+ mdiFire,
+ mdiMagnify,
+ mdiMovieOpen,
+ mdiOpenInNew,
+ mdiPlayCircle,
+ mdiStarOutline,
+ mdiTelevisionClassic,
+} from '@mdi/js';
+import axios from 'axios';
+import Head from 'next/head';
+import Link from 'next/link';
+import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
+import BaseButton from '../components/BaseButton';
+import BaseIcon from '../components/BaseIcon';
+import CardBox from '../components/CardBox';
+import NotificationBar from '../components/NotificationBar';
+import SectionMain from '../components/SectionMain';
+import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
+import { getPageTitle } from '../config';
+import LayoutAuthenticated from '../layouts/Authenticated';
+import { useAppSelector } from '../stores/hooks';
+
+type BannerType = 'success' | 'danger' | 'warning' | 'info';
+type TitleType = 'all' | 'movie' | 'tv_show';
+type WatchStatus = 'plan_to_watch' | 'watching' | 'watched' | 'on_hold' | 'dropped';
+
+type TitleRecord = {
+ id: string;
+ title_type: 'movie' | 'tv_show';
+ name: string;
+ tagline?: string;
+ overview?: string;
+ release_date?: string;
+ first_air_date?: string;
+ runtime_minutes?: number;
+ number_of_seasons?: number;
+ average_rating?: number | string;
+ popularity_score?: number | string;
+ is_trending?: boolean;
+};
+
+type WatchlistRecord = {
+ id: string;
+ name: string;
+ description?: string;
+};
+
+type WatchlistItemRecord = {
+ id: string;
+ status: WatchStatus;
+ note?: string;
+ watched_at?: string;
+ title?: TitleRecord;
+};
+
+type TitleDetailRecord = TitleRecord & {
+ cast: string[];
+ crew: string[];
+ genres: string[];
+ videos: Array<{ id: string; name: string; video_url?: string; site?: string }>;
+};
+
+const watchStatusOptions: Array<{
+ value: WatchStatus;
+ label: string;
+ tone: string;
+}> = [
+ { value: 'plan_to_watch', label: 'Plan to watch', tone: 'bg-slate-500/15 text-slate-200 border border-slate-400/20' },
+ { value: 'watching', label: 'Watching', tone: 'bg-blue-500/15 text-blue-200 border border-blue-400/20' },
+ { value: 'watched', label: 'Watched', tone: 'bg-emerald-500/15 text-emerald-200 border border-emerald-400/20' },
+ { value: 'on_hold', label: 'On hold', tone: 'bg-amber-500/15 text-amber-100 border border-amber-400/20' },
+ { value: 'dropped', label: 'Dropped', tone: 'bg-rose-500/15 text-rose-200 border border-rose-400/20' },
+];
+
+const typeFilters: Array<{ value: TitleType; label: string }> = [
+ { value: 'all', label: 'All titles' },
+ { value: 'movie', label: 'Movies' },
+ { value: 'tv_show', label: 'TV shows' },
+];
+
+const formatDate = (value?: string) => {
+ if (!value) return 'TBA';
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return 'TBA';
+
+ return new Intl.DateTimeFormat('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ }).format(date);
+};
+
+const getReleaseLabel = (title: TitleRecord) => {
+ return title.title_type === 'tv_show' ? formatDate(title.first_air_date) : formatDate(title.release_date);
+};
+
+const getTypeLabel = (titleType: TitleRecord['title_type']) => {
+ return titleType === 'tv_show' ? 'TV Show' : 'Movie';
+};
+
+const getTypeIcon = (titleType: TitleRecord['title_type']) => {
+ return titleType === 'tv_show' ? mdiTelevisionClassic : mdiMovieOpen;
+};
+
+export default function WatchHub() {
+ const { currentUser } = useAppSelector((state) => state.auth);
+
+ const [catalog, setCatalog] = useState([]);
+ const [catalogLoading, setCatalogLoading] = useState(false);
+ const [catalogQuery, setCatalogQuery] = useState('');
+ const [catalogType, setCatalogType] = useState('all');
+
+ const [watchlists, setWatchlists] = useState([]);
+ const [selectedWatchlistId, setSelectedWatchlistId] = useState('');
+ const [watchlistsLoading, setWatchlistsLoading] = useState(false);
+ const [watchlistItems, setWatchlistItems] = useState([]);
+ const [watchlistItemsLoading, setWatchlistItemsLoading] = useState(false);
+
+ const [highlightTrending, setHighlightTrending] = useState([]);
+ const [highlightRecommended, setHighlightRecommended] = useState([]);
+
+ const [selectedTitleSummary, setSelectedTitleSummary] = useState(null);
+ const [selectedTitleDetails, setSelectedTitleDetails] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+
+ const [watchlistForm, setWatchlistForm] = useState({
+ name: 'My First Watchlist',
+ description: 'Everything I want to watch next.',
+ });
+ const [createLoading, setCreateLoading] = useState(false);
+ const [actionTitleId, setActionTitleId] = useState('');
+ const [itemActionId, setItemActionId] = useState('');
+ const [banner, setBanner] = useState<{ type: BannerType; message: string } | null>(null);
+
+ const selectedWatchlist = useMemo(
+ () => watchlists.find((watchlist) => watchlist.id === selectedWatchlistId) || null,
+ [selectedWatchlistId, watchlists],
+ );
+
+ const loadCatalog = useCallback(async () => {
+ setCatalogLoading(true);
+
+ try {
+ const response = await axios.get('/titles', {
+ params: {
+ page: 0,
+ limit: 18,
+ ...(catalogQuery ? { name: catalogQuery } : {}),
+ ...(catalogType !== 'all' ? { title_type: catalogType } : {}),
+ },
+ });
+
+ setCatalog(Array.isArray(response.data.rows) ? response.data.rows : []);
+ } catch (error) {
+ console.error('Failed to load title catalog:', error);
+ setBanner({
+ type: 'danger',
+ message: 'We could not load the title catalog right now. Please refresh and try again.',
+ });
+ } finally {
+ setCatalogLoading(false);
+ }
+ }, [catalogQuery, catalogType]);
+
+ const loadHighlights = useCallback(async () => {
+ try {
+ const [trendingResponse, recommendedResponse] = await Promise.all([
+ axios.get('/titles', {
+ params: {
+ page: 0,
+ limit: 6,
+ is_trending: true,
+ },
+ }),
+ axios.get('/titles', {
+ params: {
+ page: 0,
+ limit: 6,
+ field: 'average_rating',
+ sort: 'desc',
+ },
+ }),
+ ]);
+
+ setHighlightTrending(Array.isArray(trendingResponse.data.rows) ? trendingResponse.data.rows : []);
+ setHighlightRecommended(
+ Array.isArray(recommendedResponse.data.rows) ? recommendedResponse.data.rows : [],
+ );
+ } catch (error) {
+ console.error('Failed to load highlight rails:', error);
+ }
+ }, []);
+
+ const loadWatchlists = useCallback(
+ async (preferredWatchlistId?: string) => {
+ if (!currentUser?.id) return;
+
+ setWatchlistsLoading(true);
+
+ try {
+ const response = await axios.get('/watchlists', {
+ params: {
+ page: 0,
+ limit: 50,
+ user: currentUser.id,
+ },
+ });
+
+ const rows = Array.isArray(response.data.rows) ? response.data.rows : [];
+ setWatchlists(rows);
+
+ const nextSelectedId =
+ preferredWatchlistId ||
+ rows.find((watchlist: WatchlistRecord) => watchlist.id === selectedWatchlistId)?.id ||
+ rows[0]?.id ||
+ '';
+
+ setSelectedWatchlistId(nextSelectedId);
+ } catch (error) {
+ console.error('Failed to load watchlists:', error);
+ setBanner({
+ type: 'danger',
+ message: 'Your watchlists could not be loaded. Please try again in a moment.',
+ });
+ } finally {
+ setWatchlistsLoading(false);
+ }
+ },
+ [currentUser?.id, selectedWatchlistId],
+ );
+
+ const loadWatchlistItems = useCallback(async (watchlistId: string) => {
+ if (!watchlistId) {
+ setWatchlistItems([]);
+ return;
+ }
+
+ setWatchlistItemsLoading(true);
+
+ try {
+ const response = await axios.get('/watchlist_items', {
+ params: {
+ page: 0,
+ limit: 100,
+ watchlist: watchlistId,
+ },
+ });
+
+ setWatchlistItems(Array.isArray(response.data.rows) ? response.data.rows : []);
+ } catch (error) {
+ console.error('Failed to load watchlist items:', error);
+ setBanner({
+ type: 'danger',
+ message: 'We could not load the selected watchlist. Please try again.',
+ });
+ } finally {
+ setWatchlistItemsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!currentUser?.id) return;
+
+ loadWatchlists();
+ loadHighlights();
+ }, [currentUser?.id, loadHighlights, loadWatchlists]);
+
+ useEffect(() => {
+ if (!currentUser?.id) return;
+
+ const timeoutId = window.setTimeout(() => {
+ loadCatalog();
+ }, 250);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [currentUser?.id, loadCatalog]);
+
+ useEffect(() => {
+ if (!selectedWatchlistId) {
+ setWatchlistItems([]);
+ return;
+ }
+
+ loadWatchlistItems(selectedWatchlistId);
+ }, [loadWatchlistItems, selectedWatchlistId]);
+
+ useEffect(() => {
+ if (!selectedTitleSummary?.id) {
+ setSelectedTitleDetails(null);
+ return;
+ }
+
+ let isMounted = true;
+
+ const loadTitleDetails = async () => {
+ setDetailLoading(true);
+
+ try {
+ const [titleResponse, genresResponse, castResponse, crewResponse, videosResponse] = await Promise.all([
+ axios.get(`/titles/${selectedTitleSummary.id}`),
+ axios.get('/title_genres', {
+ params: {
+ page: 0,
+ limit: 20,
+ title: selectedTitleSummary.id,
+ },
+ }),
+ axios.get('/title_cast', {
+ params: {
+ page: 0,
+ limit: 8,
+ title: selectedTitleSummary.id,
+ },
+ }),
+ axios.get('/title_crew', {
+ params: {
+ page: 0,
+ limit: 6,
+ title: selectedTitleSummary.id,
+ },
+ }),
+ axios.get('/videos', {
+ params: {
+ page: 0,
+ limit: 6,
+ title: selectedTitleSummary.id,
+ },
+ }),
+ ]);
+
+ if (!isMounted) return;
+
+ setSelectedTitleDetails({
+ ...titleResponse.data,
+ genres: (genresResponse.data.rows || [])
+ .map((row: { genre?: { name?: string } }) => row.genre?.name)
+ .filter(Boolean),
+ cast: (castResponse.data.rows || [])
+ .map((row: { person?: { name?: string } }) => row.person?.name)
+ .filter(Boolean),
+ crew: (crewResponse.data.rows || [])
+ .map((row: { person?: { name?: string; firstName?: string } }) => row.person?.name || row.person?.firstName)
+ .filter(Boolean),
+ videos: (videosResponse.data.rows || []).map(
+ (row: { id: string; name: string; video_url?: string; site?: string }) => ({
+ id: row.id,
+ name: row.name,
+ video_url: row.video_url,
+ site: row.site,
+ }),
+ ),
+ });
+ } catch (error) {
+ console.error('Failed to load title details:', error);
+ if (isMounted) {
+ setBanner({
+ type: 'danger',
+ message: 'The title details could not be loaded. Please choose another title.',
+ });
+ }
+ } finally {
+ if (isMounted) {
+ setDetailLoading(false);
+ }
+ }
+ };
+
+ loadTitleDetails();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [selectedTitleSummary?.id]);
+
+ const handleCreateWatchlist = async (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (!currentUser?.id) return;
+
+ if (!watchlistForm.name.trim()) {
+ setBanner({ type: 'warning', message: 'Give your watchlist a name before creating it.' });
+ return;
+ }
+
+ setCreateLoading(true);
+
+ try {
+ await axios.post('/watchlists', {
+ data: {
+ name: watchlistForm.name.trim(),
+ description: watchlistForm.description.trim(),
+ visibility: 'private',
+ is_public: false,
+ user: currentUser.id,
+ },
+ });
+
+ await loadWatchlists();
+ setBanner({
+ type: 'success',
+ message: `“${watchlistForm.name.trim()}” is ready. Add a title to start your watch journey.`,
+ });
+ } catch (error) {
+ console.error('Failed to create watchlist:', error);
+ setBanner({
+ type: 'danger',
+ message: 'We could not create your watchlist. Please try again.',
+ });
+ } finally {
+ setCreateLoading(false);
+ }
+ };
+
+ const handleAddToWatchlist = async (title: TitleRecord) => {
+ if (!selectedWatchlistId) {
+ setBanner({
+ type: 'warning',
+ message: 'Create or select a watchlist before saving a title.',
+ });
+ return;
+ }
+
+ const existingItem = watchlistItems.find((item) => item.title?.id === title.id);
+ if (existingItem) {
+ setSelectedTitleSummary(existingItem.title || title);
+ setBanner({
+ type: 'info',
+ message: `${title.name} is already in your watchlist. Update its status from the list on the right.`,
+ });
+ return;
+ }
+
+ setActionTitleId(title.id);
+
+ try {
+ await axios.post('/watchlist_items', {
+ data: {
+ watchlist: selectedWatchlistId,
+ title: title.id,
+ status: 'plan_to_watch',
+ sort_order: watchlistItems.length + 1,
+ added_at: new Date().toISOString(),
+ },
+ });
+
+ await loadWatchlistItems(selectedWatchlistId);
+ setSelectedTitleSummary(title);
+ setBanner({
+ type: 'success',
+ message: `${title.name} was saved to ${selectedWatchlist?.name || 'your watchlist'}.`,
+ });
+ } catch (error) {
+ console.error('Failed to add item to watchlist:', error);
+ setBanner({
+ type: 'danger',
+ message: 'We could not save this title to your watchlist. Please try again.',
+ });
+ } finally {
+ setActionTitleId('');
+ }
+ };
+
+ const handleStatusChange = async (item: WatchlistItemRecord, status: WatchStatus) => {
+ setItemActionId(item.id);
+
+ try {
+ await axios.put(`/watchlist_items/${item.id}`, {
+ id: item.id,
+ data: {
+ status,
+ watched_at: status === 'watched' ? new Date().toISOString() : null,
+ },
+ });
+
+ await loadWatchlistItems(selectedWatchlistId);
+ setBanner({
+ type: 'success',
+ message: `${item.title?.name || 'Title'} is now marked as ${watchStatusOptions.find((option) => option.value === status)?.label.toLowerCase()}.`,
+ });
+ } catch (error) {
+ console.error('Failed to update watchlist item status:', error);
+ setBanner({
+ type: 'danger',
+ message: 'We could not update this watch status. Please try again.',
+ });
+ } finally {
+ setItemActionId('');
+ }
+ };
+
+ const handleRemoveItem = async (item: WatchlistItemRecord) => {
+ setItemActionId(item.id);
+
+ try {
+ await axios.delete(`/watchlist_items/${item.id}`);
+ await loadWatchlistItems(selectedWatchlistId);
+
+ if (selectedTitleSummary?.id === item.title?.id) {
+ setSelectedTitleSummary(null);
+ }
+
+ setBanner({
+ type: 'success',
+ message: `${item.title?.name || 'Title'} was removed from this watchlist.`,
+ });
+ } catch (error) {
+ console.error('Failed to remove watchlist item:', error);
+ setBanner({
+ type: 'danger',
+ message: 'We could not remove this title. Please try again.',
+ });
+ } finally {
+ setItemActionId('');
+ }
+ };
+
+ const watchStats = useMemo(() => {
+ return watchStatusOptions.map((option) => ({
+ ...option,
+ total: watchlistItems.filter((item) => item.status === option.value).length,
+ }));
+ }, [watchlistItems]);
+
+ const selectedTitleAlreadySaved = useMemo(() => {
+ if (!selectedTitleSummary?.id) return false;
+
+ return watchlistItems.some((item) => item.title?.id === selectedTitleSummary.id);
+ }, [selectedTitleSummary?.id, watchlistItems]);
+
+ return (
+ <>
+
+ {getPageTitle('Watch Hub')}
+
+
+
+
+
+
+
+ {banner && (
+
+ {banner.message}
+
+ )}
+
+
+
+
+
+
+
+ Modern cinematic workflow
+
+
+ Browse trending titles, save what matters, and turn scattered picks into a watch plan.
+
+
+ This first slice gives you a real consumer flow: discover a title, add it to your personal watchlist,
+ track its status, and inspect the details that help you decide what to watch next.
+
+
+
+
+ Manage watchlists
+
+
+
+ View saved items
+
+
+
+
+
+
+
+ {watchStats.map((stat) => (
+
+
{stat.label}
+
+ {stat.total}
+ status
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
Discover titles
+
+ Search the seeded catalog, explore trending picks, and save a title into your next-up queue.
+
+
+
+ {typeFilters.map((filter) => (
+ setCatalogType(filter.value)}
+ className={`rounded-full border px-4 py-2 text-sm transition ${
+ catalogType === filter.value
+ ? 'border-violet-400 bg-violet-500/15 text-white'
+ : 'border-white/10 bg-white/5 text-slate-300 hover:bg-white/10'
+ }`}
+ >
+ {filter.label}
+
+ ))}
+
+
+
+
+
+ setCatalogQuery(event.target.value)}
+ placeholder='Search by title name or vibe...'
+ className='w-full border-0 bg-transparent text-sm text-white outline-none placeholder:text-slate-500'
+ />
+
+
+
+ {catalogLoading &&
+ Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+ {!catalogLoading &&
+ catalog.map((title) => {
+ const isSaved = watchlistItems.some((item) => item.title?.id === title.id);
+
+ return (
+
setSelectedTitleSummary(title)}
+ className='group overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(145deg,rgba(15,23,42,0.95),rgba(88,28,135,0.2))] p-5 text-left transition hover:-translate-y-1 hover:border-violet-400/40 hover:shadow-xl hover:shadow-violet-950/40'
+ >
+
+
+
+
+ {getTypeLabel(title.title_type)}
+
+
{title.name}
+
+ {title.tagline || title.overview || 'No synopsis yet — open the detail panel for more context.'}
+
+
+ {title.is_trending && (
+
+
+ Trending
+
+ )}
+
+
+
+
+
Release
+
{getReleaseLabel(title)}
+
+
+
Rating
+
+
+ {Number(title.average_rating || 0).toFixed(1)}
+
+
+
+
Length
+
+ {title.title_type === 'tv_show'
+ ? `${title.number_of_seasons || 0} season${title.number_of_seasons === 1 ? '' : 's'}`
+ : `${title.runtime_minutes || 0} min`}
+
+
+
+
+
+
+ {isSaved ? 'Already saved' : 'Ready to save'}
+
+ {
+ event.stopPropagation();
+ handleAddToWatchlist(title);
+ }}
+ />
+
+
+ );
+ })}
+
+
+ {!catalogLoading && !catalog.length && (
+
+
No titles matched your search.
+
Try a different keyword or switch back to all titles.
+
+ )}
+
+
+
+
+
+
+
Trending now
+
Fast picks pulled from titles flagged as trending.
+
+
+
+
+ {highlightTrending.map((title) => (
+
setSelectedTitleSummary(title)}
+ className='flex w-full items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-left transition hover:bg-white/5'
+ >
+
+
{title.name}
+
+ {getTypeLabel(title.title_type)}
+
+
+ {Number(title.popularity_score || 0).toFixed(0)}
+
+ ))}
+ {!highlightTrending.length &&
Trending titles will appear here once loaded.
}
+
+
+
+
+
+
+
Recommended tonight
+
Top-rated options for when you want a reliable pick.
+
+
+
+
+ {highlightRecommended.map((title) => (
+
setSelectedTitleSummary(title)}
+ className='flex w-full items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-left transition hover:bg-white/5'
+ >
+
+
{title.name}
+
+ {getTypeLabel(title.title_type)}
+
+
+
+
+ {Number(title.average_rating || 0).toFixed(1)}
+
+
+ ))}
+ {!highlightRecommended.length &&
Recommendations will appear here once loaded.
}
+
+
+
+
+
+
+
+
+
+
My watchlist
+
Create a personal queue, then move titles from plan to watched.
+
+
+
+
+
+
+
+
Your watchlists
+
+ {watchlistsLoading &&
Loading your watchlists...
}
+ {!watchlistsLoading && !watchlists.length && (
+
+ You have not created a watchlist yet. Start with one above, then save your first title.
+
+ )}
+ {watchlists.map((watchlist) => (
+
setSelectedWatchlistId(watchlist.id)}
+ className={`rounded-2xl border px-4 py-3 text-left transition ${
+ selectedWatchlistId === watchlist.id
+ ? 'border-violet-400 bg-violet-500/10'
+ : 'border-white/10 bg-black/20 hover:bg-white/5'
+ }`}
+ >
+ {watchlist.name}
+ {watchlist.description || 'No description yet.'}
+
+ ))}
+
+
+
+
+
+
Saved titles
+ {selectedWatchlist && (
+
+ {selectedWatchlist.name}
+
+ )}
+
+
+ {watchlistItemsLoading &&
Loading titles in this watchlist...
}
+
+ {!watchlistItemsLoading && selectedWatchlistId && !watchlistItems.length && (
+
+ No saved titles yet. Add one from the discovery cards to start tracking progress.
+
+ )}
+
+
+ {watchlistItems.map((item) => {
+ const statusTone = watchStatusOptions.find((option) => option.value === item.status)?.tone || '';
+
+ return (
+
+
+
setSelectedTitleSummary(item.title || null)} className='text-left'>
+ {item.title?.name || 'Untitled'}
+
+ {item.title ? getReleaseLabel(item.title) : 'No release date'}
+
+
+
+ {watchStatusOptions.find((option) => option.value === item.status)?.label || 'Unknown'}
+
+
+
+
+ {watchStatusOptions.map((option) => (
+ handleStatusChange(item, option.value)}
+ className={`rounded-full px-3 py-1 text-xs font-medium transition ${
+ item.status === option.value
+ ? option.tone
+ : 'border border-white/10 bg-white/5 text-slate-300 hover:bg-white/10'
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+ {item.watched_at ? `Finished ${formatDate(item.watched_at)}` : 'Not finished yet'}
+
+
handleRemoveItem(item)}
+ className='text-sm font-medium text-rose-200 transition hover:text-rose-100'
+ >
+ {itemActionId === item.id ? 'Updating...' : 'Remove'}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
Title detail
+
Select a title to inspect its synopsis, cast, genres, and trailer links.
+
+ {selectedTitleSummary && (
+
handleAddToWatchlist(selectedTitleSummary)}
+ />
+ )}
+
+
+ {!selectedTitleSummary && (
+
+
+
+
+
Choose a title from the catalog.
+
We will show the key details here so you can decide whether it belongs in your queue.
+
+ )}
+
+ {selectedTitleSummary && detailLoading && (
+
+ )}
+
+ {selectedTitleDetails && !detailLoading && (
+
+
+
+
+
+
+ {getTypeLabel(selectedTitleDetails.title_type)}
+
+
{selectedTitleDetails.name}
+
{selectedTitleDetails.tagline || 'No tagline available yet.'}
+
+ {selectedTitleDetails.is_trending && (
+
Hot right now
+ )}
+
+
+
+
+
Release
+
{getReleaseLabel(selectedTitleDetails)}
+
+
+
Rating
+
{Number(selectedTitleDetails.average_rating || 0).toFixed(1)}
+
+
+
Runtime
+
+
+ {selectedTitleDetails.title_type === 'tv_show'
+ ? `${selectedTitleDetails.number_of_seasons || 0} seasons`
+ : `${selectedTitleDetails.runtime_minutes || 0} min`}
+
+
+
+
+
+
+
+
Overview
+
+ {selectedTitleDetails.overview || 'No overview available yet.'}
+
+
+
+
+
+
Genres
+
+ {selectedTitleDetails.genres.length ? (
+ selectedTitleDetails.genres.map((genre) => (
+
+ {genre}
+
+ ))
+ ) : (
+ No genres linked yet.
+ )}
+
+
+
+
Cast
+
+ {selectedTitleDetails.cast.length ? (
+ selectedTitleDetails.cast.map((person) => (
+
+ {person}
+
+ ))
+ ) : (
+ No cast linked yet.
+ )}
+
+
+
+
+
+
Crew highlights
+
+ {selectedTitleDetails.crew.length ? (
+ selectedTitleDetails.crew.map((person) => (
+
+ {person}
+
+ ))
+ ) : (
+ No crew members linked yet.
+ )}
+
+
+
+
+
+
+ )}
+
+
+
+
+ >
+ );
+}
+
+WatchHub.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};