git branch -a.git log --graph --decorate --all.| Branch | Feature signal |
|---|---|
| origin/sidebar | navigation layout foundation |
| origin/sidebar-icons | sidebar visual/icon upgrade |
| origin/hero-banner | hero component + movie/TV page split |
| origin/watchlist | watchlist CRUD and context structure |
| origin/page/details-page | movie and TV detail sections |
| origin/feature/episodes-list | episodes list and context refactor |
| origin/profile-dashboard | profile recommendations dashboard |
| origin/feat/search | search page and comments |
| origin/search-ui-improvements | search page UI polish merged in PR #16 |
| Author | Count |
|---|---|
| Noah | 17 |
| Ali Saad | 15 |
| Almas Khan | 7 |
| Author | Files |
|---|---|
| Noah | 88 unique paths |
| Ali | 49 unique paths |
| Almas | 29 unique paths |
| Commit / branch | Meaning |
|---|---|
| initial commits | Vite app, package setup, starter files |
| 080b7e8 | MovieContext and homepage popular movies |
| origin/sidebar | layout and navigation shell |
| origin/sidebar-icons | TV route and icon improvement |
| origin/genre | genre page and footer work |
| Commit / branch | Meaning |
|---|---|
| origin/hero-banner | movie/TV hero and detail route foundations |
| origin/watchlist | watchlist create/read/update/delete flow |
| origin/page/details-page | cast, gallery, reviews, overview sections |
| origin/profile-dashboard | profile stats and recommendations |
| origin/tv-episodes-ui | episode guide UI polish |
| origin/feat/search | search page and final comments |
| PR #16 search-ui-improvements | Search.css added and Search.jsx improved |
map, filter, find, helpers, early returns, edge-case handling.useState and useEffect handle loading, filters, fetches, and route-driven reloads.
// Beginner mental model: one function protects the app from duplicate saves. async function addToWatchlist(item, mediaType) { // 1. Check current state first. if (findSavedTitle(watchlist, item.id, mediaType)) return // 2. Build one clean backend object from messy TMDB data. const entry = buildWatchlistEntry(item, mediaType) // 3. Save it, then update React state so the UI changes. const saved = await watchlistApi.post("/watchlist", entry) setWatchlist((current) => [...current, saved.data]) }
.map(). This avoids rewriting the same JSX for every route and keeps the sidebar consistent.// SideBar.jsx - presenter version of the method const links = [ { to: "/", label: "Home", icon: Home }, { to: "/movies", label: "Movies", icon: Film }, { to: "/tv-shows", label: "TV Shows", icon: Tv }, { to: "/watchlist", label: "Watchlist", icon: Bookmark } ] // map turns each object into one visible navigation link. {links.map(({ to, label, icon: Icon }) => ( <NavLink to={to}> <Icon /> {label} </NavLink> ))}
search-ui-improvementsorigin/main30d06bd authored by Alisrc/pages/Search.css, src/pages/Search.jsx// Search.jsx - presenter version of the UI state flow const [query, setQuery] = useState("") const [results, setResults] = useState([]) const [loading, setLoading] = useState(false) async function handleSearch(event) { event.preventDefault() // Empty input should not trigger a useless API request. if (!query.trim()) return setLoading(true) const data = await searchMovies(query) setResults(data) setLoading(false) } // Search.css handles the improved visual layout and result states.
// Beginner mental model: the URL decides what data to load. const { id } = useParams() const [detail, setDetail] = useState(null) useEffect(() => { let cancelled = false async function loadDetail() { // Fetch only the current media id. const data = await api.getDetail(id) if (!cancelled) setDetail(data) } loadDetail() return () => { cancelled = true } }, [id])
// MovieProvider.jsx - presenter version of shared movie state export function MovieProvider({ children }) { const [movies, setMovies] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { // Load shared TMDB data once when the provider starts. getPopularMovies() .then(setMovies) .finally(() => setLoading(false)) }, []) return ( <MovieContext.Provider value={{ movies, loading }}> {children} </MovieContext.Provider> ) }
// MovieDetail.jsx - presenter version of detail composition <InfoOverview media={movie} /> // Each section receives only the data it needs. <CastCarousel cast={credits.cast} /> <GalleryCarousel images={images.backdrops} /> <ReviewsCarousel reviews={reviews.results} /> // Beginner meaning: // MovieDetail controls the page data. // Child components control one visual section each.
// Beginner mental model: route params arrive as strings. const { genreId } = useParams() const selectedGenreId = Number(genreId) // Guard: avoid filtering with an invalid id. if (Number.isNaN(selectedGenreId)) { return <p>Genre not found</p> } // Filter only items that include the selected genre id. const matches = movies.filter((movie) => movie.genre_ids?.includes(selectedGenreId) )
// Profile.jsx - presenter version of profile composition const savedCount = watchlist.length const watchedCount = watchlist.filter((item) => item.watched).length // Profile stays readable because each section has its own component. <ProfileHeader user={profile} /> <StatsRow saved={savedCount} watched={watchedCount} /> <MoodBoard items={watchlist} /> // Beginner meaning: profile data changes when watchlist state changes.
// Defensive rendering pattern used for profile / genre fixes. if (loading) { return <Loading /> } if (!items || items.length === 0) { return <p>No results yet</p> } // Only map after the array exists. return items.map((item) => ( <MovieCard key={item.id} movie={item} /> ))
App.jsx, Homepage.jsx, and context files changed often.// The app layout: sidebar always visible, // page content changes based on the URL function App() { return ( <div className="app-layout"> <Sidebar /> // always rendered <main> <Routes> <Route path="/" element={<Homepage />} /> <Route path="/movies" element={<Movies />} /> <Route path="/tv-shows" element={<TVShows />} /> <Route path="/movie/:id" element={<MovieDetails />} /> <Route path="/tv/:id" element={<TVDetail />} /> <Route path="/watchlist" element={<Watchlist />} /> <Route path="*" element={<NotFound />} /> </Routes> </main> </div> ) }
createRoot(document.getElementById('root')).render( <StrictMode> <BrowserRouter> // enables URL routing <MovieProvider> // TMDB data everywhere <WatchlistProvider> // watchlist everywhere <App /> </WatchlistProvider> </MovieProvider> </BrowserRouter> </StrictMode> )
:id is dynamic — it changes for every movie or TV show* is a catch-all — any unknown URL shows the NotFound pagecreateRoot(root).render( <BrowserRouter> <MovieProvider> <WatchlistProvider> <App /> </WatchlistProvider> </MovieProvider> </BrowserRouter> ) // App.jsx <Sidebar /> <main> <Routes> <Route path="/movie/:id" element={<MovieDetails />} /> <Route path="/tv/:id" element={<TVDetail />} /> <Route path="*" element={<NotFound />} /> </Routes> </main>
useMovies() instead of passing movie data through many components manually.:id part changes for every movie and is read using useParams()./movies, /tv/1399, or /watchlist.<Route> and chooses which page component should appear.useMovies() or useWatchlist().<Routes>. Only the content inside <main> changes when the URL changes.// All nav links in one array // To add a new link: just add one object here const links = [ { to: "/movies", label: "Movies", icon: Film }, { to: "/tv-shows", label: "TV Shows", icon: Tv }, { to: "/search", label: "Search", icon: Search }, { to: "/watchlist", label: "Watchlist",icon: Bookmark }, ] // .map() builds a NavLink for each item {links.map((link) => ( <NavLink to={link.to} className={({ isActive }) => isActive ? "sidebar__link--active" : "" } > <link.icon size={18} /> <span>{link.label}</span> </NavLink> ))}
<Link> but knows which URL is active. Passes isActive so we can apply a CSS class automatically.<Film />, <Tv />. Import from lucide-react library.{ to, label, icon }) instead of hardcoding JSX — makes the list easy to modify.<Link> just navigates. <NavLink> also tells you if that link is the current page (isActive), so we can highlight it without writing any extra logic..map() renders the JSX. That makes the sidebar easier to maintain because adding a new route means adding one object.const links = [ { to: "/movies", label: "Movies", icon: Film }, { to: "/tv-shows", label: "TV Shows", icon: Tv }, ] {links.map((link) => { const Icon = link.icon return ( <NavLink key={link.to} to={link.to} className={({ isActive }) => isActive ? "sidebar__link--active" : "" } > <Icon size={18} /> <span>{link.label}</span> </NavLink> ) })}
NavLink for each object in the array.<Icon />.NavLink tells us whether this link matches the current URL, so CSS can highlight it.// Both movie and TV show objects are accepted // They have different field names — we handle both: const title = movie.title || movie.name || "Untitled" const released = movie.release_date || movie.first_air_date // Detect if it's a TV show to build the right link const isTv = Boolean(movie.name && !movie.title) const link = isTv ? `/tv/${movie.id}` : `/movie/${movie.id}` // Backdrop image builds the CSS background const bgUrl = `https://image.tmdb.org/t/p/original${movie.backdrop_path}`
useState + async/await — both curriculum concepts.const [status, setStatus] = useState("idle") const handleAdd = async () => { setStatus("saving") // → "Saving..." try { const result = await onAdd(movie) setStatus( result?.alreadySaved ? "already-saved" : "idle" ) // → "Already Saved" or "+ Add" } catch { setStatus("error") // → "Try Again" } }
onAdd come from the parent? So HeroBanner stays reusable. The Movies page passes a movie add function; the TV Shows page passes a TV add function. Same component, different behaviour.// 1) Keep only items that look good in the hero const heroMovies = useMemo(() => { const withBackdrops = popularMovies.filter( (movie) => movie.backdrop_path ) return withBackdrops.length > 0 ? withBackdrops : popularMovies }, [popularMovies]) // 2) Pick the current item safely const featuredMovie = heroMovies[ heroIndex % heroMovies.length ] || null // 3) Rotate every 5 seconds, then clean up useEffect(() => { if (heroMovies.length <= 1) return undefined const intervalId = window.setInterval(() => { setHeroIndex((current) => (current + 1) % heroMovies.length) }, 5000) return () => window.clearInterval(intervalId) }, [heroMovies.length])
backdrop_path are avoided when possible.popularMovies changes.onAdd or onSelectFeature.// Movies.jsx knows this is a movie <HeroBanner movie={featuredMovie} onAdd={(movie) => addToWatchlist(movie, "movie")} />
// HeroBanner.jsx does not import the watchlist context const handleAdd = () => { if (onAdd) onAdd(movie) }
/. Shows a cinematic background that slowly rotates between movie backdrops. The title stays fixed, so the page feels like a brand intro with clear entry points into the rest of the app.// Only movies with a backdrop image are used const backdropMovies = useMemo(() => popularMovies.filter((m) => m.backdrop_path).slice(0, 8) , [popularMovies]) // Background image rotates every 9 seconds useEffect(() => { const timer = window.setInterval(() => { setIndex((i) => (i + 1) % backdropMovies.length) // % = modulo: loops back to 0 after the last image }, 9000) return () => window.clearInterval(timer) // ↑ cleanup: cancels the timer when page unmounts }, [backdropMovies.length])
/movies. Has the rotating HeroBanner + carousels of movie posters. Before the split, / was doing both jobs — one page doing too much.// Get data + functions from context const { popularMovies, getPopularMovies } = useMovies() const { addToWatchlist } = useWatchlist() // Fetch once when the page loads useEffect(() => { getPopularMovies() }, []) // Only movies with backdrop go into the hero const heroMovies = useMemo(() => popularMovies.filter((m) => m.backdrop_path) , [popularMovies]) // Hero auto-rotates every 5 seconds const [heroIndex, setHeroIndex] = useState(0) const featured = heroMovies[heroIndex % heroMovies.length]
/ acted like a movie browser. Now / is a clean welcome screen and /movies is where you actually browse. Single responsibility.HeroBanner and MovieCarousel even though the data is TV shows — just passes mediaType="tv" so cards link to /tv/:id instead of /movie/:id.// Same hero component as Movies — // but onAdd passes "tv" as the media type <HeroBanner movie={featuredShow} featuredMovies={heroShows.slice(0, 5)} onAdd={(show) => addToWatchlist(show, "tv")} /> // Same carousel — but mediaType tells it to // link to /tv/:id instead of /movie/:id <MovieCarousel movies={popularTvShows} mediaType="tv" />
const { id } = useParams() // e.g. "12345" from /tv/12345 // requestedId = id that has FINISHED loading const [requestedId, setRequestedId] = useState(null) useEffect(() => { let cancelled = false getTvDetails(id).finally(() => { if (!cancelled) setRequestedId(id) // mark done }) return () => { cancelled = true } // ← safety cleanup }, [id]) // Only render the page when BOTH are ready if (loading || requestedId !== id) { return <Loading /> }
cancelled? If the user navigates away before the fetch finishes, we don't want React to update state on an unmounted component — that causes a warning/crash.const { id } = useParams() useEffect(() => { getTvDetails(id) }, [id]) if (loading || requestedId !== id) { return <Loading message="Loading TV show details..." /> } if (!selectedTv || String(selectedTv.id) !== String(id)) { return <p>TV show not found.</p> }
/tv/1399, the value 1399 becomes the id.| Meaning | Movie field | TV field | Fallback |
|---|---|---|---|
| Title | title | name | movie.title || movie.name |
| Release date | release_date | first_air_date | release_date || first_air_date |
| Length | runtime | number_of_seasons | runtime for movies, seasons for TV |
| Detail route | /movie/:id | /tv/:id | choose route from media type |
HeroBanner receives an object. That object could be a movie or a TV show. If the component only reads movie.title, TV shows would appear as blank because TV shows use name.// Safe display name const title = item.title || item.name || "Untitled" // Safe date const date = item.release_date || item.first_air_date || ""
mediaType prop.export default function MovieDetailHero({ movie, // the movie or TV show object mediaType = "movie", // "movie" or "tv" savedEntry, // null = not in watchlist onAdd, // add to watchlist onToggleWatched, // mark watched/unwatched onRemove, // remove from watchlist }) // Shows seasons for TV, runtime for movies const runtimeText = mediaType === "tv" ? `${movie.number_of_seasons} seasons` : `${movie.runtime} min`
savedEntry is null → show "Add to Watchlist". If it exists → show watchlist management buttons. This is conditional rendering with a ternary — curriculum.{savedEntry ? (
// Already saved → show watchlist controls
<>
<button onClick={onToggleWatched}>
{savedEntry.watched ? "Mark Unwatched" : "Mark Watched"}
</button>
<button onClick={handleRemove}>
{removeStatus === "removing"
? "Removing..."
: "Remove"}
</button>
</>
) : (
// Not saved → show add button
<button onClick={handleAdd}>+ Add to Watchlist</button>
)}
savedEntry = null → "Add to Watchlist"savedEntry.watched = false → "Mark Watched"savedEntry.watched = true → "Mark Unwatched"import axios from "axios" // A pre-configured Axios instance // Every call automatically goes to the right server const watchlistApi = axios.create({ baseURL: import.meta.env.VITE_WATCHLIST_API_URL // reads from the .env file — not hardcoded }) export default watchlistApi
VITE_WATCHLIST_API_URL=https://cinevault-api-2vhd.onrender.comdb.json and exposes a full REST API automatically — no database setup, no routes to write by hand.const router = jsonServer.router("db.json") // ↑ db.json becomes the database // Auto-creates GET/POST/PUT/DELETE /watchlist // CORS middleware — allows the frontend // to call this server from a different domain server.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*") res.header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE") next() }) // Health check — lets us verify the server is up server.get("/health", (req, res) => res.json({ status: "ok" }) ) server.listen(PORT, "0.0.0.0")
+ Add, Mark Watched, or Remove.addToWatchlist, updateWatchlistEntry, or removeFromWatchlist runs.watchlistApi sends Axios request to the Render backend URL.db.json through REST routes.watchlist state is updated so the UI changes instantly.watchlistApi.get(), .post(), .put(), or .delete().| Action | Endpoint | Used when |
|---|---|---|
| GET | /watchlist | Load all saved titles when the app starts or the watchlist page opens. |
| POST | /watchlist | Create a new saved movie or TV show. |
| PUT | /watchlist/:id | Update an existing backend entry, like toggling watched/unwatched. |
| DELETE | /watchlist/:id | Remove one saved entry from the backend. |
:id here is the backend entry ID, not the TMDB movie/TV ID.{
tmdbId: 1399,
mediaType: "tv",
title: "Game of Thrones",
posterPath: "/poster.jpg",
backdropPath: "/backdrop.jpg",
releaseDate: "2011-04-17",
rating: 8.4,
watched: false
}
/movie/:id or /tv/:id.// watchlistStore.js — creates the context export const WatchlistContext = createContext(null) // useWatchlist.js — the hook pages import export function useWatchlist() { const ctx = useContext(WatchlistContext) if (!ctx) throw new Error("Must be inside WatchlistProvider") return ctx } // WatchlistContext.jsx — the provider component export function WatchlistProvider({ children }) { const [watchlist, setWatchlist] = useState([]) const [isWatchlistLoading, setIsWatchlistLoading] = useState(true) ... return <WatchlistContext.Provider value={value}> {children} </WatchlistContext.Provider> }
// CREATE const addToWatchlist = useCallback(async (item, type) => { if (findSavedTitle(watchlist, item.id, type)) return { alreadySaved: true } // no duplicates const res = await watchlistApi.post("/watchlist", newEntry) setWatchlist((cur) => [...cur, res.data]) }, [watchlist]) // UPDATE const updateWatchlistEntry = useCallback(async (id, fields) => { const res = await watchlistApi.put(`/watchlist/${id}`, { ...currentEntry, ...fields // merge changes }) setWatchlist((cur) => replaceSavedTitle(cur, res.data)) }, [watchlist]) // DELETE const removeFromWatchlist = useCallback(async (id) => { await watchlistApi.delete(`/watchlist/${id}`) setWatchlist((cur) => removeSavedTitle(cur, id)) }, [])
const addToWatchlist = async (item, mediaType) => { const existing = findSavedTitle( watchlist, item.id, mediaType ) if (existing) { return { alreadySaved: true, entry: existing } } const payload = buildWatchlistEntry(item, mediaType) const created = await createWatchlistEntry(payload) setWatchlist((current) => [...current, created]) return { entry: created } }
// Prop drilling problem <App watchlist={watchlist}> <Movies watchlist={watchlist}> <HeroBanner watchlist={watchlist} /> </Movies> </App>
useWatchlist() and get the same data and actions.// Cleaner: page asks the provider directly const { watchlist, addToWatchlist, updateWatchlistEntry, removeFromWatchlist } = useWatchlist()
// Movies have .title, TV shows have .name export function getTitle(item) { return item?.title || item?.name || "Untitled" } // Check BOTH tmdbId AND mediaType // (a movie and a TV show can share the same numeric ID!) export function findSavedTitle(watchlist, tmdbId, type) { return watchlist.find((item) => String(item.tmdbId) === String(tmdbId) && getMediaType(item) === type ) || null } // String() cast: URL params are strings ("123"), // TMDB IDs are numbers (123) — cast both to compare safely // Movies use .release_date, TV shows use .first_air_date export function getReleaseDate(item) { return item?.release_date || item?.first_air_date || "" }
export function buildWatchlistEntry(item, typeOverride) { return { mediaType: getMediaType(item, typeOverride), tmdbId: item.id, title: getTitle(item), // .title or .name overview: item.overview, poster_path: item.poster_path, release_date: getReleaseDate(item), // release or air date vote_average: item.vote_average, watched: false, addedAt: new Date().toISOString().split("T")[0], } }
removeSavedTitle() — uses .filter() to return the list minus the deleted entryreplaceSavedTitle() — uses .map() to swap one updated entry in the listexport const getTitle = (item) => item?.title ?? item?.name ?? "Untitled" export const getReleaseDate = (item) => item?.release_date ?? item?.first_air_date ?? "" export const findSavedTitle = (list, tmdbId, mediaType) => list.find((entry) => String(entry.tmdbId) === String(tmdbId) && entry.mediaType === mediaType )
title, TV shows use name.undefined instead of crashing.null or undefined, not for every falsy value.// All data comes from context const { watchlist, watchlistCounts } = useWatchlist() // Filtering with .filter() — curriculum const filteredTitles = watchlist.filter((title) => { if (filter === "movie") return getMediaType(title) === "movie" if (filter === "tv") return getMediaType(title) === "tv" if (filter === "watched") return title.watched === true if (filter === "unwatched")return !title.watched return true // "all" })
// title.id → the backend's own ID (for CRUD calls) // title.tmdbId → the real TMDB movie/TV ID (for links) // Using title.id for links would go to the wrong page! const detailPath = mediaType === "tv" ? `/tv/${title.tmdbId}` // ✓ correct : `/movie/${title.tmdbId}` <Link to={detailPath}><MovieCard movie={title} /></Link>
/tv/:tmdbId, not the backend ID.// TVShows.jsx onAdd={(show) => addToWatchlist(show, "tv")} // WatchlistContext.jsx const newEntry = buildWatchlistEntry(show, "tv") const res = await watchlistApi.post("/watchlist", newEntry) setWatchlist((current) => [...current, res.data]) // Watchlist.jsx const detailPath = `/tv/${title.tmdbId}`
// Without useMemo — runs EVERY render const heroMovies = popularMovies.filter((m) => m.backdrop_path) // With useMemo — only runs when popularMovies changes const heroMovies = useMemo( () => popularMovies.filter((m) => m.backdrop_path), [popularMovies] // ← dependency: only recalculate when this changes )
// Without useCallback — new function on every render const removeFromWatchlist = async (id) => { ... } // With useCallback — same function reference // (only changes when dependencies change) const removeFromWatchlist = useCallback( async (id) => { ... }, [] // ← no dependencies = never recreated )
loadWatchlist + findWatchlistEntry.?., it just returns undefined instead of throwing an error.// Before — crashes if item is null return item.title // After — safe, returns undefined if item is null return item?.title // Chain multiple levels safely return item?.details?.poster_path
watchlistHelpers.js — every getter function uses ?. because TMDB data might be incomplete on first load.||. The || operator falls back if the value is falsy (includes 0, empty string ""). The ?? operator only falls back if the value is null or undefined. This matters when 0 or "" are valid values.// With || — 0 triggers the fallback (wrong!) const count = 0 || "no items" // → "no items" ← BUG // With ?? — only null/undefined triggers fallback const count = 0 ?? "no items" // → 0 ← correct // Used in MovieCard for size variants: const cls = sizeClasses[size] ?? sizeClasses.md // If size prop is invalid → fall back to medium
MovieCard.jsx for size variant lookup, helper functions for safe fallbacks.try/catch in curriculum — .finally() is an addition to that pattern.// Without finally — repeat the setLoading(false) twice try { await fetchData() setLoading(false) // ← repeated } catch { setLoading(false) // ← repeated again } // With finally — runs once no matter what try { await fetchData() } catch { setError("Failed") } finally { setLoading(false) // ← runs always }
TVDetail.jsx to mark that the requested ID has finished loading, WatchlistContext to always set loading to false.setState on a component that no longer exists — React throws a warning.useEffect(() => { let cancelled = false // flag starts false fetchSomething().then((data) => { if (!cancelled) setData(data) // only update if still mounted }) return () => { cancelled = true // ← cleanup: flip the flag when unmounting } }, [])
TVDetail.jsx — the let cancelled = false inside the useEffect that fetches the TV show details.title vs name.
The watchlist was not just a button. It had to create, read, update, and delete items while keeping the UI and backend in sync.
Movie and TV items can look similar, so the logic needed to compare both the TMDB id and the media type before saving.
The sidebar and detail routes had to feel connected across the app, so navigation, active links, and page structure needed to stay consistent.
The Search page worked first, but making it clear, responsive, and visually consistent required a second UI pass and className/CSS cleanup.
The hero banner needed to rotate featured movies automatically without breaking the page. The method was useState for the current slide, useEffect for timing, setInterval for auto-rotation, modulo % to loop back, and clearInterval cleanup.
State sync is harder than it looks
A feature feels simple from the UI, but behind it there is backend data, local React state, duplicate checks, loading feedback, and user expectations.
AI helped most after the logic worked
For styling, the best workflow was: build the component first, use Noah's visual direction, ask AI for className planning, audit the hooks, then generate CSS.
Rotating hero banner method
useState stores which hero item is active. useEffect starts the timer. setInterval moves to the next item. Modulo % loops from the last item back to the first. Cleanup with clearInterval prevents timer bugs.
// Hero banner: rotate automatically and loop
useEffect(() => {
const timer = setInterval(() => {
setCurrentIndex((current) =>
(current + 1) % heroItems.length
);
}, 5000);
return () => clearInterval(timer);
}, [heroItems.length]);
// Duplicate guard idea used in watchlist logic
const alreadySaved = watchlist.some((saved) =>
saved.tmdbId === item.id &&
saved.mediaType === mediaType
);
if (alreadySaved) return;
The challenge was deciding where one component should end and the next should begin, instead of keeping everything inside one huge page file.
Some logic belongs in Context API because many components need it. Other logic should stay inside the page because only that page uses it.
AI could generate useful ideas, but the real work was reading the code, understanding what it changed, and making sure it fit CineVault.
TMDB review ratings are numeric data, but the UI needed readable star ratings, so the data had to be converted into a visual format.
Component split
A page is like a full movie scene. Components are the smaller reusable parts inside it: cards, carousels, review rows, rating stars, and detail sections.
Context vs page logic
If many pages need the data, it belongs in Context. If only one page needs it, keeping it inside that page makes the code easier to understand.
// TMDB rating example: number -> stars
const ratingOutOfFive = Math.round(tmdbRating / 2);
const stars = "★".repeat(ratingOutOfFive) +
"☆".repeat(5 - ratingOutOfFive);
GenrePage.jsx was using the shared loading state from Context. The genre helper did not reset that loading state, so the page could stay stuck.
Fix: remove the shared loading dependency for this page and use a local data check like if (!movies.length).
Clicking a genre from MoodBoard showed no movies because useParams() returns the route id as a string, like "28", but genreMovies was keyed by number, like 28.
Fix: convert the route id with Number(id) before using it to read genre data.
Merge conflicts
Early in the project, shared files like Homepage.jsx caused major merge conflicts because multiple teammates changed the same area. The fix was manual conflict resolution and understanding both versions before keeping the final code.
Use of AI
Almas used Claude AI for debugging errors, understanding why bugs happened, styling components to match the design, and navigating Git commands like stashing, resolving conflicts, and managing branches.
// Route params are strings by default
const { id } = useParams(); // "28"
// Convert before using numeric genre keys
const genreId = Number(id);
const movies = genreMovies[genreId] || [];
if (!movies.length) {
return <Loading />;
}
Beginner explanation
React state updates are asynchronous. If an API call finishes after the user leaves the page, we need a guard so the old request does not update the new screen.
// Simplified pattern used for API-driven pages
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchDetails(id)
.then((data) => {
if (cancelled) return;
setDetails(data);
})
.catch((error) => {
if (!cancelled) setError(error);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [id]);
async function addToWatchlist(item) {
const exists = watchlist.some((saved) =>
saved.tmdbId === item.id &&
saved.mediaType === item.media_type
);
if (exists) return; // avoid duplicate saves
const savedItem = await createWatchlistItem(normalizeItem(item));
setWatchlist((current) => [...current, savedItem]);
}
setState so the UI reflects the confirmed saved item.Button and Input from src/components/ui.Badge component.Dialog for video previews.class-variance-authority, tailwind-merge, and the local cn() helper.// Examples from the project structure
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Carousel,
CarouselContent,
CarouselItem
} from "@/components/ui/carousel";
Beginner explanation
shadcn/ui is beyond the Ironhack curriculum. It gives reusable component files inside our project, so we can import them, customize them, and keep the UI consistent.
finally(), optional chaining, or safer fallback logic.selectedTv too early.useEffect(() => { let cancelled = false setRequestedId(null) getTvDetails(id).finally(() => { if (!cancelled) setRequestedId(id) }) return () => { cancelled = true } }, [id, getTvDetails]) if (loading || requestedId !== id) { return <Loading message="Loading TV show details..." /> }
finally() runs after success or failure, requestedId confirms the current request finished, and the cancel guard avoids updating state after leaving the page.The component or page was built functionally first. AI was used after the logic worked, mainly to translate the existing visual direction into clean classNames and CSS.
The page/component works with basic styling before AI styling starts.
We used Noah's early app visuals as the design reference so the result stayed close to the CineVault style.
AI was given the page/component JSX so it could understand the real structure, not invent fake markup.
Before CSS, we asked for better classNames and a plan to match the general app style.
After applying classNames, AI checked whether every needed element had a usable class hook.
Only then did AI generate the full CSS file, which we imported into the JSX page or component.
What AI improved
Class naming, layout structure, spacing, visual consistency, responsive styling, and writing complete CSS faster.
What the team still owned
The React logic, component behavior, data flow, design direction, testing in the browser, and deciding what CSS actually stayed in the project.
// Step 1: functional JSX gets intentional classNames
import "./Search.css";
function Search() {
return (
<section className="search-page">
<form className="search-panel">
<input className="search-input" />
<button className="search-submit">Search</button>
</form>
<div className="search-results-grid">
{movies.map(movie => (
<MovieCard key={movie.id} movie={movie} />
))}
</div>
</section>
);
}
// Step 2: AI generates CSS only after the JSX hooks are clear
.search-page { padding: 2rem; }
.search-panel { display: flex; gap: 1rem; }
.search-results-grid { display: grid; }
Presentation framing: this is beyond-course usage because it combines React implementation, AI prompting, UI direction, and CSS generation into one repeatable workflow.
| Method | Why we researched it | How we used it in CineVault | What it prevents / improves |
|---|---|---|---|
.finally() | We needed code that runs after an async request whether it succeeds or fails. | Used in async detail/watchlist logic to finish loading or mark the request as completed. | Stops duplicated cleanup code and keeps loading states consistent. |
| Cancel guard | We needed to avoid updating state after leaving a page during an async request. | Used a cancelled boolean inside useEffect cleanup. | Prevents state updates on an unmounted component and avoids stale UI behavior. |
| Requested ID guard | The TV detail page could show not-found before the correct request finished. | Stored the ID that finished loading and compared it with the current URL ID. | Prevents false not-found screens when navigating quickly or waiting for data. |
?. optional chaining | TMDB data can be missing or temporarily undefined during loading. | Used in helper functions and display logic when reading nested fields. | Prevents crashes like “Cannot read properties of undefined.” |
?? nullish fallback | We needed safer fallbacks than || for API values. | Used when choosing movie/TV title, date, poster, rating, and fallback values. | Protects valid values like 0 or empty strings from being replaced accidentally. |
Functional setState | Watchlist updates depend on the previous list. | Used setWatchlist(current => ...) after create/update/delete. | Uses the latest state and avoids bugs from stale state values. |
[...current, newEntry]loadWatchlist() → GET /watchlist → fills watchlist state → Watchlist page renders all saved titles.{...current, ...updatedFields} — merging watchlist entry updates.`/watchlist/${id}`.item?.title instead of crashing.|| — only triggers on null/undefined, not on 0 or "".let cancelled = false in useEffect — prevents state updates on unmounted components.setWatchlist((current) => [...current, newItem]) — uses the previous state, avoids stale closures.import.meta.env.VITE_* — keeps API URLs out of source code, different per machine./, /movies, /tv-shows, and /watchlist./tv/:id.# Build the project npm run build # Focused lint check npx eslint src/App.jsx \ src/components/HeroBanner.jsx \ src/pages/TVShows.jsx \ src/pages/TVDetail.jsx
| File | Job |
|---|---|
| src/main.jsx | App entry + providers |
| src/App.jsx | All routes + layout |
| components/SideBar.jsx | Sidebar + active links |
| components/SideBar.css | Sidebar styles |
| components/HeroBanner.jsx | Browse hero |
| components/HeroBanner.css | |
| components/MovieDetailHero.jsx | Detail page hero |
| components/MovieDetailHero.css | |
| components/MovieCard.jsx | Poster card |
| components/MovieCarousel.jsx | Scrollable row |
| components/Loading.jsx | Loading spinner UI |
| File | Route |
|---|---|
| pages/Homepage.jsx | / |
| pages/Movies.jsx | /movies |
| pages/MovieDetail.jsx | /movie/:id |
| pages/TVShows.jsx | /tv-shows |
| pages/TVDetail.jsx | /tv/:id |
| pages/Watchlist.jsx | /watchlist |
| pages/NotFound.jsx | * |
| assets/NotFound.svg | 404 illustration |
| assets/loading.svg | Spinner animation |
| File | Job |
|---|---|
| context/WatchlistContext.jsx | Provider + CRUD |
| context/watchlistStore.js | createContext() |
| context/useWatchlist.js | Custom hook |
| context/watchlistHelpers.js | Helper functions |
| api/watchlist.js | Axios instance |
| cinevault-api/app.js | JSON Server + CORS |
| cinevault-api/db.json | Watchlist storage |