diff --git a/README.md b/README.md index 8da5680..94c0398 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ -# ๐ŸŽฌ SceneStack +# SceneStack -> *Tracking the shows youโ€™ll definitely finishโ€ฆ eventuallyโ€ฆ probably not.* - -A personal movie & TV show tracker with a gorgeous glassmorphic UI. Yes, it's *another* media tracking app โ€” the "TODO list" of MERN stack projects. +> A modern full-stack web application for tracking movies and TV shows. Built as a learning project to explore the MERN stack, TypeScript, real-time communication, and scalable application architecture. ![React](https://img.shields.io/badge/React-19-61DAFB?logo=react) ![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript) @@ -10,216 +8,451 @@ A personal movie & TV show tracker with a gorgeous glassmorphic UI. Yes, it's *a ![Express](https://img.shields.io/badge/Express-4-000000?logo=express) ![MongoDB](https://img.shields.io/badge/MongoDB-Atlas-47A248?logo=mongodb) ![Redis](https://img.shields.io/badge/Redis-Upstash-DC382D?logo=redis) -![Vite](https://img.shields.io/badge/Vite-7-646CFF?logo=vite) -> ๐ŸŽ“ **Learning Project** โ€” This is my playground for learning the MERN stack (MongoDB, Express, React, Node.js), TypeScript, and modern web development. +**Learning Project** โ€” This application was developed to gain hands-on experience with modern web development technologies and architectural patterns including RESTful APIs, real-time synchronization, client-side routing, state management, and responsive design. --- -## โœจ Features - -- ๐Ÿ” **Search** movies & TV shows via TMDB -- ๐Ÿ“บ **Track episodes** - mark individual episodes as watched -- ๐Ÿ“Š **Statistics** - see exactly how much of your life you've spent watching -- ๐Ÿท๏ธ **Tags** - organize your chaos with custom tags (Still WIP) -- ๐ŸŽฏ **Recommendations** - discover what to watch next -- โ˜๏ธ **Real-time sync** - your watchlist lives in the cloud, access from anywhere -- ๐Ÿ”” **Notifications** - get alerted when new episodes drop (WIP โ€” TMDB doesn't have a notifications API, so I'm currently negotiating with their servers to not rate-limit me into oblivion) -- ๐ŸŒ™ **Dark mode only** - because we're civilized +## Features + +**Core Functionality** +- Full-text search for movies and TV shows powered by TMDB API +- Episode-level tracking for TV series with season/episode granularity +- Personal watchlist with status management (watching, completed, planned) +- Custom tagging system for content organization +- Personalized recommendations based on viewing history +- Cloud-synchronized data with real-time updates across devices + +**Analytics & Insights** +- Viewing statistics dashboard with time tracking +- Completion rate and binge-level metrics +- Genre analysis and taste profiling +- Visual progress indicators + +**User Experience** +- Dark-themed glassmorphic UI with smooth animations +- Fully responsive design (mobile-first approach) +- Client-side routing with browser history integration +- Optimistic UI updates with conflict resolution + +**Security & Performance** +- JWT-based authentication with HTTP-only cookies +- Server-side TMDB API proxy (prevents exposed API keys) +- Redis-based response caching with configurable TTL +- Rate limiting and request throttling +- Deferred rendering for heavy computations + +**Work in Progress** +- Episode release notifications (currently exploring TMDB's content update API patterns) --- -## ๐Ÿ—‚๏ธ Project Structure +## Architecture + +### System Overview ``` -SceneStack/ -โ”œโ”€โ”€ cinetrack-app/ โ† The actual app lives here -โ”‚ โ”œโ”€โ”€ client/ โ† React frontend (Vite + TypeScript) -โ”‚ โ”œโ”€โ”€ server/ โ† Express backend (Node.js) -โ”‚ โ””โ”€โ”€ package.json โ† Monorepo root -โ””โ”€โ”€ README.md โ† You are here +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Client (React SPA) โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ React Routerโ”‚ โ”‚ Zustand โ”‚ โ”‚ Socket.IO Clientโ”‚ โ”‚ +โ”‚ โ”‚ (SPA Routes)โ”‚ โ”‚(State Mgmt) โ”‚ โ”‚ (Real-time Sync)โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ HTTPS/WSS +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Express.js Server (Node.js) โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Auth โ”‚ โ”‚ Watchlist โ”‚ โ”‚ TMDB Proxy โ”‚ โ”‚ +โ”‚ โ”‚ Middleware โ”‚ โ”‚ Routes โ”‚ โ”‚ (Server-side) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Socket.IO โ”‚ โ”‚ Redis โ”‚ โ”‚ +โ”‚ โ”‚ Server โ”‚ โ”‚ Cache โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ MongoDB Atlas โ”‚ โ”‚ TMDB API โ”‚ + โ”‚ (Database) โ”‚ โ”‚ (External) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` -### "Why is there a `cinetrack-app` folder inside `SceneStack`?" - -Ah yes, the elephant in the repo. ๐Ÿ˜ - -**The honest answer:** This project started as CineTrack before being renamed. The folder structure is a happy accident that became permanent. By the time I realized the mistake, I had already pushed commits and... well... here we are. - -**The professional answer:** It's a deliberate architectural decision to support future multi-app monorepo expansion. *\*adjusts imaginary glasses\** - -**TL;DR:** It's technical debt I've learned to love. Like that one weird bug that somehow makes everything work. +### Technology Stack + +| Layer | Technology | +|-------|------------| +| **Frontend** | React 19 + TypeScript | +| **Routing** | React Router v7 | +| **State Management** | Zustand | +| **Styling** | Tailwind CSS 4 | +| **Build Tool** | Vite 7 | +| **Backend** | Express.js 4 | +| **Real-time** | Socket.IO | +| **Database** | MongoDB Atlas | +| **Caching** | Redis (Upstash) | +| **Authentication** | JWT + bcrypt | +| **External API** | TMDB API v3 | + +### Key Architectural Decisions + +**Monorepo Structure** +- Client and server share a workspace for atomic commits and coordinated deployment +- Single build process compiles React app and serves it via Express in production + +**State Management Evolution** +- Migrated from React Context to Zustand for better render optimization +- Implemented selector hooks to prevent unnecessary re-renders +- Deferred heavy computations with React 18's `useDeferredValue` + +**API Design** +- Server-side TMDB proxy eliminates client-side API key exposure +- Redis caching reduces external API calls by ~85% (configurable TTL per endpoint) +- LRU eviction for high-volume endpoints (search, details, recommendations) + +**Real-time Synchronization** +- Socket.IO broadcasts watchlist changes to all user sessions +- Optimistic UI updates with server confirmation +- Conflict resolution on concurrent modifications + +**Performance Optimizations** +- Route-based code splitting with React lazy loading +- Manual chunk splitting (React vendor, Socket.IO, libraries) +- Debounced search with abort controller for in-flight requests +- Image lazy loading with intersection observer --- -## ๐Ÿ—๏ธ Why a Monorepo? +## Project Structure -The frontend and backend are in the same repository because: +``` +SceneStack/ +โ”œโ”€โ”€ cinetrack-app/ # Monorepo root +โ”‚ โ”œโ”€โ”€ client/ # React frontend +โ”‚ โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ components/ # Reusable UI components +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ contexts/ # React contexts (Auth, UI, Discover) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ layouts/ # Layout components (RootLayout) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ pages/ # Route-level page components +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ services/ # API client layer +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ store/ # Zustand stores +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ types/ # TypeScript type definitions +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ router.tsx # Route configuration +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ App.tsx # Root component +โ”‚ โ”‚ โ”œโ”€โ”€ package.json +โ”‚ โ”‚ โ””โ”€โ”€ vite.config.ts +โ”‚ โ”œโ”€โ”€ server/ # Express backend +โ”‚ โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ config.js # Environment config & Redis init +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ middleware/ # Error handling, validation +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ routes/ # API route handlers +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ authRoutes.js +โ”‚ โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ watchlistRoutes.js +โ”‚ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ tmdbRoutes.js +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ schemas/ # Zod validation schemas +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ server.js # Express app entry point +โ”‚ โ”‚ โ””โ”€โ”€ package.json +โ”‚ โ”œโ”€โ”€ package.json # Workspace root (npm workspaces) +โ”‚ โ””โ”€โ”€ Dockerfile # Multi-stage production build +โ””โ”€โ”€ README.md # This file +``` -1. **Shared deployment** - One push, both deploy. Render loves it. I love it. Everyone's happy. -2. **Atomic commits** - Change the API and the frontend together. No more "which version is this compatible with?" -3. **Simplicity** - It's just me here. +**Note on Naming**: The `cinetrack-app` directory exists as a historical artifact from the project's original name. While the project is now branded as SceneStack, the internal folder structure remains unchanged. This is a deliberate decisionโ€”renaming would require updating import paths, deployment configurations, and version control history. Sometimes technical debt is best left untouched. --- -## ๐Ÿš€ Getting Started +## Getting Started -### ๐Ÿ‡ฎ๐Ÿ‡ณ Important Note +### Prerequisites -> **Plot twist:** The app will NOT work in India unless you set a custom DNS. TMDB is blocked on Jio (Not tested other ISP's), for reasons known only to Jio, who apparently thinks a harmless movie database is more dangerous than the thousands of scam sites that load instantly when you misspell โ€œgmail.โ€ +- **Node.js** 18 or higher +- **MongoDB Atlas** account (or local MongoDB instance) +- **TMDB API** Read Access Token ([get one here](https://www.themoviedb.org/settings/api)) +- **Redis** instance (optional but recommended โ€” Upstash free tier works well) -**The fix:** Use a custom DNS provider. -- [Cloudflare DNS](https://1.1.1.1/) โ€” `1.1.1.1` -- [Google DNS](https://developers.google.com/speed/public-dns) โ€” `8.8.8.8` -- [NextDNS](https://nextdns.io/) โ€” for the privacy-conscious -- [AdGuard DNS](https://adguard-dns.io/) โ€” blocks ads too +### Important Note -Set it on your router, device, or browser, and you're good to go. Welcome to the internet, the way it was meant to be. +TMDB API endpoints are blocked by some Indian ISPs (particularly Jio). If you encounter connection errors, configure a custom DNS provider: -### Prerequisites +- **Cloudflare DNS**: `1.1.1.1` / `1.0.0.1` +- **Google DNS**: `8.8.8.8` / `8.8.4.4` +- **Quad9 DNS**: `9.9.9.9` -- Node.js 18+ -- MongoDB Atlas account (or local MongoDB) -- TMDB API key ([get one here](https://www.themoviedb.org/settings/api)) -- Redis (optional - [Upstash](https://upstash.com/) free tier works great) +Configure DNS at the router level, operating system network settings, or browser level (Firefox, Edge support DNS over HTTPS). -### Setup +### Local Development Setup ```bash -# Clone the repo +# Clone the repository git clone https://github.com/Alameen1433/SceneStack.git cd SceneStack/cinetrack-app # Install all dependencies (uses npm workspaces) npm install -# Set up environment variables +# Configure server environment cp server/.env.example server/.env -# Edit server/.env with your MongoDB URI, JWT secret, etc. +# Edit server/.env with your credentials (MongoDB URI, JWT secret, etc.) +# Configure client environment (optional - only needed if using client-side fallback) cp client/.env.example client/.env -# Edit client/.env with your TMDB read access token +# Edit client/.env with your TMDB token ``` -### Development Mode +### Running in Development Mode -Run the client and server in **two separate terminals**: +The application requires two processes: the Express backend and the Vite dev server. Run in separate terminals: ```bash -# Terminal 1 - Start the backend server (port 3001) +# Terminal 1: Start the backend server (http://localhost:3001) npm run dev:server -# Terminal 2 - Start the frontend dev server (port 5173) +# Terminal 2: Start the frontend dev server (http://localhost:5173) npm run dev:client ``` -### Deployment (Render, AWS, Railway, etc.) +The Vite dev server proxies API requests to the backend automatically (configured in `client/vite.config.ts`). + +### Development with Docker (Optional) + +For local development, you can use Docker Compose to run MongoDB and Redis locally instead of using cloud services. + +**Start local services:** +```bash +cd cinetrack-app/infra +docker-compose up -d +``` + +This starts: +- **MongoDB** on `localhost:27017` (data persisted in `infra/data/mongo`) +- **Redis** on `localhost:6379` (data persisted in `infra/data/redis`) + +**Update your `.env` file:** +```bash +# Use local MongoDB instead of Atlas +MONGO_URI=mongodb://localhost:27017/scenestackDB + +# Use local Redis instead of Upstash +REDIS_URL=redis://localhost:6379 +``` + +**Stop services:** +```bash +docker-compose down +``` + +**Note**: The `Dockerfile` in the project root is for production deployment, not local development. It uses a multi-stage build to compile the React app and create a minimal production image. + +### Production Deployment + +The application is designed to run as a monolithic deployment where Express serves both the API and the compiled React frontend. -This project is configured to run as a **monolith** in production: the Express server serves both the API and the compiled React frontend. +**Build Process:** +1. `npm run build` compiles the React app to `client/dist/` +2. `npm start` runs the Express server which serves `client/dist/` as static files -**General Application Settings:** -- **Build Command:** `npm install && npm run build` (Installs deps & compiles React) -- **Start Command:** `npm start` (Starts Express server) -- **Root Directory:** `cinetrack-app` +**General Deployment Configuration:** +- **Build Command**: `npm install && npm run build` +- **Start Command**: `npm start` +- **Root Directory**: `cinetrack-app` +- **Node Version**: 18 or higher -#### Example: Deploying to Render -1. **Create a Web Service** and connect your GitHub repo -2. **Use the General Settings** above -3. **Add Environment Variables:** - - `MONGO_URI`, `JWT_SECRET`, `INVITE_CODES` - - `VITE_TMDB_API_READ_ACCESS_TOKEN` -4. **Deploy** +**Platform-Specific Examples:** -#### AWS / Other Platforms -The procedure is virtually the same. Ensure your platform builds the client (`npm run build`) and starts the server (`npm start`). -- **AWS Elastic Beanstalk:** Configure the build script to run `npm run build` before starting. -- **Railway/Heroku:** Similar to Render, just set the build and start commands. +
+Render -> **How it works:** `npm run build` compiles the React app to `client/dist/`. The Express server serves these static files in production alongside the API routes. +1. Create a new **Web Service** +2. Connect your GitHub repository +3. Configure: + - **Root Directory**: `cinetrack-app` + - **Build Command**: `npm install && npm run build` + - **Start Command**: `npm start` +4. Add environment variables (see below) +5. Deploy +
-### Environment Variables +
+AWS Elastic Beanstalk -**Server** (`cinetrack-app/server/.env`): -```env +1. Package the `cinetrack-app` directory +2. Create a Node.js environment +3. Configure `npm start` as the start command +4. Ensure `npm run build` runs before application start (use `.ebextensions` if needed) +5. Set environment variables via EB configuration +
+ +
+Railway / Heroku + +1. Connect repository +2. Set root directory to `cinetrack-app` +3. Railway auto-detects the build process +4. Add environment variables via dashboard +
+ +--- + +## Environment Variables + +### Server (`cinetrack-app/server/.env`) + +```bash # MongoDB connection string (required) MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/scenestackDB # JWT secret for token signing (required) -JWT_SECRET=your-super-secret-jwt-key +# Generate a secure secret: https://jwtsecrets.com/#generator +JWT_SECRET=your-super-secret-jwt-key-change-in-production -# Comma-separated invite codes for registration -INVITE_CODES=SCENESTACK2024,YOURCODE +# Comma-separated invite codes (required) +INVITE_CODES=SCENESTACK2024,FRIEND2024 + +# TMDB API Read Access Token (required) +TMDB_API_READ_ACCESS_TOKEN=your_tmdb_read_access_token + +# Redis connection string (optional - caching disabled if not set) +REDIS_URL=rediss://default:password@instance.upstash.io:6379 # Server port (optional, default: 3001) PORT=3001 -# TMDB API Read Access Token (required for TMDB proxy) -TMDB_API_READ_ACCESS_TOKEN=your_tmdb_read_access_token - -# Redis URL (optional - caching disabled if not set) -# Get a free Redis instance at https://upstash.com/ -REDIS_URL=rediss://default:your_password@your-instance.upstash.io:6379 +# Demo mode settings (optional) +DEMO_CODE=DEMONOW +DEMO_TTL_SECONDS=14400 ``` -**Client** (`cinetrack-app/client/.env`): -```env -# TMDB API Read Access Token (required - used as fallback if backend proxy fails) +### Client (`cinetrack-app/client/.env`) + +```bash +# TMDB Read Access Token (optional - used as fallback if backend proxy fails) VITE_TMDB_API_READ_ACCESS_TOKEN=your_tmdb_read_access_token -# API Base URL (leave empty in production) +# API base URL (leave empty in production) VITE_API_BASE_URL= ``` +**Note**: In production, the client should rely exclusively on the server-side TMDB proxy. The client-side token is only used as a fallback during development or if the proxy endpoint is unreachable. + --- -## ๐Ÿ“ก Tech Stack +## API Documentation + +### Authentication Endpoints + +**POST** `/api/auth/register` +- Registers a new user with invite code +- **Body**: `{ username, password, inviteCode }` +- **Response**: `{ user, token }` + +**POST** `/api/auth/login` +- Authenticates user and returns JWT +- **Body**: `{ username, password }` +- **Response**: `{ user, token }` + +### Watchlist Endpoints + +**GET** `/api/watchlist` +- Retrieves user's complete watchlist +- **Auth**: Required +- **Response**: `{ watchlist: WatchlistItem[] }` + +**PUT** `/api/watchlist/:id` +- Updates or creates a watchlist item +- **Auth**: Required +- **Response**: `{ item: WatchlistItem }` + +**DELETE** `/api/watchlist/:id` +- Removes an item from the watchlist +- **Auth**: Required +- **Response**: `{ success: boolean }` + +### TMDB Proxy Endpoints + +**GET** `/api/tmdb/discover` +- Fetches trending, popular movies, and popular TV shows +- **Cache**: 6 hours + +**GET** `/api/tmdb/search?q=` +- Searches for movies and TV shows +- **Cache**: 1 hour (LRU, max 1000 entries) + +**GET** `/api/tmdb/details/:type/:id` +- Fetches detailed information for a movie or TV show +- **Cache**: 1 hour (LRU, max 500 entries) + +--- -| Layer | Technology | Why | -|-------|------------|-----| -| Frontend | React 19 + TypeScript | Because I like my errors at compile time | -| Styling | Tailwind CSS | `className` that goes on for three lines | -| Build | Vite | Fast. Really fast. Like, make-webpack-look-like-a-sloth fast. | -| Backend | Express.js | Simple, battle-tested, no surprises | -| Real-time | Socket.IO | So your watchlist syncs faster than you can say "just one more episode" | -| Database | MongoDB Atlas | JSON in, JSON out. No ORM drama. | -| Cache | Redis (Upstash) | TMDB responses cached for speed & rate limit protection | -| Auth | JWT + bcrypt | Stateless sessions, hashed passwords | -| API | TMDB | The real MVP of this project | +## Learning Outcomes + +This project served as a comprehensive learning experience covering: + +**Frontend Development** +- Advanced React patterns (lazy loading, code splitting, render optimization) +- TypeScript integration in a large-scale application +- State management evolution (Context โ†’ Zustand) +- Client-side routing with React Router v7 +- CSS-in-JS to utility-first CSS migration +- Animation with Framer Motion + +**Backend Development** +- RESTful API design and authentication +- MongoDB schema design for flexible document structures +- Redis caching strategies (LRU eviction, TTL management) +- Socket.IO for real-time bidirectional communication +- Input validation with Zod schemas +- Error handling middleware patterns + +**DevOps & Deployment** +- Monorepo management with npm workspaces +- Docker multi-stage builds +- Environment-based configuration +- Production optimization (minification, compression, caching headers) + +**Software Engineering** +- Refactoring legacy code (migration from Context to Zustand) +- Performance profiling and optimization +- Security best practices (secret management, input sanitization) +- API rate limiting and quota management --- -## ๐ŸŽจ Screenshots +## Known Issues & Future Improvements -*Coming soon* +**Current Limitations** +- Episode release notifications are not yet functional (TMDB doesn't provide a webhooks API; polling is being considered) +- Tag filtering UI needs refinement for large tag collections +- No offline support (service worker implementation planned) --- -## ๐Ÿค Contributing +## Contributing -This is a personal project, but if you want to: +This is a personal learning project, but contributions are welcome. If you'd like to contribute: -1. Fork it -2. Create a branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/your-feature`) +3. Commit changes with descriptive messages +4. Push to your fork (`git push origin feature/your-feature`) +5. Open a Pull Request with a clear description --- -## ๐Ÿ“ License +## License -MIT - Do whatever you want, just don't blame me if it breaks. +MIT License - See [LICENSE](LICENSE) for details --- -## ๐Ÿ™ Acknowledgments +## Acknowledgments -- [TMDB](https://www.themoviedb.org/) - For the incredible API -- [Claude](https://www.anthropic.com/claude/opus) - guiding me through bugs that felt personally offended by my existence. -- My watchlist - For being eternally long and inspiring this project +- **TMDB** for providing a comprehensive and well-documented API +- **MongoDB Atlas** for generous free-tier hosting +- **Upstash** for serverless Redis +- **Claude** for serving as a tireless pair programmer and debugging companion ---

- Built with ๐Ÿ’œ and an unhealthy amount of TypeScript - My Github + Built with ๐Ÿ’œ and an unhealthy amount of TypeScript

diff --git a/cinetrack-app/Dockerfile b/cinetrack-app/Dockerfile index 856f200..275af3b 100644 --- a/cinetrack-app/Dockerfile +++ b/cinetrack-app/Dockerfile @@ -11,9 +11,6 @@ RUN npm ci --include=dev COPY client/ ./client/ COPY server/ ./server/ -ARG VITE_TMDB_API_READ_ACCESS_TOKEN -ENV VITE_TMDB_API_READ_ACCESS_TOKEN=$VITE_TMDB_API_READ_ACCESS_TOKEN - RUN npm run build FROM node:20-alpine AS production diff --git a/cinetrack-app/README.md b/cinetrack-app/README.md deleted file mode 100644 index d2e7761..0000000 --- a/cinetrack-app/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` diff --git a/cinetrack-app/client/src/components/layout/BottomNavBar.tsx b/cinetrack-app/client/src/components/layout/BottomNavBar.tsx index f652476..110cd74 100644 --- a/cinetrack-app/client/src/components/layout/BottomNavBar.tsx +++ b/cinetrack-app/client/src/components/layout/BottomNavBar.tsx @@ -15,34 +15,40 @@ const NavItem: React.FC<{ }> = ({ label, icon, isActive, onClick }) => ( + + {!isInWatchlist && ( + + )} + + {isMovie && isInWatchlist && ( + + )} + + {isMovie && isInWatchlist && ( + + )} + + + ); +}; + +const MobileBottomSheet = ({ onClose, item, isWatched, isInWatchlist }: any) => { + const { deleteItem, syncItem } = useWatchlistStore(); + const { handleSelectMedia } = useUIContext(); + + const handleViewDetails = () => { + const rect = new DOMRect(window.innerWidth / 2 - 50, window.innerHeight / 2 - 75, 100, 150); + handleSelectMedia({ + id: item.id, + media_type: item.media_type, + poster_path: item.poster_path, + title: item.media_type === 'movie' ? item.title : undefined, + name: item.media_type === 'tv' ? item.title : undefined, + } as any, rect); + onClose(); + }; + + const isMovie = item.media_type === 'movie'; + + return ( + <> + + +
+
+ {item.poster_path && ( + + )} +
+

{item.title}

+

{item.media_type === 'movie' ? 'Movie' : 'TV Series'}

+
+
+ +
+
+ + + {!isInWatchlist && ( + + )} + + {isMovie && isInWatchlist && ( + + )} +
+ + {isMovie && isInWatchlist && ( +
+ +
+ )} +
+
+ + +
+ + ); +}; + +export const ContextMenu = () => { + const { isOpen, position, mediaItem, type, closeMenu } = useUIStore(); + const { watchlist } = useWatchlistStore(); + + if (!mediaItem) return null; + + const isInWatchlist = watchlist.some(i => i.id === mediaItem.id); + const isWatched = watchlist.find(i => i.id === mediaItem.id) ? (watchlist.find(i => i.id === mediaItem.id) as any).watched : false; + + return ( + + {isOpen && ( + <> + {type === 'desktop' && ( +
{ e.preventDefault(); closeMenu(); }} /> + )} + + {type === 'desktop' ? ( + + ) : ( + + )} + + )} + + ); +}; diff --git a/cinetrack-app/client/src/hooks/useLongPress.ts b/cinetrack-app/client/src/hooks/useLongPress.ts new file mode 100644 index 0000000..7439514 --- /dev/null +++ b/cinetrack-app/client/src/hooks/useLongPress.ts @@ -0,0 +1,68 @@ +import { useRef, useCallback } from 'react'; + +interface UseLongPressOptions { + isPreventDefault?: boolean; + delay?: number; +} + +export const useLongPress = ( + onLongPress: (event: React.TouchEvent | React.MouseEvent) => void, + onClick: () => void, + { isPreventDefault = true, delay = 500 }: UseLongPressOptions = {} +) => { + const timeout = useRef>(undefined); + const target = useRef(null); + const startPoint = useRef<{ x: number; y: number } | null>(null); + + const start = useCallback( + (event: React.TouchEvent | React.MouseEvent) => { + if (isPreventDefault && event.target) { + target.current = event.target; + } + + if ('touches' in event) { + startPoint.current = { + x: event.touches[0].clientX, + y: event.touches[0].clientY + }; + } + + timeout.current = setTimeout(() => { + onLongPress(event); + }, delay); + }, + [onLongPress, delay, isPreventDefault] + ); + + const move = useCallback((event: React.TouchEvent) => { + if (!startPoint.current || !timeout.current) return; + + const x = event.touches[0].clientX; + const y = event.touches[0].clientY; + const diffX = Math.abs(x - startPoint.current.x); + const diffY = Math.abs(y - startPoint.current.y); + + if (diffX > 10 || diffY > 10) { + clear(event, false); + } + }, []); + + const clear = useCallback( + (_event: React.TouchEvent | React.MouseEvent, shouldTriggerClick = true) => { + timeout.current && clearTimeout(timeout.current); + timeout.current = undefined; + startPoint.current = null; + shouldTriggerClick && onClick(); + }, + [onClick] + ); + + return { + onMouseDown: (e: React.MouseEvent) => start(e), + onTouchStart: (e: React.TouchEvent) => start(e), + onTouchMove: (e: React.TouchEvent) => move(e), + onMouseUp: (e: React.MouseEvent) => clear(e), + onMouseLeave: (e: React.MouseEvent) => clear(e, false), + onTouchEnd: (e: React.TouchEvent) => clear(e) + }; +}; diff --git a/cinetrack-app/client/src/layouts/RootLayout.tsx b/cinetrack-app/client/src/layouts/RootLayout.tsx index 0e9512d..81ffe32 100644 --- a/cinetrack-app/client/src/layouts/RootLayout.tsx +++ b/cinetrack-app/client/src/layouts/RootLayout.tsx @@ -11,6 +11,7 @@ import { BottomNavBar } from "../components/layout/BottomNavBar"; import { SideNavBar } from "../components/layout/SideNavBar"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { getTVSeasonDetails } from "../services/tmdbService"; +import { ContextMenu } from "../components/overlay/ContextMenu"; const MediaDetailModal = React.lazy(() => import("../components/media/MediaDetailModal").then((module) => ({ @@ -51,7 +52,9 @@ const Header: React.FC = memo(() => { navigate(`/search?q=${encodeURIComponent(query)}`); } else { setIsSearchExpanded(false); - navigate(-1); + if (isOnSearchPage) { + navigate(-1); + } } }; @@ -311,6 +314,7 @@ const RootLayout: React.FC = () => { {!isOnSearchPage && } +
); }; diff --git a/cinetrack-app/client/src/store/useUIStore.ts b/cinetrack-app/client/src/store/useUIStore.ts new file mode 100644 index 0000000..38daf4e --- /dev/null +++ b/cinetrack-app/client/src/store/useUIStore.ts @@ -0,0 +1,51 @@ +import { create } from 'zustand'; + +export interface ContextMenuItem { + id: number; + title: string; + poster_path: string | null; + media_type: 'movie' | 'tv'; +} + +interface ContextMenuState { + isOpen: boolean; + position: { x: number; y: number }; + mediaItem: ContextMenuItem | null; + type: 'desktop' | 'mobile'; + + openMenu: ( + event: React.MouseEvent | React.TouchEvent | { clientX: number, clientY: number }, + item: ContextMenuItem, + type: 'desktop' | 'mobile' + ) => void; + closeMenu: () => void; +} + +export const useUIStore = create((set) => ({ + isOpen: false, + position: { x: 0, y: 0 }, + mediaItem: null, + type: 'desktop', + + openMenu: (event, item, type) => { + let x = 0; + let y = 0; + + if ('clientX' in event) { + x = event.clientX; + y = event.clientY; + } else if ('touches' in event && event.touches.length > 0) { + x = event.touches[0].clientX; + y = event.touches[0].clientY; + } + + set({ + isOpen: true, + position: { x, y }, + mediaItem: item, + type + }); + }, + + closeMenu: () => set({ isOpen: false, mediaItem: null }) +})); diff --git a/cinetrack-app/package-lock.json b/cinetrack-app/package-lock.json index 270475d..0b2f0ff 100644 --- a/cinetrack-app/package-lock.json +++ b/cinetrack-app/package-lock.json @@ -2295,23 +2295,23 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -2327,12 +2327,41 @@ "ms": "2.0.0" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3210,39 +3239,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -3862,9 +3891,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4954,12 +4983,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -4999,20 +5028,49 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/react": { "version": "19.2.1", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",