diff --git a/.gitignore b/.gitignore index b8ffe08..955652d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ package-lock.json - +.env +.DS_Store +*.log +.vscode/ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 25b5d47..5b4fe77 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,46 @@ -Assignment 2 - Short Stack: Basic Two-tier Web Application using HTML/CSS/JS and Node.js -=== +# Movie Rating Tracker - Aanan Goyal -Due: Monday, September 8, 2025, by 11:59 PM. +https://a3-aanangoyal.onrender.com -This assignment aims to introduce you to creating a prototype two-tiered web application. -Your application will include the use of HTML, CSS, JavaScript, and Node.js functionality, with active communication between the client and the server over the life of a user session. +A web application for tracking movie ratings with user authentication and persistent storage. Built with Express, MongoDB, and Bootstrap. -Baseline Requirements ---- +## Features -There is a large range of application areas and possibilities that meet these baseline requirements. -Try to make your application do something useful! A todo list, storing / retrieving high scores for a very simple game... have a little fun with it. +- **User Authentication**: Simple login system that creates accounts on first login +- **Personal Movie Collections**: Each user has their own private movie list +- **Full CRUD Operations**: Add, view, update ratings, and delete movies +- **Automatic Recommendations**: Movies categorized as Must Watch, Highly Recommended, Worth Watching, or Skip +- **Responsive Design**: Clean Bootstrap-based UI that works on all devices -Your application is required to implement the following functionalities (4 pts each, total 20 pts): +## Authentication Strategy -- a `Server` which not only serves files, but also maintains a tabular dataset with 3 or more fields related to your application -- a `Results` functionality which shows the entire dataset residing in the server's memory -- a `Form/Entry` functionality which allows a user to add or delete data items residing in the server's memory -- a `Server Logic` which, upon receiving new or modified "incoming" data, includes and uses a function that adds at least one additional derived field to this incoming data before integrating it with the existing dataset -- the `Derived field` for a new row of data must be computed based on fields already existing in the row. -For example, a `todo` dataset with `task`, `priority`, and `creation_date` may generate a new field `deadline` by looking at `creation_date` and `priority` +I chose simple username/password authentication because it was straightforward to implement and meets the assignment requirements. New accounts are automatically created on first login, which is clearly communicated to users on the login page. -Your application is required to demonstrate the use of the following concepts: +## CSS Framework -HTML (4 pts each, total 16 pts): -- One or more [HTML Forms](https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms), with any combination of form tags appropriate for the user input portion of the application -- A results page displaying all data currently available on the server. You will most likely use a `` tag for this, but `
+ + + + + + + + + + + + + + + +
TitleGenreRatingDate AddedRecommendationActions
Loading movies...
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/script.js b/public/script.js new file mode 100644 index 0000000..36c2545 --- /dev/null +++ b/public/script.js @@ -0,0 +1,185 @@ +document.addEventListener('DOMContentLoaded', function() { + const currentPage = window.location.pathname; + + if (currentPage === '/') { + initIndexPage(); + } else if (currentPage === '/results') { + initResultsPage(); + } + + function initIndexPage() { + const form = document.getElementById('movieForm'); + if (!form) return; + + loadRecentMovies(); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const data = { + title: document.getElementById('title').value, + genre: document.getElementById('genre').value, + rating: document.getElementById('rating').value + }; + + try { + const response = await fetch('/api/movies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + form.reset(); + loadRecentMovies(); + showMessage('Movie added successfully!', 'success'); + } else { + const error = await response.json(); + showMessage(error.error, 'danger'); + } + } catch (error) { + showMessage('Failed to add movie', 'danger'); + } + }); + + async function loadRecentMovies() { + try { + const response = await fetch('/api/movies'); + const movies = await response.json(); + displayRecentMovies(movies.slice(-3).reverse()); + } catch (error) { + document.getElementById('recentMovies').innerHTML = '

Failed to load movies

'; + } + } + + function displayRecentMovies(movies) { + const container = document.getElementById('recentMovies'); + if (movies.length === 0) { + container.innerHTML = '

No movies yet

'; + return; + } + + container.innerHTML = movies.map(movie => ` +
+
${escapeHtml(movie.title)}
+ ${movie.genre} • ${movie.rating}/10 • ${movie.recommendation} +
+ `).join(''); + } + } + + function initResultsPage() { + loadAllMovies(); + + async function loadAllMovies() { + try { + const response = await fetch('/api/movies'); + const movies = await response.json(); + displayMovies(movies); + document.getElementById('movieCount').textContent = movies.length; + } catch (error) { + showMessage('Failed to load movies', 'danger'); + } + } + + function displayMovies(movies) { + const tbody = document.getElementById('moviesTableBody'); + const table = document.getElementById('moviesTable'); + const emptyState = document.getElementById('emptyState'); + + if (movies.length === 0) { + table.style.display = 'none'; + emptyState.style.display = 'block'; + return; + } + + table.style.display = 'table'; + emptyState.style.display = 'none'; + + tbody.innerHTML = movies.map(movie => ` + + ${escapeHtml(movie.title)} + ${movie.genre} + + + + ${formatDate(movie.dateAdded)} + ${movie.recommendation} + + + + + `).join(''); + } + + window.loadAllMovies = loadAllMovies; + } + + window.deleteMovie = async function(id) { + if (!confirm('Delete this movie?')) return; + + try { + const response = await fetch(`/api/movies/${id}`, { method: 'DELETE' }); + if (response.ok) { + if (window.loadAllMovies) window.loadAllMovies(); + showMessage('Movie deleted', 'success'); + } + } catch (error) { + showMessage('Delete failed', 'danger'); + } + }; + + window.updateRating = async function(id, rating) { + try { + const response = await fetch(`/api/movies/${id}/rating`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rating: parseFloat(rating) }) + }); + + if (response.ok && window.loadAllMovies) { + window.loadAllMovies(); + showMessage('Rating updated', 'success'); + } + } catch (error) { + showMessage('Update failed', 'danger'); + } + }; + + function getRecBadge(rec) { + const badges = { + 'Must Watch': 'bg-danger', + 'Highly Recommended': 'bg-primary', + 'Worth Watching': 'bg-success', + 'Skip': 'bg-secondary' + }; + return badges[rec] || 'bg-secondary'; + } + + function formatDate(dateString) { + return new Date(dateString).toLocaleDateString(); + } + + function showMessage(message, type) { + const alert = document.createElement('div'); + alert.className = `alert alert-${type} alert-dismissible fade show position-fixed`; + alert.style.cssText = 'top: 20px; right: 20px; z-index: 1050; min-width: 300px;'; + alert.innerHTML = ` + ${message} + + `; + document.body.appendChild(alert); + setTimeout(() => alert.remove(), 3000); + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +}); \ No newline at end of file diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..edba875 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,101 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: Arial, sans-serif; + line-height: 1.5; + background: #f4f4f4; + color: #333; + min-height: 100vh; + text-align: center; +} +.container { max-width: 1000px; margin: auto; padding: 20px; } + +.header { + background: #fff; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; +} +.header h1 { font-size: 2rem; margin-bottom: 5px; } +.header p { font-size: 1rem; color: #555; } + +.navigation { + display: flex; justify-content: center; gap: 15px; margin-bottom: 20px; +} +.nav-link { + padding: 8px 16px; + background: #eee; + border-radius: 20px; + text-decoration: none; + color: #333; +} +.nav-link.active { background: #333; color: #fff; } + +.main-content { display: grid; gap: 20px; grid-template-columns: 1fr 1fr; } +.section { + background: #fff; + padding: 20px; + border-radius: 8px; + text-align: left; +} +.section h2 { font-size: 1.3rem; margin-bottom: 15px; } + +.movie-form { display: flex; flex-direction: column; gap: 15px; } +.form-group label { font-weight: bold; margin-bottom: 5px; } +.form-group input, .form-group select { + padding: 10px; border: 1px solid #ccc; border-radius: 5px; +} +.submit-btn { + background: #333; color: #fff; + padding: 10px; border: none; border-radius: 5px; cursor: pointer; +} + +.recent-movies { display: flex; flex-direction: column; gap: 10px; } +.movie-card { + background: #fafafa; padding: 10px; border-left: 4px solid #333; +} +.movie-title { font-weight: bold; margin-bottom: 5px; } +.view-all-link { + display: inline-block; margin-top: 10px; + padding: 8px 12px; background: #333; color: #fff; + border-radius: 5px; text-decoration: none; +} + +.results-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 15px; +} +.movie-count { + background: #333; color: #fff; padding: 5px 10px; border-radius: 15px; +} +.movies-table { + width: 100%; border-collapse: collapse; +} +.movies-table th, .movies-table td { + padding: 10px; border-bottom: 1px solid #ccc; text-align: left; +} +.movies-table th { background: #333; color: #fff; } + +.rating-badge, .recommendation-badge { + padding: 3px 6px; border-radius: 8px; font-size: 0.8rem; color: #fff; +} +.rating-excellent { background: #4caf50; } +.rating-good { background: #2196f3; } +.rating-average { background: #ff9800; } +.rating-poor { background: #f44336; } +.rec-must-watch { background: #6a1b9a; } +.rec-highly-recommended { background: #1976d2; } +.rec-worth-watching { background: #388e3c; } +.rec-mediocre { background: #ff9800; } +.rec-skip { background: #f44336; } + +.delete-btn { + background: #f44336; color: #fff; + border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; +} + +.footer { margin-top: 30px; color: #777; } + +@media (max-width: 768px) { + .main-content { grid-template-columns: 1fr; } + .results-header { flex-direction: column; gap: 10px; } +} diff --git a/server.improved.js b/server.improved.js deleted file mode 100644 index 0f63012..0000000 --- a/server.improved.js +++ /dev/null @@ -1,74 +0,0 @@ -const http = require( "http" ), - fs = require( "fs" ), - // IMPORTANT: you must run `npm install` in the directory for this assignment - // to install the mime library if you"re testing this on your local machine. - // However, Glitch will install it automatically by looking in your package.json - // file. - mime = require( "mime" ), - dir = "public/", - port = 3000 - -const appdata = [ - { "model": "toyota", "year": 1999, "mpg": 23 }, - { "model": "honda", "year": 2004, "mpg": 30 }, - { "model": "ford", "year": 1987, "mpg": 14} -] - -const server = http.createServer( function( request,response ) { - if( request.method === "GET" ) { - handleGet( request, response ) - }else if( request.method === "POST" ){ - handlePost( request, response ) - } -}) - -const handleGet = function( request, response ) { - const filename = dir + request.url.slice( 1 ) - - if( request.url === "/" ) { - sendFile( response, "public/index.html" ) - }else{ - sendFile( response, filename ) - } -} - -const handlePost = function( request, response ) { - let dataString = "" - - request.on( "data", function( data ) { - dataString += data - }) - - request.on( "end", function() { - console.log( JSON.parse( dataString ) ) - - // ... do something with the data here!!! - - response.writeHead( 200, "OK", {"Content-Type": "text/plain" }) - response.end("test") - }) -} - -const sendFile = function( response, filename ) { - const type = mime.getType( filename ) - - fs.readFile( filename, function( err, content ) { - - // if the error = null, then we"ve loaded the file successfully - if( err === null ) { - - // status code: https://httpstatuses.com - response.writeHeader( 200, { "Content-Type": type }) - response.end( content ) - - }else{ - - // file not found, error code 404 - response.writeHeader( 404 ) - response.end( "404 Error: File Not Found" ) - - } - }) -} - -server.listen( process.env.PORT || port ) diff --git a/server.js b/server.js new file mode 100644 index 0000000..765b038 --- /dev/null +++ b/server.js @@ -0,0 +1,209 @@ +require('dotenv').config(); +const express = require('express'); +const { MongoClient } = require('mongodb'); +const session = require('express-session'); +const bcrypt = require('bcryptjs'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// MongoDB setup +let db; +const client = new MongoClient(process.env.MONGODB_URI); + +// Middleware +app.use(express.static('public')); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(session({ + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } +})); + +// Auth middleware +const requireAuth = (req, res, next) => { + if (!req.session.userId) { + return res.redirect('/login'); + } + next(); +}; + +// Connect to MongoDB +async function connectDB() { + try { + await client.connect(); + db = client.db('movietracker'); + console.log('Connected to MongoDB'); + } catch (error) { + console.error('MongoDB connection error:', error); + process.exit(1); + } +} + +// Helper function to calculate recommendation +function calculateRecommendation(rating) { + if (rating >= 9.0) return "Must Watch"; + if (rating >= 7.5) return "Highly Recommended"; + if (rating >= 6.0) return "Worth Watching"; + return "Skip"; +} + +// Routes +app.get('/', requireAuth, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +app.get('/results', requireAuth, (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'results.html')); +}); + +app.get('/login', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'login.html')); +}); + +// Auth endpoints +app.post('/api/login', async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + + try { + let user = await db.collection('users').findOne({ username }); + + if (!user) { + // Create new user + const hashedPassword = await bcrypt.hash(password, 10); + user = { + username, + password: hashedPassword, + movies: [], + createdAt: new Date() + }; + await db.collection('users').insertOne(user); + req.session.userId = user._id; + req.session.username = username; + return res.json({ message: 'Account created successfully!' }); + } + + // Check password + const validPassword = await bcrypt.compare(password, user.password); + if (!validPassword) { + return res.status(401).json({ error: 'Invalid password' }); + } + + req.session.userId = user._id; + req.session.username = username; + res.json({ message: 'Login successful' }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Login failed' }); + } +}); + +app.post('/api/logout', (req, res) => { + req.session.destroy((err) => { + if (err) { + return res.status(500).json({ error: 'Logout failed' }); + } + res.json({ message: 'Logged out successfully' }); + }); +}); + +// Movie endpoints +app.get('/api/movies', requireAuth, async (req, res) => { + try { + const user = await db.collection('users').findOne({ username: req.session.username }); + res.json(user.movies || []); + } catch (error) { + console.error('Error fetching movies:', error); + res.status(500).json({ error: 'Failed to fetch movies' }); + } +}); + +app.post('/api/movies', requireAuth, async (req, res) => { + const { title, genre, rating } = req.body; + + if (!title || !genre || rating === undefined) { + return res.status(400).json({ error: 'All fields required' }); + } + + try { + const newMovie = { + id: Date.now(), + title, + genre, + rating: parseFloat(rating), + dateAdded: new Date().toISOString(), + recommendation: calculateRecommendation(parseFloat(rating)) + }; + + await db.collection('users').updateOne( + { username: req.session.username }, + { $push: { movies: newMovie } } + ); + + res.json(newMovie); + } catch (error) { + console.error('Error adding movie:', error); + res.status(500).json({ error: 'Failed to add movie' }); + } +}); + +app.patch('/api/movies/:id/rating', requireAuth, async (req, res) => { + const movieId = parseInt(req.params.id); + const { rating } = req.body; + + try { + const newRating = parseFloat(rating); + const newRecommendation = calculateRecommendation(newRating); + + await db.collection('users').updateOne( + { username: req.session.username, "movies.id": movieId }, + { + $set: { + "movies.$.rating": newRating, + "movies.$.recommendation": newRecommendation + } + } + ); + + res.json({ message: 'Rating updated' }); + } catch (error) { + console.error('Error updating rating:', error); + res.status(500).json({ error: 'Failed to update rating' }); + } +}); + +app.delete('/api/movies/:id', requireAuth, async (req, res) => { + const movieId = parseInt(req.params.id); + + try { + await db.collection('users').updateOne( + { username: req.session.username }, + { $pull: { movies: { id: movieId } } } + ); + + res.json({ message: 'Movie deleted' }); + } catch (error) { + console.error('Error deleting movie:', error); + res.status(500).json({ error: 'Failed to delete movie' }); + } +}); + +// Start server +connectDB().then(() => { + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); +}); + +// Graceful shutdown +process.on('SIGINT', async () => { + await client.close(); + process.exit(0); +}); \ No newline at end of file