diff --git a/mflix/client/app/components/FilterBar/FilterBar.module.css b/mflix/client/app/components/FilterBar/FilterBar.module.css new file mode 100644 index 0000000..ee17f25 --- /dev/null +++ b/mflix/client/app/components/FilterBar/FilterBar.module.css @@ -0,0 +1,212 @@ +/** + * FilterBar Component Styles + * + * CSS Module for the movie filter bar component. + * Provides a horizontal filter bar for filtering movies by genre, year, rating, etc. + */ + +.filterBar { + background: white; + border-radius: 12px; + padding: 1.25rem 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + margin-bottom: 1.5rem; +} + +.filterHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.filterTitle { + font-size: 0.875rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.clearFiltersButton { + background: transparent; + border: 1px solid #e2e8f0; + color: #64748b; + padding: 0.375rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.clearFiltersButton:hover { + background: #f8fafc; + border-color: #cbd5e1; + color: #475569; +} + +.filterControls { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} + +.filterGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 140px; +} + +.filterLabel { + font-size: 0.75rem; + font-weight: 500; + color: #64748b; +} + +.filterSelect, +.filterInput { + padding: 0.5rem 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 0.875rem; + background: white; + color: #374151; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + min-width: 120px; +} + +.filterSelect:hover, +.filterInput:hover { + border-color: #cbd5e1; +} + +.filterSelect:focus, +.filterInput:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.ratingGroup { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ratingInput { + width: 70px; +} + +.ratingDivider { + color: #94a3b8; + font-size: 0.875rem; +} + +.applyButton { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.applyButton:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); +} + +.applyButton:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.activeFilters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; +} + +.filterChip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: #eff6ff; + color: #2563eb; + padding: 0.25rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.chipRemove { + background: none; + border: none; + color: #2563eb; + cursor: pointer; + padding: 0; + font-size: 1rem; + line-height: 1; + opacity: 0.7; +} + +.chipRemove:hover { + opacity: 1; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .filterBar { + padding: 1rem; + } + + .filterControls { + flex-direction: column; + align-items: stretch; + } + + .filterGroup { + min-width: 100%; + } + + .ratingGroup { + flex-wrap: wrap; + } + + .ratingInput { + flex: 1; + min-width: 80px; + } + + .applyButton { + width: 100%; + padding: 0.75rem 1rem; + } +} + +@media (max-width: 480px) { + .filterHeader { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .clearFiltersButton { + width: 100%; + } +} diff --git a/mflix/client/app/components/FilterBar/FilterBar.tsx b/mflix/client/app/components/FilterBar/FilterBar.tsx new file mode 100644 index 0000000..324840d --- /dev/null +++ b/mflix/client/app/components/FilterBar/FilterBar.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { useState, useCallback, useEffect, useRef } from 'react'; +import styles from './FilterBar.module.css'; +import { fetchGenres, type MovieFilterParams } from '@/lib/api'; + +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'year', label: 'Year' }, + { value: 'imdb.rating', label: 'IMDB Rating' }, +]; + +interface FilterBarProps { + onFilterChange: (filters: MovieFilterParams) => void; + isLoading?: boolean; + initialFilters?: MovieFilterParams; +} + +/** + * Compares two MovieFilterParams objects for equality. + * Returns true if all filter values match. + */ +function areFiltersEqual(a: MovieFilterParams, b: MovieFilterParams): boolean { + return ( + a.genre === b.genre && + a.year === b.year && + a.minRating === b.minRating && + a.maxRating === b.maxRating && + a.sortBy === b.sortBy && + a.sortOrder === b.sortOrder + ); +} + +export default function FilterBar({ + onFilterChange, + isLoading = false, + initialFilters = {} +}: FilterBarProps) { + const [filters, setFilters] = useState(initialFilters); + const [genres, setGenres] = useState([]); + const [isLoadingGenres, setIsLoadingGenres] = useState(true); + + // Track previous initialFilters to detect changes + const prevInitialFiltersRef = useRef(initialFilters); + + // Fetch genres from the API on mount + useEffect(() => { + async function loadGenres() { + setIsLoadingGenres(true); + const fetchedGenres = await fetchGenres(); + setGenres(fetchedGenres); + setIsLoadingGenres(false); + } + loadGenres(); + }, []); + + // Sync internal state when initialFilters changes (e.g. from URL navigation) + useEffect(() => { + if (!areFiltersEqual(prevInitialFiltersRef.current, initialFilters)) { + setFilters(initialFilters); + prevInitialFiltersRef.current = initialFilters; + } + }, [initialFilters]); + + const handleFilterChange = useCallback((key: keyof MovieFilterParams, value: string | number | undefined) => { + setFilters(prev => { + const newFilters = { ...prev }; + if (value === '' || value === undefined) { + delete newFilters[key]; + } else { + (newFilters as Record)[key] = value; + } + return newFilters; + }); + }, []); + + const handleApplyFilters = useCallback(() => { + onFilterChange(filters); + }, [filters, onFilterChange]); + + const handleClearFilters = useCallback(() => { + setFilters({}); + onFilterChange({}); + }, [onFilterChange]); + + const hasActiveFilters = Object.keys(filters).length > 0; + + const activeFilterChips: { key: string; label: string }[] = []; + if (filters.genre) activeFilterChips.push({ key: 'genre', label: `Genre: ${filters.genre}` }); + if (filters.year) activeFilterChips.push({ key: 'year', label: `Year: ${filters.year}` }); + if (filters.minRating !== undefined) activeFilterChips.push({ key: 'minRating', label: `Min Rating: ${filters.minRating}` }); + if (filters.maxRating !== undefined) activeFilterChips.push({ key: 'maxRating', label: `Max Rating: ${filters.maxRating}` }); + if (filters.sortBy) { + const sortLabel = SORT_OPTIONS.find(o => o.value === filters.sortBy)?.label || filters.sortBy; + activeFilterChips.push({ key: 'sort', label: `Sort: ${sortLabel} (${filters.sortOrder || 'asc'})` }); + } + + const removeFilter = (key: string) => { + if (key === 'sort') { + handleFilterChange('sortBy', undefined); + handleFilterChange('sortOrder', undefined); + } else { + handleFilterChange(key as keyof MovieFilterParams, undefined); + } + }; + + return ( +
+
+

+ Filter Movies +

+ {hasActiveFilters && ( + + )} +
+ +
+
+ + +
+ +
+ + handleFilterChange('year', e.target.value ? parseInt(e.target.value) : undefined)} + disabled={isLoading} + min={1900} + max={2030} + /> +
+ +
+ +
+ handleFilterChange('minRating', e.target.value ? parseFloat(e.target.value) : undefined)} + disabled={isLoading} + min={0} + max={10} + step={0.1} + /> + to + handleFilterChange('maxRating', e.target.value ? parseFloat(e.target.value) : undefined)} + disabled={isLoading} + min={0} + max={10} + step={0.1} + /> +
+
+ +
+ + +
+ +
+ + +
+ + +
+ + {activeFilterChips.length > 0 && ( +
+ {activeFilterChips.map(chip => ( + + {chip.label} + + + ))} +
+ )} +
+ ); +} + diff --git a/mflix/client/app/components/FilterBar/index.ts b/mflix/client/app/components/FilterBar/index.ts new file mode 100644 index 0000000..839567f --- /dev/null +++ b/mflix/client/app/components/FilterBar/index.ts @@ -0,0 +1,2 @@ +export { default as FilterBar } from './FilterBar'; + diff --git a/mflix/client/app/components/MovieCard/MovieCard.tsx b/mflix/client/app/components/MovieCard/MovieCard.tsx index 1ff2c9d..7fa702c 100644 --- a/mflix/client/app/components/MovieCard/MovieCard.tsx +++ b/mflix/client/app/components/MovieCard/MovieCard.tsx @@ -3,12 +3,13 @@ import Image from 'next/image'; import Link from 'next/link'; import movieStyles from "./MovieCard.module.css"; -import { Movie } from "../../types/movie"; -import { ROUTES } from "../../lib/constants"; +import { Movie } from "@/types/movie"; +import { ROUTES } from "@/lib/constants"; +import React from "react"; /** * Movie Card Client Component - * + * * This component handles the interactive parts of the movie card, * such as image error handling and selection checkbox. */ diff --git a/mflix/client/app/components/index.ts b/mflix/client/app/components/index.ts index 03c422b..11b6de1 100644 --- a/mflix/client/app/components/index.ts +++ b/mflix/client/app/components/index.ts @@ -7,6 +7,7 @@ export { default as EditMovieForm } from './EditMovieForm'; export { default as AddMovieForm } from './AddMovieForm'; export { default as BatchEditMovieForm } from './BatchEditMovieForm'; export { default as SearchMovieModal } from './SearchMovieModal'; +export { FilterBar } from './FilterBar'; export { Skeleton, MovieCardSkeleton, diff --git a/mflix/client/app/lib/api.ts b/mflix/client/app/lib/api.ts index c99c08f..7ac8976 100644 --- a/mflix/client/app/lib/api.ts +++ b/mflix/client/app/lib/api.ts @@ -1,4 +1,4 @@ -import { Movie, MoviesApiResponse } from '../types/movie'; +import { Movie, MoviesApiResponse } from '@/types/movie'; /** * API configuration and helper functions @@ -7,17 +7,59 @@ import { Movie, MoviesApiResponse } from '../types/movie'; const API_BASE_URL = process.env.API_URL || 'http://localhost:3001'; /** - * Fetches movies from the Express API with pagination support - * This function runs on the server during SSR + * Filter parameters for the movies endpoint + * These map to MongoDB find() query operators + */ +export interface MovieFilterParams { + genre?: string; + year?: number; + minRating?: number; + maxRating?: number; + sortBy?: 'title' | 'year' | 'imdb.rating'; + sortOrder?: 'asc' | 'desc'; +} + +/** + * Fetches movies from the backend API with pagination and filtering support + * using MongoDB find() with query filters. */ export async function fetchMovies( - limit: number = 20, - skip: number = 0 + limit: number = 20, + skip: number = 0, + filters?: MovieFilterParams ): Promise<{ movies: Movie[]; hasNextPage: boolean; hasPrevPage: boolean }> { try { + // Build query parameters + const queryParams = new URLSearchParams(); + // Request one extra movie to check if there's a next page const requestLimit = Math.min(limit + 1, 100); - const response = await fetch(`${API_BASE_URL}/api/movies?limit=${requestLimit}&skip=${skip}`, { + queryParams.append('limit', requestLimit.toString()); + queryParams.append('skip', skip.toString()); + + // Add filter parameters if provided + if (filters) { + if (filters.genre) { + queryParams.append('genre', filters.genre); + } + if (filters.year !== undefined) { + queryParams.append('year', filters.year.toString()); + } + if (filters.minRating !== undefined) { + queryParams.append('minRating', filters.minRating.toString()); + } + if (filters.maxRating !== undefined) { + queryParams.append('maxRating', filters.maxRating.toString()); + } + if (filters.sortBy) { + queryParams.append('sortBy', filters.sortBy); + } + if (filters.sortOrder) { + queryParams.append('sortOrder', filters.sortOrder); + } + } + + const response = await fetch(`${API_BASE_URL}/api/movies?${queryParams}`, { next: { revalidate: 300 }, // Revalidate every 5 minutes }); @@ -26,7 +68,7 @@ export async function fetchMovies( } const result: MoviesApiResponse = await response.json(); - + if (!result.success) { throw new Error('API returned error response'); } @@ -42,12 +84,12 @@ export async function fetchMovies( }; } catch (error) { console.error('Error fetching movies:', error); - + // In development, throw the error to help with debugging if (process.env.NODE_ENV === 'development') { throw error; } - + // In production, return empty result with logged error to prevent page crash return { movies: [], @@ -57,6 +99,34 @@ export async function fetchMovies( } } +/** + * Fetches all unique genres from the backend API + * using MongoDB's distinct() operation. + */ +export async function fetchGenres(): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/movies/genres`, { + next: { revalidate: 3600 }, // Cache genres for 1 hour since they rarely change + }); + + if (!response.ok) { + throw new Error(`Failed to fetch genres: ${response.status}`); + } + + const result: { success: boolean; data: string[]; message: string } = await response.json(); + + if (!result.success) { + throw new Error('API returned error response'); + } + + return result.data; + } catch (error) { + console.error('Error fetching genres:', error); + // Return empty array on error - FilterBar will handle gracefully + return []; + } +} + /** * Fetch a single movie by ID */ diff --git a/mflix/client/app/movie/[id]/page.tsx b/mflix/client/app/movie/[id]/page.tsx index 42dade2..86395c4 100644 --- a/mflix/client/app/movie/[id]/page.tsx +++ b/mflix/client/app/movie/[id]/page.tsx @@ -4,11 +4,11 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { fetchMovieById, updateMovie, deleteMovie } from '../../lib/api'; +import { fetchMovieById, updateMovie, deleteMovie } from '@/lib/api'; import { ActionButtons, EditMovieForm } from '../../components'; import { ErrorDisplay, LoadingSpinner } from '../../components/ui'; -import { Movie } from '../../types/movie'; -import { ROUTES } from '../../lib/constants'; +import { Movie } from '@/types/movie'; +import { ROUTES } from '@/lib/constants'; import pageStyles from './page.module.css'; interface MovieDetailsPageProps { @@ -205,7 +205,7 @@ export default function MovieDetailsPage({ params }: MovieDetailsPageProps) { {movie.poster ? (
{`${movie.title}(null); - + + // Parse filter parameters from URL for persistence + const parseFiltersFromUrl = (): MovieFilterParams => { + const filters: MovieFilterParams = {}; + + const genre = searchParams.get('genre'); + const year = searchParams.get('year'); + const minRating = searchParams.get('minRating'); + const maxRating = searchParams.get('maxRating'); + const sortBy = searchParams.get('sortBy'); + const sortOrder = searchParams.get('sortOrder'); + + if (genre) filters.genre = genre; + if (year) filters.year = parseInt(year); + if (minRating) filters.minRating = parseFloat(minRating); + if (maxRating) filters.maxRating = parseFloat(maxRating); + if (sortBy && ['title', 'year', 'imdb.rating'].includes(sortBy)) { + filters.sortBy = sortBy as MovieFilterParams['sortBy']; + } + if (sortOrder && ['asc', 'desc'].includes(sortOrder)) { + filters.sortOrder = sortOrder as 'asc' | 'desc'; + } + + return filters; + }; + + // Build URL with filter parameters + const buildUrlWithFilters = (newPage: number, newLimit: number, filters: MovieFilterParams): string => { + const params = new URLSearchParams(); + params.set('page', newPage.toString()); + params.set('limit', newLimit.toString()); + + if (filters.genre) params.set('genre', filters.genre); + if (filters.year !== undefined) params.set('year', filters.year.toString()); + if (filters.minRating !== undefined) params.set('minRating', filters.minRating.toString()); + if (filters.maxRating !== undefined) params.set('maxRating', filters.maxRating.toString()); + if (filters.sortBy) params.set('sortBy', filters.sortBy); + if (filters.sortOrder) params.set('sortOrder', filters.sortOrder); + + return `${ROUTES.movies}?${params.toString()}`; + }; + + // Get filters from URL on initial load and when URL changes + const urlFilters = parseFiltersFromUrl(); + const hasUrlFilters = Object.keys(urlFilters).length > 0; + const page = parseInt(searchParams.get('page') || '1'); const limit = Math.min( - parseInt(searchParams.get('limit') || APP_CONFIG.defaultMovieLimit.toString()), + parseInt(searchParams.get('limit') || APP_CONFIG.defaultMovieLimit.toString()), APP_CONFIG.maxMovieLimit ); const skip = (page - 1) * limit; - const loadMovies = async () => { + const loadMovies = async (filters?: MovieFilterParams) => { setIsLoading(true); setError(null); - + try { - const result = await fetchMovies(limit, skip); + const result = await fetchMovies(limit, skip, filters); setMovies(result.movies); setHasNextPage(result.hasNextPage); setHasPrevPage(result.hasPrevPage); @@ -73,13 +118,23 @@ export default function Movies() { setError('Failed to load movies. Make sure the server is running on port 3001.'); setMovies([]); } - + setIsLoading(false); }; useEffect(() => { - loadMovies(); - }, [page, limit]); + // Load movies with filters from URL when page/limit/filters change + loadMovies(hasUrlFilters ? urlFilters : undefined); + }, [searchParams]); // Re-run when any URL param changes + + // Handler for filter changes from FilterBar - updates URL + const handleFilterChange = (filters: MovieFilterParams) => { + const hasFilters = Object.keys(filters).length > 0; + + // Always reset to page 1 when filters change and update URL + const newUrl = buildUrlWithFilters(1, limit, filters); + router.push(newUrl); + }; const handleAddMovie = () => { setShowAddForm(true); @@ -462,7 +517,7 @@ export default function Movies() {

- {isSearchMode ? `Search Results` : 'Movies'} + {isSearchMode ? `Search Results` : hasUrlFilters ? 'Filtered Movies' : 'Movies'}

@@ -572,20 +627,31 @@ export default function Movies() { {/* Page Size Selector - only show for regular mode */} {!showAddForm && !showBatchEditForm && !showSearchModal && !isSearchMode && } - + + {/* Filter Bar - display when not in search mode and not showing forms */} + {!showAddForm && !showBatchEditForm && !showSearchModal && !isSearchMode && ( + + )} + {/* Movies Content */} {!showAddForm && !showBatchEditForm && !showSearchModal && ( <> {error && displayMovies.length === 0 ? ( - handleSearchSubmit(currentSearchParams!) : loadMovies} + handleSearchSubmit(currentSearchParams!) : () => loadMovies(hasUrlFilters ? urlFilters : undefined)} /> ) : displayMovies.length === 0 ? (

- {isSearchMode + {isSearchMode ? 'No movies found matching your search criteria. Try different search terms.' + : hasUrlFilters + ? 'No movies found matching your filter criteria. Try adjusting your filters.' : 'No movies found. Make sure the server is running on port 3001.' }

diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index 0d107e5..6ff04ba 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -134,6 +134,34 @@ export async function getAllMovies(req: Request, res: Response): Promise { res.json(createSuccessResponse(movies, `Found ${movies.length} movies`)); } +/** + * GET /api/movies/genres + * + * Retrieves all unique genres from the movies collection. + * Demonstrates the distinct() operation. + * + * Returns an array of unique genre strings, sorted alphabetically. + */ +export async function getDistinctGenres( + req: Request, + res: Response +): Promise { + const moviesCollection = getCollection("movies"); + + // Use distinct() to get all unique values from the genres array field + // MongoDB automatically flattens array fields when using distinct() + const genres = await moviesCollection.distinct("genres"); + + // Filter out null/empty values and sort alphabetically + const validGenres = genres + .filter((genre): genre is string => typeof genre === "string" && genre.length > 0) + .sort((a, b) => a.localeCompare(b)); + + res.json( + createSuccessResponse(validGenres, `Found ${validGenres.length} distinct genres`) + ); +} + /** * GET /api/movies/:id * diff --git a/mflix/server/js-express/src/routes/movies.ts b/mflix/server/js-express/src/routes/movies.ts index ed2c9d0..4be4df2 100644 --- a/mflix/server/js-express/src/routes/movies.ts +++ b/mflix/server/js-express/src/routes/movies.ts @@ -118,6 +118,31 @@ const router = express.Router(); */ router.get("/", asyncHandler(movieController.getAllMovies)); +/** + * @swagger + * /api/movies/genres: + * get: + * summary: Get all distinct genres + * description: Retrieves all unique genres from the movies collection. Demonstrates the MongoDB distinct() operation. + * tags: [Movies] + * responses: + * 200: + * description: List of distinct genres + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: array + * items: + * type: string + * example: ["Action", "Adventure", "Animation", "Comedy", "Drama"] + */ +router.get("/genres", asyncHandler(movieController.getDistinctGenres)); + /** * @swagger * /api/movies/search: