Initial commit

This commit is contained in:
Plexi09 2026-02-22 20:10:40 +01:00
parent 32184a54ce
commit a18a0e9b7a
Signed by: Plexi09
GPG key ID: 20D439A69163544A
31 changed files with 8180 additions and 0 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
node_modules
frontend/node_modules
backend/node_modules
frontend/dist
.git
.env
cinematch.db

32
.gitignore vendored Normal file
View file

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
backend/cinematch.db

25
Dockerfile Normal file
View file

@ -0,0 +1,25 @@
FROM node:20-slim AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM node:20-slim AS backend
WORKDIR /app
COPY package*.json ./
RUN npm install --production
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm install --production
COPY backend/ ./
# Copy frontend build
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
EXPOSE 3001
CMD ["node", "server.js"]

46
backend/database.js Normal file
View file

@ -0,0 +1,46 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const dbPath = path.resolve(__dirname, 'cinematch.db');
const db = new sqlite3.Database(dbPath);
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
password TEXT,
code TEXT UNIQUE NOT NULL,
partner_code TEXT,
genres TEXT
)
`);
// Add genres column if it doesn't exist (for existing db)
db.run(`ALTER TABLE users ADD COLUMN genres TEXT`, (err) => {
// Ignore error if column already exists
});
db.run(`
CREATE TABLE IF NOT EXISTS swipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
movie_id INTEGER,
direction TEXT,
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(user_id, movie_id)
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS watched (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
movie_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id),
UNIQUE(user_id, movie_id)
)
`)
});
module.exports = db;

2193
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
backend/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"sqlite3": "^5.1.7"
}
}

381
backend/server.js Normal file
View file

@ -0,0 +1,381 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const path = require('path');
const db = require('./database');
const app = express();
app.use(cors());
app.use(express.json());
const JWT_SECRET = 'cinematch-super-secret-key';
const TMDB_API_KEY = process.env.TMDB_API_KEY || '3fd2be6f0c70a2a598f084ddfb75487c'; // Fallback to a public tutorial key if not set
// Helper to fetch from TMDB
const fetchTMDB = async (endpoint, params = {}) => {
const url = new URL(`https://api.themoviedb.org/3${endpoint}`);
url.searchParams.append('api_key', TMDB_API_KEY);
for (const [key, value] of Object.entries(params)) {
url.searchParams.append(key, value);
}
const res = await fetch(url);
if (!res.ok) throw new Error('TMDB API error');
return res.json();
};
// Map TMDB movie to our Movie format
const mapMovie = (m) => ({
id: m.id,
title: m.title,
posterUrl: m.poster_path ? `https://image.tmdb.org/t/p/w500${m.poster_path}` : 'https://via.placeholder.com/500x750?text=No+Poster',
rating: m.vote_average,
synopsis: m.overview,
year: m.release_date ? parseInt(m.release_date.substring(0, 4)) : null,
genre: m.genre_ids.map(String)
});
// Helper to generate random code
const generateCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();
// Middleware to authenticate
const authenticate = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.userId = decoded.userId;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
};
// Register
app.post('/api/auth/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
const hashedPassword = await bcrypt.hash(password, 10);
const code = generateCode();
db.run('INSERT INTO users (username, password, code) VALUES (?, ?, ?)', [username, hashedPassword, code], function (err) {
if (err) return res.status(400).json({ error: 'Username already exists' });
const token = jwt.sign({ userId: this.lastID }, JWT_SECRET);
res.json({ token, user: { id: this.lastID, username, code, partner_code: null, genres: null } });
});
});
// Login
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body;
db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => {
if (err || !user) return res.status(400).json({ error: 'Invalid credentials' });
const match = await bcrypt.compare(password, user.password);
if (!match) return res.status(400).json({ error: 'Invalid credentials' });
const token = jwt.sign({ userId: user.id }, JWT_SECRET);
res.json({ token, user: { id: user.id, username: user.username, code: user.code, partner_code: user.partner_code, genres: user.genres ? JSON.parse(user.genres) : null } });
});
});
// Anonymous Login
app.post('/api/auth/anonymous', (req, res) => {
const code = generateCode();
db.run('INSERT INTO users (code) VALUES (?)', [code], function (err) {
if (err) return res.status(500).json({ error: 'Failed to create anonymous user' });
const token = jwt.sign({ userId: this.lastID }, JWT_SECRET);
res.json({ token, user: { id: this.lastID, username: null, code, partner_code: null, genres: null } });
});
});
// Get Me
app.get('/api/auth/me', authenticate, (req, res) => {
db.get('SELECT id, username, code, partner_code, genres FROM users WHERE id = ?', [req.userId], (err, user) => {
if (err || !user) return res.status(404).json({ error: 'User not found' });
res.json({ user: { ...user, genres: user.genres ? JSON.parse(user.genres) : null } });
});
});
// Update Genres
app.post('/api/user/genres', authenticate, (req, res) => {
const { genres } = req.body;
db.run('UPDATE users SET genres = ? WHERE id = ?', [JSON.stringify(genres), req.userId], (err) => {
if (err) return res.status(500).json({ error: 'Failed to update genres' });
res.json({ success: true });
});
});
// Link Partner
app.post('/api/link', authenticate, (req, res) => {
const { partnerCode } = req.body;
db.get('SELECT id, code FROM users WHERE code = ?', [partnerCode], (err, partner) => {
if (err || !partner) return res.status(404).json({ error: 'Partner code not found' });
if (partner.id === req.userId) return res.status(400).json({ error: 'Cannot link with yourself' });
db.get('SELECT code FROM users WHERE id = ?', [req.userId], (err, user) => {
if (err || !user) return res.status(500).json({ error: 'User not found' });
db.run('UPDATE users SET partner_code = ? WHERE id = ?', [partnerCode, req.userId], (err) => {
if (err) return res.status(500).json({ error: 'Failed to link partner' });
db.run('UPDATE users SET partner_code = ? WHERE id = ?', [user.code, partner.id], (err) => {
if (err) return res.status(500).json({ error: 'Failed to link partner on the other side' });
res.json({ success: true, partner_code: partnerCode });
});
});
});
});
});
// Unlink Partner
app.post('/api/unlink', authenticate, (req, res) => {
db.get('SELECT partner_code FROM users WHERE id = ?', [req.userId], (err, user) => {
if (err || !user) return res.status(500).json({ error: 'User not found' });
db.run('UPDATE users SET partner_code = NULL WHERE id = ?', [req.userId], (err) => {
if (err) return res.status(500).json({ error: 'Failed to unlink partner' });
if (user.partner_code) {
db.run('UPDATE users SET partner_code = NULL WHERE code = ?', [user.partner_code], (err) => {
res.json({ success: true });
});
} else {
res.json({ success: true });
}
});
});
});
// Swipe
app.post('/api/swipe', authenticate, (req, res) => {
const { movieId, direction } = req.body;
db.run('INSERT OR REPLACE INTO swipes (user_id, movie_id, direction) VALUES (?, ?, ?)', [req.userId, movieId, direction], function (err) {
if (err) return res.status(500).json({ error: 'Failed to record swipe' });
res.json({ success: true });
});
});
// Get Swipes
app.get('/api/swipes', authenticate, (req, res) => {
db.all('SELECT movie_id, direction FROM swipes WHERE user_id = ?', [req.userId], (err, rows) => {
if (err) return res.status(500).json({ error: 'Failed to get swipes' });
res.json({ swipes: rows });
});
});
// Get Matches
app.get('/api/matches', authenticate, (req, res) => {
db.get('SELECT partner_code FROM users WHERE id = ?', [req.userId], (err, user) => {
if (err || !user || !user.partner_code) return res.json({ matches: [] });
db.get('SELECT id FROM users WHERE code = ?', [user.partner_code], (err, partner) => {
if (err || !partner) return res.json({ matches: [] });
const query = `
SELECT s1.movie_id
FROM swipes s1
JOIN swipes s2 ON s1.movie_id = s2.movie_id
WHERE s1.user_id = ? AND s2.user_id = ? AND s1.direction = 'right' AND s2.direction = 'right'
`;
db.all(query, [req.userId, partner.id], async (err, rows) => {
if (err) return res.status(500).json({ error: 'Failed to get matches' });
const matchIds = rows.map(r => r.movie_id);
const matchDetails = [];
for (const id of matchIds) {
try {
const movieData = await fetchTMDB(`/movie/${id}`);
if (movieData && !movieData.error) {
matchDetails.push({
id: movieData.id,
title: movieData.title,
poster_path: movieData.poster_path,
overview: movieData.overview,
release_date: movieData.release_date,
vote_average: movieData.vote_average
});
}
} catch (e) {
console.error('Error fetching match details:', e);
}
}
res.json({ matches: matchDetails });
});
});
});
});
// Mark as watched
app.post('/api/watched', authenticate, (req, res) => {
const { movieId } = req.body;
db.run('INSERT OR IGNORE INTO watched (user_id, movie_id) VALUES (?, ?)', [req.userId, movieId], function (err) {
if (err) return res.status(500).json({ error: 'Failed to mark as watched' });
res.json({ success: true });
});
});
// Get watched
app.get('/api/watched', authenticate, (req, res) => {
db.all('SELECT movie_id FROM watched WHERE user_id = ?', [req.userId], (err, rows) => {
if (err) return res.status(500).json({ error: 'Failed to get watched movies' });
res.json({ watched: rows.map(r => r.movie_id) });
});
});
// Get Movie Feed
app.get('/api/movies/feed', authenticate, async (req, res) => {
console.log('Fetching feed for user:', req.userId);
try {
// 1. Get user info (genres, partner_code)
const user = await new Promise((resolve, reject) => {
db.get('SELECT * FROM users WHERE id = ?', [req.userId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
if (!user) return res.status(404).json({ error: 'User not found' });
const userGenres = user.genres ? JSON.parse(user.genres) : [];
// 2. Get user's swipes to filter out already seen movies
const userSwipes = await new Promise((resolve, reject) => {
db.all('SELECT movie_id FROM swipes WHERE user_id = ?', [req.userId], (err, rows) => {
if (err) reject(err);
else resolve(rows.map(r => r.movie_id));
});
});
let feedMovies = [];
// 3. If user has a partner, get partner's liked movies that user hasn't swiped on
if (user.partner_code) {
const partner = await new Promise((resolve, reject) => {
db.get('SELECT id FROM users WHERE code = ?', [user.partner_code], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
if (partner) {
const partnerLikes = await new Promise((resolve, reject) => {
db.all('SELECT movie_id FROM swipes WHERE user_id = ? AND direction = "right"', [partner.id], (err, rows) => {
if (err) reject(err);
else resolve(rows.map(r => r.movie_id));
});
});
const unseenPartnerLikes = partnerLikes.filter(id => !userSwipes.includes(id));
// Fetch details for these movies from TMDB
for (const movieId of unseenPartnerLikes.slice(0, 5)) { // Limit to 5 partner likes per feed request
try {
const movieData = await fetchTMDB(`/movie/${movieId}`, { append_to_response: 'credits' });
if (movieData && !movieData.error) {
const director = movieData.credits?.crew?.find(c => c.job === 'Director')?.name || 'Unknown';
const genres = movieData.genres?.map(g => g.name) || [];
feedMovies.push({
id: movieData.id,
title: movieData.title,
poster_path: movieData.poster_path,
overview: movieData.overview,
release_date: movieData.release_date,
vote_average: movieData.vote_average,
director: director,
genres: genres,
imdb_id: movieData.imdb_id,
isPartnerLike: true // Flag to show it's a partner like
});
}
} catch (e) {
console.error('Error fetching partner movie:', e);
}
}
}
}
// 4. Fetch recommendations from TMDB based on genres
let tmdbEndpoint = '/movie/popular';
let queryParams = new URLSearchParams();
if (userGenres && userGenres.length > 0) {
tmdbEndpoint = '/discover/movie';
// Use pipe '|' for OR condition (movies with ANY of the selected genres)
queryParams.append('with_genres', userGenres.join('|'));
queryParams.append('sort_by', 'popularity.desc');
}
// Try to fetch movies until we have enough for the feed (at least 10)
let attempts = 0;
while (feedMovies.length < 10 && attempts < 5) {
// Limit to top 50 pages to ensure we get relatively popular/good movies
const randomPage = Math.floor(Math.random() * 50) + 1;
queryParams.set('page', randomPage.toString());
const tmdbUrl = `${tmdbEndpoint}?${queryParams.toString()}`;
const tmdbResponse = await fetchTMDB(tmdbUrl);
if (tmdbResponse && tmdbResponse.results) {
const recommendedMovies = tmdbResponse.results
.filter(m => !userSwipes.includes(m.id) && !feedMovies.some(fm => fm.id === m.id));
for (const m of recommendedMovies) {
if (feedMovies.length >= 10) break; // Limit feed to 10 movies per request
try {
const details = await fetchTMDB(`/movie/${m.id}`, { append_to_response: 'credits' });
if (details && !details.error) {
const director = details.credits?.crew?.find(c => c.job === 'Director')?.name || 'Unknown';
const genres = details.genres?.map(g => g.name) || [];
feedMovies.push({
id: details.id,
title: details.title,
poster_path: details.poster_path,
overview: details.overview,
release_date: details.release_date,
vote_average: details.vote_average,
director: director,
genres: genres,
imdb_id: details.imdb_id,
isPartnerLike: false
});
}
} catch (e) {
console.error('Error fetching recommended movie details:', e);
}
}
}
attempts++;
}
res.setHeader('Cache-Control', 'no-store');
res.json({ movies: feedMovies });
} catch (error) {
console.error('Feed error:', error);
res.status(500).json({ error: 'Failed to generate feed' });
}
});
// Serve frontend static files in production
app.use(express.static(path.join(__dirname, '../frontend/dist'), {
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}
}));
app.use((req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View file

@ -0,0 +1,73 @@
# 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...
},
},
])
```

23
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CineMatch</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3725
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
frontend/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
},
"overrides": {
"minimatch": "^10.2.1"
}
}

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

25
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,25 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AppProvider } from './store/AppContext';
import { Home } from './pages/Home';
import { Swipe } from './pages/Swipe';
import { Matches } from './pages/Matches';
import { Navbar } from './components/Navbar';
function App() {
return (
<AppProvider>
<Router>
<div className="min-h-screen bg-background text-white font-sans">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/swipe" element={<Swipe />} />
<Route path="/matches" element={<Matches />} />
</Routes>
<Navbar />
</div>
</Router>
</AppProvider>
);
}
export default App;

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,44 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Film, Heart, Users } from 'lucide-react';
import { useAppContext } from '../store/AppContext';
export const Navbar: React.FC = () => {
const location = useLocation();
const { matches } = useAppContext();
const navItems = [
{ path: '/', icon: Users, label: 'Partner' },
{ path: '/swipe', icon: Film, label: 'Discover' },
{ path: '/matches', icon: Heart, label: 'Matches', badge: matches.length },
];
return (
<nav className="fixed bottom-0 w-full bg-surface border-t border-slate-700 pb-safe">
<div className="flex justify-around items-center h-16 max-w-md mx-auto">
{navItems.map(({ path, icon: Icon, label, badge }) => {
const isActive = location.pathname === path;
return (
<Link
key={path}
to={path}
className={`flex flex-col items-center justify-center w-full h-full space-y-1 ${
isActive ? 'text-primary' : 'text-slate-400 hover:text-slate-200'
}`}
>
<div className="relative">
<Icon size={24} />
{badge !== undefined && badge > 0 && (
<span className="absolute -top-2 -right-2 bg-primary text-white text-xs rounded-full h-5 w-5 flex items-center justify-center">
{badge}
</span>
)}
</div>
<span className="text-xs font-medium">{label}</span>
</Link>
);
})}
</div>
</nav>
);
};

View file

@ -0,0 +1,75 @@
export interface Movie {
id: number;
title: string;
posterUrl: string;
rating: number;
synopsis: string;
year: number;
genre: string[];
}
export const MOCK_MOVIES: Movie[] = [
{
id: 1,
title: "Dune: Part Two",
posterUrl: "https://image.tmdb.org/t/p/w500/1pdfLvkbY9ohJlCjQH2JGjjc9CW.jpg",
rating: 8.8,
synopsis: "Paul Atreides unites with Chani and the Fremen while on a warpath of revenge against the conspirators who destroyed his family.",
year: 2024,
genre: ["Sci-Fi", "Adventure"]
},
{
id: 2,
title: "Oppenheimer",
posterUrl: "https://image.tmdb.org/t/p/w500/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
rating: 8.6,
synopsis: "The story of American scientist J. Robert Oppenheimer and his role in the development of the atomic bomb.",
year: 2023,
genre: ["Drama", "History"]
},
{
id: 3,
title: "Spider-Man: Across the Spider-Verse",
posterUrl: "https://image.tmdb.org/t/p/w500/8Vt6mWEReuy4Of61Lnj5Xj704m8.jpg",
rating: 8.7,
synopsis: "Miles Morales catapults across the Multiverse, where he encounters a team of Spider-People charged with protecting its very existence.",
year: 2023,
genre: ["Animation", "Action"]
},
{
id: 4,
title: "Everything Everywhere All at Once",
posterUrl: "https://image.tmdb.org/t/p/w500/w3LxiVYdWWRvEVllEjOIQ1kO0fX.jpg",
rating: 8.0,
synopsis: "An aging Chinese immigrant is swept up in an insane adventure, where she alone can save the world by exploring other universes.",
year: 2022,
genre: ["Action", "Adventure", "Comedy"]
},
{
id: 5,
title: "The Batman",
posterUrl: "https://image.tmdb.org/t/p/w500/74xTEgt7R36Fpooo50r9T25onhq.jpg",
rating: 7.9,
synopsis: "When a sadistic serial killer begins murdering key political figures in Gotham, Batman is forced to investigate the city's hidden corruption.",
year: 2022,
genre: ["Action", "Crime", "Drama"]
},
{
id: 6,
title: "Interstellar",
posterUrl: "https://image.tmdb.org/t/p/w500/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
rating: 8.6,
synopsis: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
year: 2014,
genre: ["Sci-Fi", "Drama"]
},
{
id: 7,
title: "Parasite",
posterUrl: "https://image.tmdb.org/t/p/w500/7IiTTgloJzvGI1TAYymCfbfl3vT.jpg",
rating: 8.5,
synopsis: "Greed and class discrimination threaten the newly formed symbiotic relationship between the wealthy Park family and the destitute Kim clan.",
year: 2019,
genre: ["Thriller", "Drama"]
}
];

22
frontend/src/index.css Normal file
View file

@ -0,0 +1,22 @@
@import "tailwindcss";
@theme {
--color-primary: #e11d48;
--color-primary-hover: #be123c;
--color-background: #0f172a;
--color-surface: #1e293b;
}
body {
background-color: var(--color-background);
color: white;
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
overflow-x: hidden;
}
/* Hide scrollbar for clean UI */
::-webkit-scrollbar {
width: 0px;
background: transparent;
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

303
frontend/src/pages/Home.tsx Normal file
View file

@ -0,0 +1,303 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppContext } from '../store/AppContext';
import { Users, Link as LinkIcon, Copy, Check, LogOut, User as UserIcon, Film } from 'lucide-react';
const TMDB_GENRES = [
{ id: 28, name: 'Action' },
{ id: 12, name: 'Adventure' },
{ id: 16, name: 'Animation' },
{ id: 35, name: 'Comedy' },
{ id: 80, name: 'Crime' },
{ id: 99, name: 'Documentary' },
{ id: 18, name: 'Drama' },
{ id: 10751, name: 'Family' },
{ id: 14, name: 'Fantasy' },
{ id: 36, name: 'History' },
{ id: 27, name: 'Horror' },
{ id: 10402, name: 'Music' },
{ id: 9648, name: 'Mystery' },
{ id: 10749, name: 'Romance' },
{ id: 878, name: 'Science Fiction' },
{ id: 53, name: 'Thriller' },
{ id: 10752, name: 'War' },
{ id: 37, name: 'Western' }
];
export const Home: React.FC = () => {
const { user, login, logout, linkPartner, unlinkPartner, updateGenres, isLoading } = useAppContext();
const [inputCode, setInputCode] = useState('');
const [copied, setCopied] = useState(false);
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [selectedGenres, setSelectedGenres] = useState<number[]>([]);
const navigate = useNavigate();
if (isLoading) {
return <div className="min-h-screen bg-background text-white flex items-center justify-center">Loading...</div>;
}
const handleConnect = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (inputCode.trim()) {
try {
await linkPartner(inputCode.trim());
navigate('/swipe');
} catch (err: any) {
setError(err.message);
}
}
};
const copyCode = () => {
if (!user) return;
navigator.clipboard.writeText(user.code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const endpoint = isLogin ? '/api/auth/login' : '/api/auth/register';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
login(data.token, data.user);
} catch (err: any) {
setError(err.message);
}
};
const handleAnonymous = async () => {
setError('');
try {
const res = await fetch('/api/auth/anonymous', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error);
login(data.token, data.user);
} catch (err: any) {
setError(err.message);
}
};
const handleSaveGenres = async () => {
if (selectedGenres.length === 0) {
setError('Please select at least one genre');
return;
}
await updateGenres(selectedGenres);
};
const toggleGenre = (id: number) => {
setSelectedGenres(prev =>
prev.includes(id) ? prev.filter(g => g !== id) : [...prev, id]
);
};
if (!user) {
return (
<div className="min-h-screen bg-background text-white p-6 flex flex-col items-center justify-center max-w-md mx-auto pb-24">
<div className="w-full space-y-8">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center p-4 bg-primary/10 rounded-full mb-4">
<Users className="w-12 h-12 text-primary" />
</div>
<h1 className="text-3xl font-bold tracking-tight">CineMatch</h1>
<p className="text-slate-400">Find movies to watch together.</p>
</div>
<form onSubmit={handleAuth} className="bg-surface p-6 rounded-2xl border border-slate-700 space-y-4">
<h2 className="text-lg font-semibold">{isLogin ? 'Login' : 'Create Account'}</h2>
{error && <p className="text-red-500 text-sm">{error}</p>}
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
className="w-full bg-background border border-slate-700 rounded-xl p-3 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full bg-background border border-slate-700 rounded-xl p-3 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
required
/>
<button
type="submit"
className="w-full py-3 px-4 bg-primary hover:bg-primary-hover text-white rounded-xl font-medium transition-colors"
>
{isLogin ? 'Login' : 'Register'}
</button>
<button
type="button"
onClick={() => setIsLogin(!isLogin)}
className="w-full text-sm text-slate-400 hover:text-white transition-colors"
>
{isLogin ? "Don't have an account? Register" : "Already have an account? Login"}
</button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-background text-slate-400">OR</span>
</div>
</div>
<button
onClick={handleAnonymous}
className="w-full py-3 px-4 bg-surface border border-slate-700 hover:bg-slate-800 text-white rounded-xl font-medium transition-colors flex items-center justify-center gap-2"
>
<UserIcon className="w-5 h-5" />
Continue as Guest
</button>
</div>
</div>
);
}
if (!user.genres) {
return (
<div className="min-h-screen bg-background text-white p-6 flex flex-col items-center justify-center max-w-md mx-auto pb-24">
<div className="w-full space-y-6">
<div className="text-center space-y-2">
<div className="inline-flex items-center justify-center p-4 bg-primary/10 rounded-full mb-4">
<Film className="w-12 h-12 text-primary" />
</div>
<h1 className="text-2xl font-bold tracking-tight">What do you like?</h1>
<p className="text-slate-400">Select a few genres to get better movie recommendations.</p>
</div>
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
<div className="flex flex-wrap gap-3 justify-center">
{TMDB_GENRES.map(genre => (
<button
key={genre.id}
onClick={() => toggleGenre(genre.id)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
selectedGenres.includes(genre.id)
? 'bg-primary border-primary text-white'
: 'bg-surface border-slate-700 text-slate-300 hover:border-slate-500'
}`}
>
{genre.name}
</button>
))}
</div>
<button
onClick={handleSaveGenres}
className="w-full py-3 px-4 bg-primary hover:bg-primary-hover text-white rounded-xl font-medium transition-colors mt-8"
>
Continue
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background text-white p-6 flex flex-col items-center justify-center max-w-md mx-auto pb-24">
<div className="w-full space-y-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<UserIcon className="w-6 h-6 text-primary" />
<span className="font-medium">{user.username || 'Guest User'}</span>
</div>
<button onClick={logout} className="p-2 text-slate-400 hover:text-white transition-colors">
<LogOut className="w-5 h-5" />
</button>
</div>
{user.partner_code ? (
<div className="bg-surface p-6 rounded-2xl border border-slate-700 text-center space-y-4">
<div className="inline-flex items-center justify-center p-3 bg-green-500/10 rounded-full">
<Check className="w-8 h-8 text-green-500" />
</div>
<div>
<h2 className="text-xl font-semibold">Connected!</h2>
<p className="text-slate-400 text-sm mt-1">You are linked with partner: {user.partner_code}</p>
</div>
<button
onClick={() => navigate('/swipe')}
className="w-full py-3 px-4 bg-primary hover:bg-primary-hover text-white rounded-xl font-medium transition-colors"
>
Start Swiping
</button>
<button
onClick={unlinkPartner}
className="w-full py-3 px-4 bg-transparent border border-slate-700 hover:bg-slate-800 text-white rounded-xl font-medium transition-colors"
>
Disconnect
</button>
</div>
) : (
<div className="space-y-6">
<div className="bg-surface p-6 rounded-2xl border border-slate-700 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<LinkIcon className="w-5 h-5 text-primary" />
Your Code
</h2>
<p className="text-sm text-slate-400">Share this code with your partner so they can connect with you.</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-background p-3 rounded-xl text-center text-lg font-mono tracking-wider border border-slate-700">
{user.code}
</code>
<button
onClick={copyCode}
className="p-3 bg-primary/10 hover:bg-primary/20 text-primary rounded-xl transition-colors"
title="Copy code"
>
{copied ? <Check className="w-6 h-6" /> : <Copy className="w-6 h-6" />}
</button>
</div>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-background text-slate-400">OR</span>
</div>
</div>
<form onSubmit={handleConnect} className="bg-surface p-6 rounded-2xl border border-slate-700 space-y-4">
<h2 className="text-lg font-semibold">Enter Partner Code</h2>
{error && <p className="text-red-500 text-sm">{error}</p>}
<input
type="text"
value={inputCode}
onChange={(e) => setInputCode(e.target.value)}
placeholder="e.g. CINE-5678"
className="w-full bg-background border border-slate-700 rounded-xl p-3 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
required
/>
<button
type="submit"
className="w-full py-3 px-4 bg-primary hover:bg-primary-hover text-white rounded-xl font-medium transition-colors"
>
Connect
</button>
</form>
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,100 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppContext } from '../store/AppContext';
import { Heart, CheckCircle2, Circle } from 'lucide-react';
export const Matches: React.FC = () => {
const { user, matches, watchedMovies, markAsWatched, isLoading } = useAppContext();
const navigate = useNavigate();
useEffect(() => {
if (!isLoading && !user) {
navigate('/');
}
}, [user, isLoading, navigate]);
if (isLoading || !user) {
return <div className="min-h-screen bg-background text-white flex items-center justify-center">Loading...</div>;
}
if (matches.length === 0) {
return (
<div className="min-h-screen bg-background text-white flex flex-col items-center justify-center p-6 pb-24">
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center p-4 bg-surface rounded-full mb-4">
<Heart className="w-12 h-12 text-slate-400" />
</div>
<h2 className="text-2xl font-bold">No matches yet</h2>
<p className="text-slate-400">Keep swiping to find movies you both want to watch!</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background text-white p-6 pb-24 max-w-md mx-auto">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Heart className="w-6 h-6 text-primary fill-current" />
Your Matches
</h1>
<span className="bg-primary/20 text-primary px-3 py-1 rounded-full text-sm font-medium">
{matches.length} Movies
</span>
</div>
<div className="grid gap-4">
{matches.map((movie) => {
const isWatched = watchedMovies.includes(movie.id);
const posterUrl = movie.poster_path
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
: 'https://via.placeholder.com/500x750?text=No+Poster';
const year = movie.release_date ? movie.release_date.split('-')[0] : 'N/A';
const rating = movie.vote_average ? movie.vote_average.toFixed(1) : 'N/A';
return (
<div
key={movie.id}
className={`bg-surface border border-slate-700 rounded-2xl overflow-hidden flex transition-opacity ${
isWatched ? 'opacity-50' : ''
}`}
>
<img
src={posterUrl}
alt={movie.title}
className="w-24 h-36 object-cover"
/>
<div className="p-4 flex flex-col justify-between flex-1">
<div>
<h3 className="font-bold text-lg leading-tight line-clamp-2">{movie.title}</h3>
<p className="text-slate-400 text-sm mt-1">{year} {rating}/10</p>
</div>
<button
onClick={() => !isWatched && markAsWatched(movie.id)}
disabled={isWatched}
className={`flex items-center gap-2 text-sm font-medium w-fit ${
isWatched ? 'text-green-500' : 'text-slate-400 hover:text-white'
} transition-colors`}
>
{isWatched ? (
<>
<CheckCircle2 className="w-5 h-5" />
Watched
</>
) : (
<>
<Circle className="w-5 h-5" />
Mark as watched
</>
)}
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,295 @@
import React, { useState, useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, useMotionValue, useTransform, AnimatePresence, animate } from 'framer-motion';
import type { PanInfo } from 'framer-motion';
import { Heart, X, Info } from 'lucide-react';
import { useAppContext } from '../store/AppContext';
import type { Movie } from '../store/AppContext';
export interface SwipeCardRef {
swipe: (direction: 'left' | 'right') => Promise<void>;
}
interface SwipeCardProps {
movie: Movie;
onSwipe: (direction: 'left' | 'right') => void;
}
const SwipeCard = forwardRef<SwipeCardRef, SwipeCardProps>(({ movie, onSwipe }, ref) => {
const [showInfo, setShowInfo] = useState(false);
const x = useMotionValue(0);
const rotate = useTransform(x, [-200, 200], [-15, 15]);
const likeOpacity = useTransform(x, [0, 100], [0, 1]);
const passOpacity = useTransform(x, [0, -100], [0, 1]);
const handleSwipeAction = async (direction: 'left' | 'right') => {
const targetX = direction === 'left' ? -window.innerWidth : window.innerWidth;
await animate(x, targetX, { duration: 0.3, ease: "easeOut" });
onSwipe(direction);
};
useImperativeHandle(ref, () => ({
swipe: handleSwipeAction
}));
const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
const threshold = 100;
if (info.offset.x > threshold) {
handleSwipeAction('right');
} else if (info.offset.x < -threshold) {
handleSwipeAction('left');
} else {
animate(x, 0, { type: 'spring', stiffness: 300, damping: 20 });
}
};
const posterUrl = movie.poster_path
? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
: 'https://via.placeholder.com/500x750?text=No+Poster';
const year = movie.release_date ? movie.release_date.split('-')[0] : 'N/A';
const rating = movie.vote_average ? movie.vote_average.toFixed(1) : 'N/A';
return (
<motion.div
className="absolute w-full h-full bg-surface rounded-3xl shadow-2xl overflow-hidden border border-slate-700"
style={{ x, rotate }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
onDragEnd={handleDragEnd}
initial={{ scale: 0.6, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
whileDrag={{ scale: 1.05 }}
>
<img
src={posterUrl}
alt={movie.title}
className="w-full h-full object-cover pointer-events-none"
/>
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-linear-to-t from-black/90 via-black/40 to-transparent pointer-events-none" />
{/* Like/Pass Indicators */}
<motion.div
className="absolute top-8 right-8 border-4 border-green-500 text-green-500 rounded-xl px-4 py-2 font-bold text-3xl rotate-12 pointer-events-none"
style={{ opacity: likeOpacity }}
>
SMASH
</motion.div>
<motion.div
className="absolute top-8 left-8 border-4 border-red-500 text-red-500 rounded-xl px-4 py-2 font-bold text-3xl -rotate-12 pointer-events-none"
style={{ opacity: passOpacity }}
>
PASS
</motion.div>
{/* Movie Info */}
<div className="absolute bottom-0 left-0 right-0 p-6 space-y-2">
<div className="flex justify-between items-end">
<div>
<h2 className="text-3xl font-bold leading-tight">{movie.title}</h2>
<p className="text-slate-300 font-medium">{year} {rating}/10</p>
{movie.director && (
<p className="text-slate-400 text-sm mt-1">{movie.director}</p>
)}
</div>
<button
onClick={() => setShowInfo(!showInfo)}
className="p-2 bg-white/20 backdrop-blur-md rounded-full hover:bg-white/30 transition-colors"
>
<Info className="w-6 h-6" />
</button>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{movie.isPartnerLike && (
<span className="px-3 py-1 bg-primary/80 backdrop-blur-md rounded-full text-xs font-bold flex items-center gap-1">
<Heart className="w-3 h-3 fill-current" /> Partner Liked
</span>
)}
{movie.genres?.slice(0, 3).map((genre, idx) => (
<span key={idx} className="px-3 py-1 bg-slate-800/80 backdrop-blur-md rounded-full text-xs font-medium text-slate-300">
{genre}
</span>
))}
</div>
<AnimatePresence>
{showInfo && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<p className="text-sm text-slate-300 mt-4 leading-relaxed">
{movie.overview}
</p>
{movie.imdb_id && (
<a
href={`https://www.imdb.com/title/${movie.imdb_id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-4 px-4 py-2 bg-yellow-500 text-black font-bold rounded-lg text-sm hover:bg-yellow-400 transition-colors"
>
Open on IMDB
</a>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
});
export const Swipe: React.FC = () => {
const { user, handleSwipe, feed, isLoading, fetchFeed, newMatch, clearNewMatch } = useAppContext();
const cardRef = useRef<SwipeCardRef>(null);
const navigate = useNavigate();
useEffect(() => {
if (!isLoading && !user) {
navigate('/');
}
}, [user, isLoading, navigate]);
useEffect(() => {
if (user && feed.length === 0) {
fetchFeed();
}
}, [user, feed.length, fetchFeed]);
const activeMovie = feed[0];
const onSwipe = (direction: 'left' | 'right') => {
if (activeMovie) {
handleSwipe(activeMovie, direction);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex flex-col items-center justify-center p-4 overflow-hidden">
<div className="w-full max-w-md aspect-2/3 relative">
<AnimatePresence mode="popLayout">
{activeMovie ? (
<SwipeCard
key={activeMovie.id}
movie={activeMovie}
onSwipe={onSwipe}
ref={cardRef}
/>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 flex flex-col items-center justify-center text-center p-8 bg-surface rounded-3xl border border-slate-700"
>
<div className="w-16 h-16 bg-slate-800 rounded-full flex items-center justify-center mb-4">
<Heart className="w-8 h-8 text-slate-500" />
</div>
<h3 className="text-xl font-bold mb-2">No more movies!</h3>
<p className="text-slate-400">
You've seen all the recommendations for now. Check back later for more!
</p>
<button
onClick={() => fetchFeed()}
className="mt-6 px-6 py-2 bg-primary text-white rounded-full font-medium hover:bg-primary/90 transition-colors"
>
Refresh Feed
</button>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-6 mt-8">
<button
onClick={() => cardRef.current?.swipe('left')}
disabled={!activeMovie}
className="p-4 bg-surface border border-slate-700 rounded-full text-red-500 hover:bg-red-500/10 hover:border-red-500/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<X className="w-8 h-8" />
</button>
<button
onClick={() => cardRef.current?.swipe('right')}
disabled={!activeMovie}
className="p-4 bg-surface border border-slate-700 rounded-full text-green-500 hover:bg-green-500/10 hover:border-green-500/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Heart className="w-8 h-8" />
</button>
</div>
{/* Match Notification Overlay */}
<AnimatePresence>
{newMatch && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
>
<motion.div
initial={{ scale: 0.8, y: 50 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.8, y: 50 }}
transition={{ type: 'spring', damping: 20, stiffness: 300 }}
className="bg-surface border border-primary/50 rounded-3xl p-8 max-w-sm w-full text-center shadow-2xl shadow-primary/20"
>
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-6">
<Heart className="w-10 h-10 text-primary animate-pulse" fill="currentColor" />
</div>
<h2 className="text-4xl font-black text-transparent bg-clip-text bg-linear-to-r from-primary to-pink-500 mb-2">
It's a Match!
</h2>
<p className="text-slate-300 mb-6 text-lg">
You and your partner both liked <br/>
<span className="font-bold text-white">{newMatch.title}</span>
</p>
{newMatch.poster_path && (
<div className="w-32 h-48 mx-auto mb-8 rounded-xl overflow-hidden shadow-lg">
<img
src={`https://image.tmdb.org/t/p/w500${newMatch.poster_path}`}
alt={newMatch.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex flex-col gap-3">
<button
onClick={() => {
clearNewMatch();
navigate('/matches');
}}
className="w-full py-3 bg-primary text-white rounded-xl font-bold hover:bg-primary/90 transition-colors"
>
View Matches
</button>
<button
onClick={clearNewMatch}
className="w-full py-3 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-colors"
>
Keep Swiping
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View file

@ -0,0 +1,317 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
export interface User {
id: number;
username: string | null;
code: string;
partner_code: string | null;
genres: number[] | null;
}
export interface Movie {
id: number;
title: string;
poster_path: string;
overview: string;
release_date: string;
vote_average: number;
director?: string;
genres?: string[];
imdb_id?: string;
isPartnerLike?: boolean;
}
interface AppState {
user: User | null;
token: string | null;
login: (token: string, user: User) => void;
logout: () => void;
linkPartner: (code: string) => Promise<void>;
unlinkPartner: () => Promise<void>;
updateGenres: (genres: number[]) => Promise<void>;
likedMovies: number[];
passedMovies: number[];
matches: Movie[];
feed: Movie[];
fetchFeed: () => Promise<void>;
handleSwipe: (movie: Movie, direction: 'left' | 'right') => Promise<void>;
markAsWatched: (movieId: number) => Promise<void>;
watchedMovies: number[];
isLoading: boolean;
newMatch: Movie | null;
clearNewMatch: () => void;
}
const AppContext = createContext<AppState | undefined>(undefined);
export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('cinematch_token'));
const [likedMovies, setLikedMovies] = useState<number[]>([]);
const [passedMovies, setPassedMovies] = useState<number[]>([]);
const [matches, setMatches] = useState<Movie[]>([]);
const [feed, setFeed] = useState<Movie[]>([]);
const [watchedMovies, setWatchedMovies] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [newMatch, setNewMatch] = useState<Movie | null>(null);
const clearNewMatch = () => setNewMatch(null);
const fetchWithAuth = async (url: string, options: RequestInit = {}) => {
const headers = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers as any),
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
logout();
throw new Error('Unauthorized');
}
return response;
};
const loadUserData = async () => {
if (!token) {
setIsLoading(false);
return;
}
try {
const [userRes, swipesRes, matchesRes, watchedRes] = await Promise.all([
fetchWithAuth('/api/auth/me'),
fetchWithAuth('/api/swipes'),
fetchWithAuth('/api/matches'),
fetchWithAuth('/api/watched')
]);
if (userRes.ok) {
const userData = await userRes.json();
setUser(userData.user);
}
if (swipesRes.ok) {
const { swipes } = await swipesRes.json();
setLikedMovies(swipes.filter((s: any) => s.direction === 'right').map((s: any) => s.movie_id));
setPassedMovies(swipes.filter((s: any) => s.direction === 'left').map((s: any) => s.movie_id));
}
if (matchesRes.ok) {
const { matches: matchData } = await matchesRes.json();
setMatches(matchData);
}
if (watchedRes.ok) {
const { watched } = await watchedRes.json();
setWatchedMovies(watched);
}
await fetchFeed();
} catch (error) {
console.error('Failed to load user data', error);
} finally {
setIsLoading(false);
}
};
const fetchFeed = async () => {
if (!token) return;
try {
const res = await fetchWithAuth('/api/movies/feed');
if (res.ok) {
const data = await res.json();
setFeed(data.movies);
}
} catch (error) {
console.error('Failed to fetch feed', error);
}
};
const updateGenres = async (genres: number[]) => {
try {
const res = await fetchWithAuth('/api/user/genres', {
method: 'POST',
body: JSON.stringify({ genres }),
});
if (res.ok) {
setUser(prev => prev ? { ...prev, genres } : null);
await fetchFeed(); // Refresh feed with new genres
}
} catch (error) {
console.error('Failed to update genres', error);
}
};
useEffect(() => {
loadUserData();
}, [token]);
// Poll for partner code if we don't have one
useEffect(() => {
if (!token || user?.partner_code) return;
const interval = setInterval(async () => {
try {
const res = await fetchWithAuth('/api/auth/me');
if (res.ok) {
const { user: userData } = await res.json();
if (userData.partner_code) {
setUser(userData);
// Also fetch matches since we now have a partner
const matchesRes = await fetchWithAuth('/api/matches');
if (matchesRes.ok) {
const { matches: matchData } = await matchesRes.json();
setMatches(matchData);
}
}
}
} catch (e) {
// ignore
}
}, 5000);
return () => clearInterval(interval);
}, [token, user?.partner_code]);
// Poll for matches every 10 seconds if we have a partner
useEffect(() => {
if (!token || !user?.partner_code) return;
const interval = setInterval(async () => {
try {
const res = await fetchWithAuth('/api/matches');
if (res.ok) {
const { matches: matchData } = await res.json();
setMatches(matchData);
}
} catch (e) {
// ignore
}
}, 10000);
return () => clearInterval(interval);
}, [token, user?.partner_code]);
const login = (newToken: string, newUser: User) => {
localStorage.setItem('cinematch_token', newToken);
setToken(newToken);
setUser(newUser);
};
const logout = () => {
localStorage.removeItem('cinematch_token');
setToken(null);
setUser(null);
setLikedMovies([]);
setPassedMovies([]);
setMatches([]);
setWatchedMovies([]);
};
const linkPartner = async (code: string) => {
const res = await fetchWithAuth('/api/link', {
method: 'POST',
body: JSON.stringify({ partnerCode: code })
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to link partner');
}
if (user) {
setUser({ ...user, partner_code: code });
}
// Reload matches immediately
loadUserData();
};
const unlinkPartner = async () => {
const res = await fetchWithAuth('/api/unlink', { method: 'POST' });
if (res.ok && user) {
setUser({ ...user, partner_code: null });
setMatches([]);
}
};
const handleSwipe = async (movie: Movie, direction: 'left' | 'right') => {
if (direction === 'right') {
setLikedMovies((prev) => [...prev, movie.id]);
} else {
setPassedMovies((prev) => [...prev, movie.id]);
}
setFeed((prev) => prev.filter(m => m.id !== movie.id));
try {
await fetchWithAuth('/api/swipe', {
method: 'POST',
body: JSON.stringify({ movieId: movie.id, direction })
});
// Check for matches immediately after a right swipe
if (direction === 'right' && user?.partner_code) {
const res = await fetchWithAuth('/api/matches');
if (res.ok) {
const { matches: matchData } = await res.json();
// If we found a new match that wasn't there before
if (matchData.length > matches.length) {
setNewMatch(movie);
}
setMatches(matchData);
}
}
// Fetch more movies if feed is getting low
if (feed.length <= 3) {
fetchFeed();
}
} catch (error) {
console.error('Failed to save swipe', error);
}
};
const markAsWatched = async (movieId: number) => {
setWatchedMovies((prev) => [...prev, movieId]);
try {
await fetchWithAuth('/api/watched', {
method: 'POST',
body: JSON.stringify({ movieId })
});
} catch (error) {
console.error('Failed to mark as watched', error);
}
};
return (
<AppContext.Provider
value={{
user,
token,
login,
logout,
linkPartner,
unlinkPartner,
updateGenres,
likedMovies,
passedMovies,
matches,
feed,
fetchFeed,
handleSwipe,
markAsWatched,
watchedMovies,
isLoading,
newMatch,
clearNewMatch
}}
>
{children}
</AppContext.Provider>
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const useAppContext = () => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
};

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:3001'
}
}
})

292
package-lock.json generated Normal file
View file

@ -0,0 +1,292 @@
{
"name": "cinematch",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinematch",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"concurrently": "^9.2.1",
"dotenv": "^17.3.1"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"engines": {
"node": ">=12"
}
}
}
}

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "cinematch",
"version": "1.0.0",
"description": "CineMatch is a collaborative movie discovery application designed to solve the common dilemma of choosing what to watch. By leveraging a swipe-based interface, it streamlines the decision-making process for couples, friends, groups, etc.",
"main": "index.js",
"scripts": {
"start": "concurrently \"npm run server\" \"npm run client\"",
"server": "cd backend && node server.js",
"client": "cd frontend && npm run dev"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"concurrently": "^9.2.1",
"dotenv": "^17.3.1"
}
}

1
start.sh Normal file
View file

@ -0,0 +1 @@
docker build -t cinematch . && docker stop cinematch && docker rm cinematch && docker run -d -p 3001:3001 --name cinematch cinematch