diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..61ed0e9 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,6 @@ +# Client Environment Variables +# Copy this file to .env.local and update the values as needed + +# URL of the Express API server +# Default: http://localhost:3001 (for local development) +API_URL=http://localhost:3001 \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..189ae56 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions +package-lock.json + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/client/app/components/MovieCard/MovieCard.module.css b/client/app/components/MovieCard/MovieCard.module.css new file mode 100644 index 0000000..770da86 --- /dev/null +++ b/client/app/components/MovieCard/MovieCard.module.css @@ -0,0 +1,156 @@ +/** + * Movies Page Styles + * + * CSS Module for the movies page component. + * Provides responsive grid layout and movie card styling. + */ + +.moviesGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 24px; + width: 100%; + max-width: 1200px; +} + +.movieCard { + border: 1px solid #ddd; + border-radius: 8px; + padding: 16px; + background: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.movieCard:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.moviePoster { + position: relative; + width: 100%; + height: 300px; + margin-bottom: 16px; + background: #f5f5f5; + border-radius: 4px; + overflow: hidden; +} + +.moviePoster img { + border-radius: 4px; +} + +.posterPlaceholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + color: #666; + font-size: 14px; + text-align: center; + background: #f5f5f5; + border-radius: 4px; +} + +.movieInfo { + margin-bottom: 16px; +} + +.movieTitle { + margin: 0 0 8px 0; + font-size: 18px; + font-weight: 600; + line-height: 1.3; + color: #333; +} + +.movieYear { + margin: 0 0 4px 0; + color: #666; + font-size: 14px; +} + +.movieRating { + margin: 0 0 4px 0; + color: #666; + font-size: 14px; +} + +.movieGenres { + margin: 0; + color: #888; + font-size: 12px; + font-style: italic; +} + +.detailsButton { + width: 100%; + background: #0066cc; + color: white; + border: none; + padding: 12px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.detailsButton:hover { + background: #0052a3; +} + +.noMovies { + text-align: center; + padding: 40px; + color: #666; +} + +.pageTitle { + margin: 0 0 16px 0; + font-size: 32px; + color: #333; +} + +.movieCount { + margin: 0 0 32px 0; + color: #666; + font-size: 16px; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .moviesGrid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; + } + + .moviePoster { + height: 240px; + } + + .pageTitle { + font-size: 28px; + } +} + +@media (max-width: 480px) { + .moviesGrid { + grid-template-columns: 1fr; + gap: 16px; + } + + .movieCard { + padding: 12px; + } + + .moviePoster { + height: 200px; + } + + .pageTitle { + font-size: 24px; + } +} \ No newline at end of file diff --git a/client/app/components/MovieCard/MovieCard.tsx b/client/app/components/MovieCard/MovieCard.tsx new file mode 100644 index 0000000..28551ff --- /dev/null +++ b/client/app/components/MovieCard/MovieCard.tsx @@ -0,0 +1,60 @@ +'use client'; + +import Image from 'next/image'; +import movieStyles from "./MovieCard.module.css"; +import { MovieCardProps } from "../../types/movie"; + +/** + * Movie Card Client Component + * + * This component handles the interactive parts of the movie card, + * such as image error handling, while the parent remains a Server Component. + */ +export default function MovieCard({ movie }: MovieCardProps) { + const handleImageError = () => { + // This will be handled by the Image component's onError prop + console.warn(`Failed to load poster for: ${movie.title}`); + }; + + return ( +
+
+ {movie.poster ? ( + {`${movie.title} + ) : ( +
+ No Poster Available +
+ )} +
+ +
+

{movie.title}

+ {movie.year && ( +

({movie.year})

+ )} + {movie.imdb?.rating && ( +

⭐ {movie.imdb.rating}/10

+ )} + {movie.genres && movie.genres.length > 0 && ( +

+ {movie.genres.slice(0, 3).join(', ')} +

+ )} +
+ + +
+ ); +} \ No newline at end of file diff --git a/client/app/components/MovieCard/index.ts b/client/app/components/MovieCard/index.ts new file mode 100644 index 0000000..43524e4 --- /dev/null +++ b/client/app/components/MovieCard/index.ts @@ -0,0 +1 @@ +export { default } from './MovieCard'; \ No newline at end of file diff --git a/client/app/components/index.ts b/client/app/components/index.ts new file mode 100644 index 0000000..596d313 --- /dev/null +++ b/client/app/components/index.ts @@ -0,0 +1,2 @@ +// Components +export { default as MovieCard } from './MovieCard'; \ No newline at end of file diff --git a/client/app/components/ui/ErrorDisplay.tsx b/client/app/components/ui/ErrorDisplay.tsx new file mode 100644 index 0000000..949301a --- /dev/null +++ b/client/app/components/ui/ErrorDisplay.tsx @@ -0,0 +1,55 @@ +/** + * Error component for displaying error states + */ + +interface ErrorDisplayProps { + message?: string; + onRetry?: () => void; +} + +export default function ErrorDisplay({ + message = "Something went wrong", + onRetry +}: ErrorDisplayProps) { + return ( +
+

Error

+

{message}

+ {onRetry && ( + + )} + + +
+ ); +} \ No newline at end of file diff --git a/client/app/components/ui/LoadingSpinner.tsx b/client/app/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..31ad4d0 --- /dev/null +++ b/client/app/components/ui/LoadingSpinner.tsx @@ -0,0 +1,59 @@ +'use client'; + +/** + * Loading spinner component + */ + +interface LoadingSpinnerProps { + size?: 'small' | 'medium' | 'large'; + message?: string; +} + +export default function LoadingSpinner({ + size = 'medium', + message = 'Loading...' +}: LoadingSpinnerProps) { + const sizeClasses = { + small: 'w-4 h-4', + medium: 'w-8 h-8', + large: 'w-12 h-12' + }; + + return ( +
+
+ {message &&

{message}

} + + +
+ ); +} \ No newline at end of file diff --git a/client/app/components/ui/index.ts b/client/app/components/ui/index.ts new file mode 100644 index 0000000..81db7a9 --- /dev/null +++ b/client/app/components/ui/index.ts @@ -0,0 +1,3 @@ +// UI Components +export { default as ErrorDisplay } from './ErrorDisplay'; +export { default as LoadingSpinner } from './LoadingSpinner'; \ No newline at end of file diff --git a/client/app/globals.css b/client/app/globals.css new file mode 100644 index 0000000..1d8a143 --- /dev/null +++ b/client/app/globals.css @@ -0,0 +1,11 @@ +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} \ No newline at end of file diff --git a/client/app/home.module.css b/client/app/home.module.css new file mode 100644 index 0000000..03326c6 --- /dev/null +++ b/client/app/home.module.css @@ -0,0 +1,95 @@ +/** + * Home Page Styles + * + * Styles specific to the home/landing page + */ + +.page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; + text-align: center; +} + +.main { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + max-width: 600px; +} + +.title { + font-size: 3rem; + font-weight: 700; + color: #333; + margin: 0; + letter-spacing: -0.025em; +} + +.description { + font-size: 1.25rem; + color: #666; + margin: 0; + line-height: 1.6; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1rem 2rem; + font-size: 1.1rem; + font-weight: 600; + color: white; + background: #0066cc; + border: none; + border-radius: 8px; + text-decoration: none; + transition: all 0.2s ease; + cursor: pointer; + min-width: 180px; +} + +.button:hover { + background: #0052a3; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 102, 204, 0.3); +} + +.button:active { + transform: translateY(0); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .title { + font-size: 2.5rem; + } + + .description { + font-size: 1.1rem; + } + + .page { + padding: 1rem; + } +} + +@media (max-width: 480px) { + .title { + font-size: 2rem; + } + + .description { + font-size: 1rem; + } + + .button { + padding: 0.875rem 1.5rem; + font-size: 1rem; + } +} \ No newline at end of file diff --git a/client/app/layout.tsx b/client/app/layout.tsx new file mode 100644 index 0000000..2bea842 --- /dev/null +++ b/client/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import "./globals.css"; +export const metadata: Metadata = { + title: 'Sample MFlix - MongoDB Movie Database', + description: 'Explore movies from the MongoDB sample_mflix database', +}; +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} \ No newline at end of file diff --git a/client/app/lib/api.ts b/client/app/lib/api.ts new file mode 100644 index 0000000..7235435 --- /dev/null +++ b/client/app/lib/api.ts @@ -0,0 +1,62 @@ +import { Movie, MoviesApiResponse } from '../types/movie'; + +/** + * API configuration and helper functions + */ + +const API_BASE_URL = process.env.API_URL || 'http://localhost:3001'; + +/** + * Fetches all movies from the Express API + * This function runs on the server during SSR + */ +export async function fetchMovies(limit: number = 50): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/movies?limit=${limit}`, { + next: { revalidate: 300 }, // Revalidate every 5 minutes + }); + + if (!response.ok) { + throw new Error(`Failed to fetch movies: ${response.status}`); + } + + const result: MoviesApiResponse = await response.json(); + + if (!result.success) { + throw new Error('API returned error response'); + } + + return result.data; + } 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 array with logged error to prevent page crash + return []; + } +} + +/** + * Fetch a single movie by ID + */ +export async function fetchMovieById(id: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/movies/${id}`, { + next: { revalidate: 300 }, + }); + + if (!response.ok) { + return null; + } + + const result = await response.json(); + return result.success ? result.data : null; + } catch (error) { + console.error('Error fetching movie:', error); + return null; + } +} \ No newline at end of file diff --git a/client/app/lib/constants.ts b/client/app/lib/constants.ts new file mode 100644 index 0000000..961d8b2 --- /dev/null +++ b/client/app/lib/constants.ts @@ -0,0 +1,21 @@ +/** + * Application constants + */ + +export const APP_CONFIG = { + name: 'MFlix', + description: 'Browse movies from the sample MFlix database', + defaultMovieLimit: 50, + imageFormats: ['image/avif', 'image/webp'], +} as const; + +export const ROUTES = { + home: '/', + movies: '/movies', + movie: (id: string) => `/movie/${id}`, +} as const; + +export const API_ENDPOINTS = { + movies: '/api/movies', + movie: (id: string) => `/api/movies/${id}`, +} as const; \ No newline at end of file diff --git a/client/app/lib/utils.ts b/client/app/lib/utils.ts new file mode 100644 index 0000000..d3d00cd --- /dev/null +++ b/client/app/lib/utils.ts @@ -0,0 +1,40 @@ +/** + * Utility functions for the application + */ + +/** + * Formats a movie year for display + */ +export function formatYear(year?: number): string { + return year ? `(${year})` : ''; +} + +/** + * Formats movie genres for display + */ +export function formatGenres(genres?: string[], maxGenres: number = 3): string { + if (!genres || genres.length === 0) return ''; + return genres.slice(0, maxGenres).join(', '); +} + +/** + * Formats IMDB rating for display + */ +export function formatRating(rating?: number): string { + return rating ? `⭐ ${rating}/10` : ''; +} + +/** + * Truncates text to a specified length + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.substring(0, maxLength)}...`; +} + +/** + * Generates a placeholder image URL for broken images + */ +export function getPlaceholderImage(width: number = 300, height: number = 450): string { + return `data:image/svg+xml,%3Csvg width='${width}' height='${height}' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='%23f5f5f5'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23666' font-family='Arial, sans-serif' font-size='16'%3ENo Poster Available%3C/text%3E%3C/svg%3E`; +} \ No newline at end of file diff --git a/client/app/movies/error.tsx b/client/app/movies/error.tsx new file mode 100644 index 0000000..e063d3c --- /dev/null +++ b/client/app/movies/error.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { ErrorDisplay } from '../components/ui'; + +/** + * Error boundary for movies page + */ +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} \ No newline at end of file diff --git a/client/app/movies/loading.module.css b/client/app/movies/loading.module.css new file mode 100644 index 0000000..8f2d1d2 --- /dev/null +++ b/client/app/movies/loading.module.css @@ -0,0 +1,45 @@ +/** + * Loading Component Styles + * + * CSS Module for the movies loading component. + */ + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; +} + +.loadingSpinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #0066cc; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.pageTitle { + margin: 0 0 32px 0; + font-size: 32px; + color: #333; +} + +.loadingMessage { + color: #666; + font-size: 16px; + margin: 0; +} \ No newline at end of file diff --git a/client/app/movies/loading.tsx b/client/app/movies/loading.tsx new file mode 100644 index 0000000..7d6c498 --- /dev/null +++ b/client/app/movies/loading.tsx @@ -0,0 +1,22 @@ + +import loadingStyles from "./loading.module.css"; + +/** + * Loading Component for Movies Page + * + * This component is automatically displayed by Next.js while the movies page + * is loading during Server Side Rendering or when navigating to the page. + */ +export default function Loading() { + return ( +
+
+

Movies

+
+
+

Loading movies from the database...

+
+
+
+ ); +} \ No newline at end of file diff --git a/client/app/movies/movies.module.css b/client/app/movies/movies.module.css new file mode 100644 index 0000000..37ec2c2 --- /dev/null +++ b/client/app/movies/movies.module.css @@ -0,0 +1,33 @@ +/** + * Movies Card Styles + * + * CSS Module for the movies card component. + * Provides responsive grid layout for the movies listing page. + */ + +.moviesGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 24px; + width: 100%; + max-width: 1200px; +} + +.noMovies { + text-align: center; + padding: 40px; + color: #666; +} + +.pageTitle { + margin: 0 0 16px 0; + font-size: 32px; + color: #333; +} + +.movieCount { + margin: 0 0 32px 0; + color: #666; + font-size: 16px; +} + diff --git a/client/app/movies/page.module.css b/client/app/movies/page.module.css new file mode 100644 index 0000000..e32b3f3 --- /dev/null +++ b/client/app/movies/page.module.css @@ -0,0 +1,29 @@ +/** + * Movies Page Layout Styles + * + * Layout-specific styles for the movies page structure + */ + +.page { + min-height: 100vh; + padding: 2rem; +} + +.main { + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +/* Responsive padding */ +@media (max-width: 768px) { + .page { + padding: 1rem; + } +} + +@media (max-width: 480px) { + .page { + padding: 0.5rem; + } +} \ No newline at end of file diff --git a/client/app/movies/page.tsx b/client/app/movies/page.tsx new file mode 100644 index 0000000..4cb2db3 --- /dev/null +++ b/client/app/movies/page.tsx @@ -0,0 +1,30 @@ +import pageStyles from "./page.module.css"; +import movieStyles from "./movies.module.css"; +import MovieCard from "../components/MovieCard"; +import { fetchMovies } from "../lib/api"; +import { APP_CONFIG } from "../lib/constants"; + +export default async function Movies() { + const movies = await fetchMovies(APP_CONFIG.defaultMovieLimit); + + return ( +
+
+

Movies

+

Displaying {movies.length} movies from the sample_mflix database

+ + {movies.length === 0 ? ( +
+

No movies found. Make sure the Express server is running on port 3001.

+
+ ) : ( +
+ {movies.map((movie) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/client/app/page.tsx b/client/app/page.tsx new file mode 100644 index 0000000..c74794d --- /dev/null +++ b/client/app/page.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; +import styles from "./home.module.css"; + +export default function Home() { + return ( +
+
+

Sample Mflix

+

+ Explore movies from the sample MFlix database +

+ + See movies + +
+
+ ); +} diff --git a/client/app/types/movie.ts b/client/app/types/movie.ts new file mode 100644 index 0000000..ea5b8d8 --- /dev/null +++ b/client/app/types/movie.ts @@ -0,0 +1,37 @@ +/** + * Shared type definitions for the Movie application + * These types match the backend API response structure + */ + +/** + * Movie interface for type safety + * Matches the Movie type from the Express backend + */ +export interface Movie { + _id: string; + title: string; + year?: number; + plot?: string; + poster?: string; + genres?: string[]; + imdb?: { + rating?: number; + }; +} + +/** + * API Response interface for the movies endpoint + * Matches the SuccessResponse type from the Express backend + */ +export interface MoviesApiResponse { + success: boolean; + data: Movie[]; + message?: string; +} + +/** + * Props interface for MovieCard component + */ +export interface MovieCardProps { + movie: Movie; +} \ No newline at end of file diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs new file mode 100644 index 0000000..719cea2 --- /dev/null +++ b/client/eslint.config.mjs @@ -0,0 +1,25 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, +]; + +export default eslintConfig; diff --git a/client/next.config.ts b/client/next.config.ts new file mode 100644 index 0000000..701bc37 --- /dev/null +++ b/client/next.config.ts @@ -0,0 +1,26 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + images: { + // Allow images from external domains (movie poster URLs) + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + { + protocol: 'http', + hostname: '**', + }, + ], + // Optimize image formats + formats: ['image/avif', 'image/webp'], + // Image sizes for responsive images + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + }, + // Enable compression for better performance + compress: true, +}; + +export default nextConfig; diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..3cc711d --- /dev/null +++ b/client/package.json @@ -0,0 +1,25 @@ +{ + "name": "sample-mflix-front-end", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build --turbopack", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "react": "19.2.0", + "react-dom": "19.2.0", + "next": "16.0.0" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.0.0", + "@eslint/eslintrc": "^3" + } +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..fec0bb6 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./app/*"], + "@/types/*": ["./app/types/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/server/express/package.json b/server/express/package.json index 299ceb0..29b341c 100644 --- a/server/express/package.json +++ b/server/express/package.json @@ -16,9 +16,9 @@ }, "dependencies": { "cors": "^2.8.5", - "dotenv": "^16.3.1", + "dotenv": "^17.2.3", "express": "^5.1.0", - "mongodb": "^6.3.0" + "mongodb": "^6.20.0" }, "devDependencies": { "@types/cors": "^2.8.17",