From c2e2a1bb5f65e2e517a4fcb4050d013f81bc6ea2 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 15 Apr 2026 15:06:49 +0000 Subject: [PATCH] Auto commit: 2026-04-15T15:06:49.025Z --- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 5 + frontend/src/pages/index.tsx | 267 +++--- frontend/src/pages/watch-hub.tsx | 1115 ++++++++++++++++++++++++ 5 files changed, 1245 insertions(+), 148 deletions(-) create mode 100644 frontend/src/pages/watch-hub.tsx 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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - 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

+
+
+
+
+
Movies & TV Tracker
+
Modern, dark, cinematic watchlist experience.
- - - +
+ + +
+
- - +
+
+
+ + 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) => ( + + ))} +
+
+ +
+ + 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 ( + + ); + })} +
+ + {!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) => ( + + ))} + {!highlightTrending.length &&

Trending titles will appear here once loaded.

} +
+
+ + +
+
+

Recommended tonight

+

Top-rated options for when you want a reliable pick.

+
+ +
+
+ {highlightRecommended.map((title) => ( + + ))} + {!highlightRecommended.length &&

Recommendations will appear here once loaded.

} +
+
+
+
+ +
+ +
+
+

My watchlist

+

Create a personal queue, then move titles from plan to watched.

+
+ +
+ +
+
+
+ + setWatchlistForm((current) => ({ ...current, name: event.target.value }))} + className='w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none transition focus:border-violet-400' + /> +
+
+ +