Initial commit
This commit is contained in:
parent
32184a54ce
commit
a18a0e9b7a
31 changed files with 8180 additions and 0 deletions
7
.dockerignore
Normal file
7
.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
frontend/node_modules
|
||||
backend/node_modules
|
||||
frontend/dist
|
||||
.git
|
||||
.env
|
||||
cinematch.db
|
||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal 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
25
Dockerfile
Normal 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
46
backend/database.js
Normal 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
2193
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
backend/package.json
Normal file
20
backend/package.json
Normal 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
381
backend/server.js
Normal 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
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
3725
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
25
frontend/src/App.tsx
Normal 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;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
44
frontend/src/components/Navbar.tsx
Normal file
44
frontend/src/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
75
frontend/src/data/movies.ts
Normal file
75
frontend/src/data/movies.ts
Normal 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
22
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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
303
frontend/src/pages/Home.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
100
frontend/src/pages/Matches.tsx
Normal file
100
frontend/src/pages/Matches.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
295
frontend/src/pages/Swipe.tsx
Normal file
295
frontend/src/pages/Swipe.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
317
frontend/src/store/AppContext.tsx
Normal file
317
frontend/src/store/AppContext.tsx
Normal 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;
|
||||
};
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
13
frontend/vite.config.ts
Normal 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
292
package-lock.json
generated
Normal 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
18
package.json
Normal 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
1
start.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
docker build -t cinematch . && docker stop cinematch && docker rm cinematch && docker run -d -p 3001:3001 --name cinematch cinematch
|
||||
Loading…
Add table
Add a link
Reference in a new issue