mirror of
https://github.com/kuhyx/WUT_Computer_Science.git
synced 2026-07-04 14:43:08 +02:00
minor fixes
This commit is contained in:
parent
770559d5ea
commit
a8db79ba06
1
backend/.env
Normal file
1
backend/.env
Normal file
@ -0,0 +1 @@
|
||||
TMDB_BEARER_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIxYmMxYTM1Y2U5MmIwZjc5MjUzODJhYWQxN2NkMDI5NiIsInN1YiI6IjY2NDFkOTBlNTM3MzE0ZGRmMGZiNGYxZCIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.PbJkpuXgokjZPd8tL_ZVUyCW4Hf0dW67JInkgld27Ew"
|
||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
__pycache__
|
||||
instance
|
||||
# .env
|
||||
.json
|
||||
199
backend/app.py
Normal file
199
backend/app.py
Normal file
@ -0,0 +1,199 @@
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, auth
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import requests
|
||||
|
||||
load_dotenv()
|
||||
TMDB_BEARER_TOKEN = os.getenv('TMDB_BEARER_TOKEN')
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.sqlite3'
|
||||
app.config['SQLALCHEY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
CORS(app)
|
||||
|
||||
cred = credentials.Certificate('movie-recommendation-firebase-adminsdk.json')
|
||||
firebase_admin.initialize_app(cred)
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
uid = db.Column(db.String(80), unique=True, nullable=False)
|
||||
email = db.Column(db.String(80), unique=True, nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
class Rating(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.uid'), nullable=False)
|
||||
movie_id = db.Column(db.Integer, nullable=False)
|
||||
value = db.Column(db.Integer, nullable=False)
|
||||
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
|
||||
@app.route('/login', methods=['POST'])
|
||||
def login():
|
||||
token = request.json.get('token')
|
||||
try:
|
||||
decoded_token = auth.verify_id_token(token)
|
||||
uid = decoded_token['uid']
|
||||
print(uid)
|
||||
email = decoded_token.get('email')
|
||||
|
||||
user = User.query.filter_by(uid=uid).first()
|
||||
if user is None:
|
||||
user = User(uid=uid, email=email)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Login successful!', 'email': email, 'is_admin': user.is_admin}), 200
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return jsonify({'message': 'Login failed'}), 401
|
||||
|
||||
|
||||
@app.route('/count_user_ratings', methods=['POST'])
|
||||
def count_user_ratings():
|
||||
token = request.json.get('token')
|
||||
try:
|
||||
decoded_token = auth.verify_id_token(token)
|
||||
user_id = decoded_token['uid']
|
||||
|
||||
user = User.query.filter_by(uid=user_id).first()
|
||||
if user is None:
|
||||
return jsonify({'message': 'Error'}), 500
|
||||
|
||||
rating_count = Rating.query.filter_by(user_id=user_id).count()
|
||||
return jsonify({'message': 'Ratings counted!', 'rating_count': rating_count}), 200
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return jsonify({'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/movie/<int:movie_id>', methods=['GET'])
|
||||
def get_tmdb_data_movie_id(movie_id):
|
||||
url = f'https://api.themoviedb.org/3/movie/{movie_id}'
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {TMDB_BEARER_TOKEN}'}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return jsonify(response.json()), 200
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return jsonify({'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/movie', methods=['GET'])
|
||||
def get_tmdb_data_query():
|
||||
query = request.args.get('query')
|
||||
|
||||
if query:
|
||||
url = f'https://api.themoviedb.org/3/search/movie?query={query}'
|
||||
else:
|
||||
url = 'https://api.themoviedb.org/3/trending/movie/day'
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {TMDB_BEARER_TOKEN}'}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return jsonify(response.json()), 200
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return jsonify({'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/rating', methods=['POST'])
|
||||
def add_rating():
|
||||
token = request.json.get('token')
|
||||
movie = request.json.get('movie')
|
||||
value = request.json.get('value')
|
||||
try:
|
||||
decoded_token = auth.verify_id_token(token)
|
||||
user_id = decoded_token['uid']
|
||||
|
||||
# user = User.query.filter_by(uid=user_id).first()
|
||||
# if user is None:
|
||||
# return jsonify({'message': 'Error'}), 500
|
||||
|
||||
rating = Rating.query.filter_by(
|
||||
user_id=user_id, movie_id=movie).first()
|
||||
if rating is None:
|
||||
rating = Rating(user_id=user_id, movie_id=movie, value=value)
|
||||
|
||||
db.session.add(rating)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Rating added successfully!'}), 201
|
||||
else:
|
||||
rating.value = value
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Rating updated successfully!'}), 200
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return jsonify({'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/rating', methods=['DELETE'])
|
||||
def remove_rating():
|
||||
token = request.json.get('token')
|
||||
movie = request.json.get('movie')
|
||||
try:
|
||||
decoded_token = auth.verify_id_token(token)
|
||||
user_id = decoded_token['uid']
|
||||
|
||||
# user = User.query.filter_by(uid=user_id).first()
|
||||
# if user is None:
|
||||
# return jsonify({'message': 'Error'}), 500
|
||||
|
||||
rating = Rating.query.filter_by(
|
||||
user_id=user_id, movie_id=movie).first()
|
||||
if rating is None:
|
||||
return jsonify({'message': 'Error'}), 500
|
||||
|
||||
db.session.delete(rating)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Rating removed successfully!'}), 200
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return jsonify({'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/get_rating', methods=['POST'])
|
||||
def get_rating():
|
||||
token = request.json.get('token')
|
||||
movie = request.json.get('movie')
|
||||
try:
|
||||
decoded_token = auth.verify_id_token(token)
|
||||
user_id = decoded_token['uid']
|
||||
|
||||
# user = User.query.filter_by(uid=user_id).first()
|
||||
# if user is None:
|
||||
# return jsonify({'message': 'Error'}), 500
|
||||
|
||||
rating = Rating.query.filter_by(
|
||||
user_id=user_id, movie_id=movie).first()
|
||||
if rating is None:
|
||||
return jsonify({'message': 'Rating not found!'}), 200
|
||||
else:
|
||||
return jsonify({'message': 'Rating found!', 'movie': rating.movie_id, 'value': rating.value}), 200
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return jsonify({'message': str(e)}), 500
|
||||
19
frontend/.eslintrc.cjs
Normal file
19
frontend/.eslintrc.cjs
Normal file
@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'prettier',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
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?
|
||||
3
frontend/.prettierrc.json
Normal file
3
frontend/.prettierrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
}
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM node:18-alpine AS build-stage
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
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>Movie Recommendation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="flex min-h-dvh flex-col bg-gray-800 text-white"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5356
frontend/package-lock.json
generated
Normal file
5356
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "movie-recommendation-client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@smastrom/react-rating": "^1.5.0",
|
||||
"axios": "^1.6.8",
|
||||
"firebase": "^10.11.1",
|
||||
"react": "^18.2.0",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-loader-spinner": "^6.1.6",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"recharts": "^2.12.7",
|
||||
"swr": "^2.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
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 |
26
frontend/src/App.tsx
Normal file
26
frontend/src/App.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
import Home from './pages/Home';
|
||||
import Recommendations from './pages/Recommendations';
|
||||
import Rate from './pages/Rate';
|
||||
import Analytics from './pages/Analytics';
|
||||
|
||||
function App() {
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{ path: '/', element: <Home /> },
|
||||
{ path: '/recommendations', element: <Recommendations /> },
|
||||
{ path: '/rate', element: <Rate /> },
|
||||
{ path: '/analytics', element: <Analytics /> },
|
||||
{ path: '*', element: <code>404</code> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
32
frontend/src/Layout.tsx
Normal file
32
frontend/src/Layout.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthState } from 'react-firebase-hooks/auth';
|
||||
import { Triangle } from 'react-loader-spinner';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import Footer from './components/Footer';
|
||||
import { auth } from './firebase';
|
||||
|
||||
function Layout() {
|
||||
const [user, userLoading] = useAuthState(auth);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!userLoading && !user) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [navigate, user, userLoading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
{userLoading ? (
|
||||
<Triangle wrapperClass="m-auto" height={120} width={120} />
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
1
frontend/src/assets/google.svg
Normal file
1
frontend/src/assets/google.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#fff" d="M44.59 4.21a63.28 63.28 0 004.33 120.9 67.6 67.6 0 0032.36.35 57.13 57.13 0 0025.9-13.46 57.44 57.44 0 0016-26.26 74.33 74.33 0 001.61-33.58H65.27v24.69h34.47a29.72 29.72 0 01-12.66 19.52 36.16 36.16 0 01-13.93 5.5 41.29 41.29 0 01-15.1 0A37.16 37.16 0 0144 95.74a39.3 39.3 0 01-14.5-19.42 38.31 38.31 0 010-24.63 39.25 39.25 0 019.18-14.91A37.17 37.17 0 0176.13 27a34.28 34.28 0 0113.64 8q5.83-5.8 11.64-11.63c2-2.09 4.18-4.08 6.15-6.22A61.22 61.22 0 0087.2 4.59a64 64 0 00-42.61-.38z"/><path fill="#e33629" d="M44.59 4.21a64 64 0 0142.61.37 61.22 61.22 0 0120.35 12.62c-2 2.14-4.11 4.14-6.15 6.22Q95.58 29.23 89.77 35a34.28 34.28 0 00-13.64-8 37.17 37.17 0 00-37.46 9.74 39.25 39.25 0 00-9.18 14.91L8.76 35.6A63.53 63.53 0 0144.59 4.21z"/><path fill="#f8bd00" d="M3.26 51.5a62.93 62.93 0 015.5-15.9l20.73 16.09a38.31 38.31 0 000 24.63q-10.36 8-20.73 16.08a63.33 63.33 0 01-5.5-40.9z"/><path fill="#587dbd" d="M65.27 52.15h59.52a74.33 74.33 0 01-1.61 33.58 57.44 57.44 0 01-16 26.26c-6.69-5.22-13.41-10.4-20.1-15.62a29.72 29.72 0 0012.66-19.54H65.27c-.01-8.22 0-16.45 0-24.68z"/><path fill="#319f43" d="M8.75 92.4q10.37-8 20.73-16.08A39.3 39.3 0 0044 95.74a37.16 37.16 0 0014.08 6.08 41.29 41.29 0 0015.1 0 36.16 36.16 0 0013.93-5.5c6.69 5.22 13.41 10.4 20.1 15.62a57.13 57.13 0 01-25.9 13.47 67.6 67.6 0 01-32.36-.35 63 63 0 01-23-11.59A63.73 63.73 0 018.75 92.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
frontend/src/assets/poster_placeholder.svg
Normal file
1
frontend/src/assets/poster_placeholder.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" width="790" height="512.20805" viewBox="0 0 790 512.20805" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M925.56335,704.58909,903,636.49819s24.81818,24.81818,24.81818,45.18181l-4.45454-47.09091s12.72727,17.18182,11.45454,43.27273S925.56335,704.58909,925.56335,704.58909Z" transform="translate(-205 -193.89598)" fill="#e6e6e6"/><path d="M441.02093,642.58909,419,576.13509s24.22155,24.22155,24.22155,44.09565l-4.34745-45.95885s12.42131,16.76877,11.17917,42.23245S441.02093,642.58909,441.02093,642.58909Z" transform="translate(-205 -193.89598)" fill="#e6e6e6"/><path d="M784.72555,673.25478c.03773,43.71478-86.66489,30.26818-192.8092,30.35979s-191.53562,13.68671-191.57335-30.028,86.63317-53.29714,192.77748-53.38876S784.68782,629.54,784.72555,673.25478Z" transform="translate(-205 -193.89598)" fill="#e6e6e6"/><rect y="509.69312" width="790" height="2" fill="#3f3d56"/><polygon points="505.336 420.322 491.459 420.322 484.855 366.797 505.336 366.797 505.336 420.322" fill="#a0616a"/><path d="M480.00587,416.35743H508.3101a0,0,0,0,1,0,0V433.208a0,0,0,0,1,0,0H464.69674a0,0,0,0,1,0,0v-1.54149A15.30912,15.30912,0,0,1,480.00587,416.35743Z" fill="#2f2e41"/><polygon points="607.336 499.322 593.459 499.322 586.855 445.797 607.336 445.797 607.336 499.322" fill="#a0616a"/><path d="M582.00587,495.35743H610.3101a0,0,0,0,1,0,0V512.208a0,0,0,0,1,0,0H566.69674a0,0,0,0,1,0,0v-1.54149A15.30912,15.30912,0,0,1,582.00587,495.35743Z" fill="#2f2e41"/><path d="M876.34486,534.205A10.31591,10.31591,0,0,0,873.449,518.654l-32.23009-131.2928L820.6113,396.2276l38.33533,126.949a10.37185,10.37185,0,0,0,17.39823,11.0284Z" transform="translate(-205 -193.89598)" fill="#a0616a"/><path d="M851.20767,268.85955a11.38227,11.38227,0,0,0-17.41522,1.15247l-49.88538,5.72709,7.58861,19.24141,45.36779-8.49083a11.44393,11.44393,0,0,0,14.3442-17.63014Z" transform="translate(-205 -193.89598)" fill="#a0616a"/><path d="M769,520.58909l21.76811,163.37417,27.09338-5.578s-3.98437-118.98157,9.56238-133.32513S810,505.58909,810,505.58909Z" transform="translate(-205 -193.89598)" fill="#2f2e41"/><path d="M778,475.58909l-10,15s-77-31.99929-77,19-4.40631,85.60944-6,88,18.43762,8.59375,28,7c0,0,11.79687-82.21884,11-87,0,0,75.53355,37.03335,89.87712,33.84591S831.60944,536.964,834,530.58909s-1-57-1-57l-47.81-14.59036Z" transform="translate(-205 -193.89598)" fill="#2f2e41"/><path d="M779.34915,385.52862l-2.85032-3.42039s-31.92361-71.82815-19.3822-91.21035,67.26762-22.23252,68.97783-21.0924-4.08488,15.9428-.09446,22.78361c0,0-42.394,9.19121-45.24435,10.33134s21.96615,43.2737,21.96615,43.2737l-2.85031,25.6529Z" transform="translate(-205 -193.89598)" fill="#ccc"/><path d="M835.21549,350.18459S805.57217,353.605,804.432,353.605s-1.71017-7.41084-1.71017-7.41084l-26.223,35.91406S763.57961,486.29929,767,484.58909s66.50531,8.11165,67.07539,3.55114-.57008-27.3631,1.14014-28.50324,29.64328-71.82811,29.64328-71.82811-2.85032-14.82168-12.54142-19.95227S835.21549,350.18459,835.21549,350.18459Z" transform="translate(-205 -193.89598)" fill="#ccc"/><path d="M855.73783,378.11779l9.121,9.69109S878.41081,499.1687,871,502.58909s-22,3-22,3l-14.35458-52.79286Z" transform="translate(-205 -193.89598)" fill="#ccc"/><circle cx="601.72966" cy="122.9976" r="26.2388" fill="#a0616a"/><path d="M800.57267,320.98789c-.35442-5.44445-7.22306-5.631-12.67878-5.68255s-11.97836.14321-15.0654-4.35543c-2.0401-2.973-1.65042-7.10032.035-10.28779s4.45772-5.639,7.18508-7.99742c7.04139-6.08884,14.29842-12.12936,22.7522-16.02662s18.36045-5.472,27.12788-2.3435c10.77008,3.84307,25.32927,23.62588,26.5865,34.99176s-3.28507,22.95252-10.9419,31.44586-25.18188,5.0665-36.21069,8.088c6.7049-9.48964,2.28541-26.73258-8.45572-31.164Z" transform="translate(-205 -193.89598)" fill="#2f2e41"/><circle cx="361.7217" cy="403.5046" r="62.98931" fill="#f9c326"/><path d="M524.65625,529.9355a45.15919,45.15919,0,0,1-41.25537-26.78614L383.44873,278.05757a59.83039,59.83039,0,1,1,111.87012-41.86426l72.37744,235.41211a45.07978,45.07978,0,0,1-43.04,58.33008Z" transform="translate(-205 -193.89598)" fill="#f9c326"/></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
frontend/src/assets/tmdb.svg
Normal file
1
frontend/src/assets/tmdb.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 423.04 35.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.7" x2="423.04" y2="17.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 1</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M227.5,0h8.9l8.75,23.2h.1L254.15,0h8.35L247.9,35.4h-6.25Zm46.6,0h7.8V35.4h-7.8Zm22.2,0h24.05V7.2H304.1v6.6h15.35V21H304.1v7.2h17.15v7.2H296.3Zm55,0H363a33.54,33.54,0,0,1,8.07,1A18.55,18.55,0,0,1,377.75,4a15.1,15.1,0,0,1,4.52,5.53A18.5,18.5,0,0,1,384,17.8a16.91,16.91,0,0,1-1.63,7.58,16.37,16.37,0,0,1-4.37,5.5,19.52,19.52,0,0,1-6.35,3.37A24.59,24.59,0,0,1,364,35.4H351.29Zm7.81,28.2h4a21.57,21.57,0,0,0,5-.55,10.87,10.87,0,0,0,4-1.83,8.69,8.69,0,0,0,2.67-3.34,11.92,11.92,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9,9,0,0,0-2.62-3.18,11.68,11.68,0,0,0-3.88-1.88,17.43,17.43,0,0,0-4.67-.62h-4.6ZM395.24,0h13.2a34.42,34.42,0,0,1,4.63.32,12.9,12.9,0,0,1,4.17,1.3,7.88,7.88,0,0,1,3,2.73A8.34,8.34,0,0,1,421.39,9a7.42,7.42,0,0,1-1.67,5,9.28,9.28,0,0,1-4.43,2.82v.1a10,10,0,0,1,3.18,1,8.38,8.38,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.42,9.42,0,0,1-3.1,3,13.38,13.38,0,0,1-4.27,1.65,23.11,23.11,0,0,1-4.73.5h-14.5ZM403,14.15h5.65a8.16,8.16,0,0,0,1.78-.2A4.78,4.78,0,0,0,412,13.3a3.34,3.34,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8,3.22,3.22,0,0,0-.47-1.82,3.33,3.33,0,0,0-1.23-1.13,5.77,5.77,0,0,0-1.7-.58,10.79,10.79,0,0,0-1.85-.17H403Zm0,14.65h7a8.91,8.91,0,0,0,1.83-.2,4.78,4.78,0,0,0,1.67-.7,4,4,0,0,0,1.23-1.3,3.71,3.71,0,0,0,.47-2,3.13,3.13,0,0,0-.62-2A4,4,0,0,0,413,21.45,7.83,7.83,0,0,0,411,20.9a15.12,15.12,0,0,0-2.05-.15H403Zm-199,6.53H205a17.66,17.66,0,0,0,17.66-17.66h0A17.67,17.67,0,0,0,205,0h-.91A17.67,17.67,0,0,0,186.4,17.67h0A17.66,17.66,0,0,0,204.06,35.33ZM10.1,6.9H0V0H28V6.9H17.9V35.4H10.1ZM39,0h7.8V13.2H61.9V0h7.8V35.4H61.9V20.1H46.75V35.4H39ZM80.2,0h24V7.2H88v6.6h15.35V21H88v7.2h17.15v7.2h-25Zm55,0H147l8.15,23.1h.1L163.45,0H175.2V35.4h-7.8V8.25h-.1L158,35.4h-5.95l-9-27.15H143V35.4h-7.8Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
30
frontend/src/components/Footer.tsx
Normal file
30
frontend/src/components/Footer.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import tmdb from '../assets/tmdb.svg';
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<div className="mt-auto mx-auto">
|
||||
<footer className="py-6 px-4 mt-16 flex flex-col items-center gap-4">
|
||||
<a
|
||||
href="https://www.themoviedb.org"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
<img src={tmdb} alt="The Movie Database" className="w-[180px]" />
|
||||
</a>
|
||||
<small className="text-sm text-[#a0d7d8] text-center leading-relaxed">
|
||||
<span>This product uses the </span>
|
||||
<a
|
||||
href="https://www.themoviedb.org"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
TMDB
|
||||
</a>
|
||||
<span> API but is not endorsed or certified by TMDB.</span>
|
||||
</small>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
61
frontend/src/components/Navbar.tsx
Normal file
61
frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useAuthState, useSignOut } from 'react-firebase-hooks/auth';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { auth } from '../firebase';
|
||||
|
||||
function Navbar() {
|
||||
const [user, userLoading] = useAuthState(auth);
|
||||
const [signOut] = useSignOut(auth);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSignOut = () => {
|
||||
navigate('/');
|
||||
signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="px-6 py-4 flex justify-between items-center">
|
||||
<Link to="/">
|
||||
<h1 className="text-xl sm:text-2xl">Movie Recommendation</h1>
|
||||
</Link>
|
||||
{userLoading && <p>Loading user info</p>}
|
||||
{!userLoading && !user && (
|
||||
<p className="text-gray-300 text-lg">Logged out</p>
|
||||
)}
|
||||
{!!user && (
|
||||
<div className="flex gap-8">
|
||||
<nav>
|
||||
<ul className="flex flex-col text-center gap-4 md:flex-row">
|
||||
<li>
|
||||
<NavLink
|
||||
to="/recommendations"
|
||||
className={({ isActive }) => (isActive ? 'font-medium' : '')}
|
||||
>
|
||||
Recommendations
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
to="/rate"
|
||||
className={({ isActive }) => (isActive ? 'font-medium' : '')}
|
||||
>
|
||||
Rate
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
className={({ isActive }) => (isActive ? 'font-medium' : '')}
|
||||
>
|
||||
Analytics
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<button onClick={handleSignOut}>Log out</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
98
frontend/src/components/RateMovie.tsx
Normal file
98
frontend/src/components/RateMovie.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthState } from 'react-firebase-hooks/auth';
|
||||
import { useCollectionData } from 'react-firebase-hooks/firestore';
|
||||
import {
|
||||
addDoc,
|
||||
collection,
|
||||
deleteDoc,
|
||||
query,
|
||||
setDoc,
|
||||
where,
|
||||
} from 'firebase/firestore';
|
||||
import { Rating, RatingChange } from '@smastrom/react-rating';
|
||||
import { auth, db } from '../firebase';
|
||||
import TMDBMovie from '../types/TMDBMovie';
|
||||
import posterPlaceholder from '../assets/poster_placeholder.svg';
|
||||
|
||||
interface RateMovieProps {
|
||||
movie: TMDBMovie;
|
||||
}
|
||||
|
||||
const ratingsRef = collection(db, 'ratings');
|
||||
|
||||
function RateMovie({ movie }: RateMovieProps) {
|
||||
const [user] = useAuthState(auth);
|
||||
|
||||
if (!user) throw new Error('No user');
|
||||
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const q = query(
|
||||
ratingsRef,
|
||||
where('userId', '==', user.uid),
|
||||
where('movieId', '==', movie.id),
|
||||
);
|
||||
|
||||
const [values, isLoading, , snapshot] = useCollectionData<{
|
||||
userId?: string;
|
||||
movieId?: number;
|
||||
rating?: number;
|
||||
}>(q);
|
||||
|
||||
const dbRating = values?.[0]?.rating;
|
||||
const docRef = snapshot?.docs[0]?.ref;
|
||||
|
||||
useEffect(() => {
|
||||
setRating(dbRating || null);
|
||||
}, [dbRating]);
|
||||
|
||||
const handleChange: RatingChange = async (value: number) => {
|
||||
setIsProcessing(true);
|
||||
|
||||
const data = {
|
||||
userId: user.uid,
|
||||
movieId: movie.id,
|
||||
rating: value,
|
||||
};
|
||||
|
||||
try {
|
||||
if (docRef) {
|
||||
if (value !== 0) {
|
||||
await setDoc(docRef, data);
|
||||
} else {
|
||||
await deleteDoc(docRef);
|
||||
}
|
||||
} else {
|
||||
await addDoc(ratingsRef, data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const posterUrl = `https://image.tmdb.org/t/p/w154${movie.poster_path}`;
|
||||
|
||||
return (
|
||||
<article className="flex flex-col items-center w-[250px]">
|
||||
<img
|
||||
src={movie.poster_path ? posterUrl : posterPlaceholder}
|
||||
alt={movie.title}
|
||||
className="w-[154px] h-[231px] object-contain mb-2"
|
||||
/>
|
||||
<h3 className="my-auto text-lg text-center text-gray-200 font-medium">
|
||||
{movie.title}
|
||||
</h3>
|
||||
<Rating
|
||||
value={rating || 0}
|
||||
onChange={handleChange}
|
||||
isDisabled={isLoading || isProcessing}
|
||||
className="max-w-[150px] mt-2"
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default RateMovie;
|
||||
63
frontend/src/components/Recommendation.tsx
Normal file
63
frontend/src/components/Recommendation.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar';
|
||||
import IRecommendation from '../types/Recommendation';
|
||||
import TMDBMovie from '../types/TMDBMovie';
|
||||
import posterPlaceholder from '../assets/poster_placeholder.svg';
|
||||
|
||||
interface RecommendationProps {
|
||||
recommendation: IRecommendation;
|
||||
}
|
||||
|
||||
function Recommendation({ recommendation }: RecommendationProps) {
|
||||
const [movie, setMovie] = useState<TMDBMovie | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const url = `https://api.themoviedb.org/3/movie/${recommendation.movieId}`;
|
||||
|
||||
const res = await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${import.meta.env.VITE_TMDB_BEARER_TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
const movieDetails = res.data as TMDBMovie;
|
||||
|
||||
setMovie(movieDetails);
|
||||
})();
|
||||
}, [recommendation.movieId]);
|
||||
|
||||
if (!movie) return null;
|
||||
|
||||
const posterUrl = `https://image.tmdb.org/t/p/w154${movie.poster_path}`;
|
||||
|
||||
return (
|
||||
<article className="flex flex-col items-center w-[250px]">
|
||||
<img
|
||||
src={movie.poster_path ? posterUrl : posterPlaceholder}
|
||||
alt={movie.title}
|
||||
className="w-[154px] h-[231px] object-contain mb-2"
|
||||
/>
|
||||
<h3 className="my-auto text-lg text-center text-gray-200 font-medium">
|
||||
{movie.title}
|
||||
</h3>
|
||||
<CircularProgressbar
|
||||
value={recommendation.match}
|
||||
text={`${recommendation.match}%`}
|
||||
className="size-[75px] mt-4"
|
||||
strokeWidth={11}
|
||||
circleRatio={0.75}
|
||||
styles={buildStyles({
|
||||
rotation: 1 / 2 + 1 / 8,
|
||||
strokeLinecap: 'butt',
|
||||
trailColor: '#eee',
|
||||
textSize: '24px',
|
||||
textColor: '#fff',
|
||||
})}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default Recommendation;
|
||||
27
frontend/src/components/Refresh.tsx
Normal file
27
frontend/src/components/Refresh.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { RotatingLines } from 'react-loader-spinner';
|
||||
|
||||
interface RefreshProps {
|
||||
mutate: () => void;
|
||||
isValidating: boolean;
|
||||
}
|
||||
|
||||
function Refresh({ mutate, isValidating }: RefreshProps) {
|
||||
return (
|
||||
<div className="flex relative">
|
||||
<button
|
||||
onClick={() => mutate()}
|
||||
disabled={isValidating}
|
||||
className={`mt-10 mb-10 flex items-center gap-2 border rounded-full py-2 px-4 border-gray-400 hover:bg-gray-700 duration-100 ${isValidating ? 'cursor-wait' : ''}`}
|
||||
>
|
||||
{isValidating ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
{isValidating && (
|
||||
<div className="absolute -right-2 translate-x-full top-1/2 -translate-y-1/2">
|
||||
<RotatingLines width="24" strokeColor="#fff" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Refresh;
|
||||
63
frontend/src/components/Welcome.tsx
Normal file
63
frontend/src/components/Welcome.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import {
|
||||
collection,
|
||||
getCountFromServer,
|
||||
query,
|
||||
where,
|
||||
} from 'firebase/firestore';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthState } from 'react-firebase-hooks/auth';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { auth, db } from '../firebase';
|
||||
|
||||
const ratingsRef = collection(db, 'ratings');
|
||||
|
||||
function Welcome() {
|
||||
const [user] = useAuthState(auth);
|
||||
const [ratingsCount, setRatingsCount] = useState<number | null>(null);
|
||||
|
||||
if (!user) throw new Error('No user');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const ratingsQuery = query(ratingsRef, where('userId', '==', user.uid));
|
||||
const snapshot = await getCountFromServer(ratingsQuery);
|
||||
|
||||
setRatingsCount(snapshot.data().count);
|
||||
})();
|
||||
}, [user.uid]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="text-lg mb-12">Hello {user.displayName}!</p>
|
||||
{ratingsCount === null && <p>Loading your data</p>}
|
||||
{Number.isInteger(ratingsCount) &&
|
||||
(ratingsCount === 0 ? (
|
||||
<p className="leading-loose">
|
||||
ℹ️
|
||||
<br />
|
||||
You don't have any ratings yet.
|
||||
<br />
|
||||
<span>Please </span>
|
||||
<Link to="/rate" className="underline">
|
||||
rate
|
||||
</Link>
|
||||
<span> your first movie.</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="leading-loose">
|
||||
<span>You have </span>
|
||||
<span className="text-yellow-400 font-bold">{ratingsCount}</span>
|
||||
<span> movie rating{ratingsCount! > 1 ? 's' : ''}!</span>
|
||||
<br />
|
||||
<span>Go to </span>
|
||||
<Link to="/recommendations" className="underline">
|
||||
recommendations
|
||||
</Link>
|
||||
<span> to get inspired.</span>
|
||||
</p>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Welcome;
|
||||
18
frontend/src/firebase.ts
Normal file
18
frontend/src/firebase.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyAF34ovW0qaDlV68sbpCjylz4TQ0he_XEM',
|
||||
authDomain: 'movie-recommendation-2024.firebaseapp.com',
|
||||
projectId: 'movie-recommendation-2024',
|
||||
storageBucket: 'movie-recommendation-2024.appspot.com',
|
||||
messagingSenderId: '777010585410',
|
||||
appId: '1:777010585410:web:7db1436b72879d512032db',
|
||||
};
|
||||
|
||||
const app = initializeApp(firebaseConfig);
|
||||
const auth = getAuth(app);
|
||||
const db = getFirestore(app);
|
||||
|
||||
export { auth, db };
|
||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
12
frontend/src/main.tsx
Normal file
12
frontend/src/main.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import '@smastrom/react-rating/style.css';
|
||||
import 'react-circular-progressbar/dist/styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
73
frontend/src/pages/Analytics.tsx
Normal file
73
frontend/src/pages/Analytics.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { ThreeDots } from 'react-loader-spinner';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import useSWR, { Fetcher } from 'swr';
|
||||
import Refresh from '../components/Refresh';
|
||||
|
||||
interface VisitorsData {
|
||||
date: string;
|
||||
visitors: number;
|
||||
}
|
||||
|
||||
const fetcher: Fetcher<VisitorsData[], string> = async () => {
|
||||
const res = {
|
||||
data: {
|
||||
visitors: [
|
||||
{ date: '2024-05-10', visitors: Math.floor(Math.random() * 10) },
|
||||
{ date: '2024-05-11', visitors: Math.floor(Math.random() * 10) },
|
||||
{ date: '2024-05-12', visitors: Math.floor(Math.random() * 10) },
|
||||
{ date: '2024-05-13', visitors: Math.floor(Math.random() * 10) },
|
||||
{ date: '2024-05-14', visitors: Math.floor(Math.random() * 10) },
|
||||
{ date: '2024-05-15', visitors: Math.floor(Math.random() * 10) },
|
||||
{ date: '2024-05-16', visitors: Math.floor(Math.random() * 10) },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await new Promise((res) => setTimeout(res, 1500));
|
||||
|
||||
return res.data.visitors as VisitorsData[];
|
||||
};
|
||||
|
||||
function Analytics() {
|
||||
const { data, isLoading, isValidating, mutate } = useSWR('visitors', fetcher);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center px-6 flex-1">
|
||||
<h2 className="text-2xl font-medium">Analytics</h2>
|
||||
{isLoading && (
|
||||
<div className="my-auto flex flex-col items-center gap-2">
|
||||
<ThreeDots />
|
||||
<p className="italic">Loading analytics...</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && <Refresh mutate={mutate} isValidating={isValidating} />}
|
||||
{!isLoading && !data?.length && <p>Try again later 😬</p>}
|
||||
{!isLoading && !!data?.length && (
|
||||
<div className="w-full max-w-6xl text-[#2a5e87] my-auto">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, bottom: 5, left: 0 }}
|
||||
>
|
||||
<Legend verticalAlign="top" height={30} />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Area type="monotone" dataKey="visitors" />
|
||||
<Tooltip />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Analytics;
|
||||
33
frontend/src/pages/Home.tsx
Normal file
33
frontend/src/pages/Home.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useAuthState, useSignInWithGoogle } from 'react-firebase-hooks/auth';
|
||||
import Welcome from '../components/Welcome';
|
||||
import { auth } from '../firebase';
|
||||
import googleLogo from '../assets/google.svg';
|
||||
|
||||
function Home() {
|
||||
const [user] = useAuthState(auth);
|
||||
const [signInWithGoogle] = useSignInWithGoogle(auth);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center mt-16 mx-4 text-center">
|
||||
<h2 className="text-2xl font-medium mb-8">
|
||||
Find the perfect movie to watch tonight.
|
||||
</h2>
|
||||
{!user && (
|
||||
<>
|
||||
<p className="mt-4">But first you need to log in:</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signInWithGoogle()}
|
||||
className="mt-10 flex items-center gap-2 border rounded-full py-2 px-4 border-gray-400 hover:bg-gray-700 duration-100"
|
||||
>
|
||||
<img src={googleLogo} alt="Google" className="size-8" />
|
||||
<span>Log in with Google</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!!user && <Welcome />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
52
frontend/src/pages/Rate.tsx
Normal file
52
frontend/src/pages/Rate.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import axios from 'axios';
|
||||
import { useState } from 'react';
|
||||
import { ThreeDots } from 'react-loader-spinner';
|
||||
import useSWR, { Fetcher } from 'swr';
|
||||
import RateMovie from '../components/RateMovie';
|
||||
import TMDBMovie from '../types/TMDBMovie';
|
||||
|
||||
const fetcher: Fetcher<TMDBMovie[], string> = async (url) => {
|
||||
const res = await axios.get(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${import.meta.env.VITE_TMDB_BEARER_TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.data.results as TMDBMovie[];
|
||||
};
|
||||
|
||||
function Rate() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const tmdbQuery = value
|
||||
? `https://api.themoviedb.org/3/search/movie?query=${value}`
|
||||
: `https://api.themoviedb.org/3/trending/movie/day`;
|
||||
|
||||
const { data, isLoading } = useSWR(tmdbQuery, fetcher);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center px-6">
|
||||
<h2 className="text-2xl font-medium">Rate a movie</h2>
|
||||
<div className="max-w-full w-[600px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Find a movie..."
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="mt-12 bg-gray-700 rounded-full py-2 px-4 mb-12 w-full"
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <ThreeDots />}
|
||||
{!isLoading && !data?.length && <p>Could not find a movie 😬</p>}
|
||||
{!isLoading && !!data?.length && (
|
||||
<section className="flex flex-wrap gap-x-6 gap-y-9 justify-center">
|
||||
{data.slice(0, 12).map((movie) => (
|
||||
<RateMovie movie={movie} key={movie.id} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Rate;
|
||||
62
frontend/src/pages/Recommendations.tsx
Normal file
62
frontend/src/pages/Recommendations.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useAuthState } from 'react-firebase-hooks/auth';
|
||||
import { ThreeDots } from 'react-loader-spinner';
|
||||
import useSWR, { Fetcher } from 'swr';
|
||||
import Recommendation from '../components/Recommendation';
|
||||
import Refresh from '../components/Refresh';
|
||||
import { auth } from '../firebase';
|
||||
import IRecommendation from '../types/Recommendation';
|
||||
|
||||
const fetcher: Fetcher<IRecommendation[], string> = async (userId) => {
|
||||
const res = {
|
||||
data: {
|
||||
userId,
|
||||
recommendations: [
|
||||
{ movieId: 174, match: 78 },
|
||||
{ movieId: 70, match: 74 },
|
||||
{ movieId: 421, match: 70 },
|
||||
{ movieId: 5846, match: 54 },
|
||||
{ movieId: 926, match: 48 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await new Promise((res) => setTimeout(res, 2500));
|
||||
|
||||
return res.data.recommendations as IRecommendation[];
|
||||
};
|
||||
|
||||
function Recommendations() {
|
||||
const [user] = useAuthState(auth);
|
||||
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
const { data, isLoading, isValidating, mutate } = useSWR(user.uid, fetcher);
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center px-6 flex-1">
|
||||
<h2 className="text-2xl font-medium">Recommendations</h2>
|
||||
{isLoading && (
|
||||
<div className="my-auto flex flex-col items-center gap-2">
|
||||
<ThreeDots />
|
||||
<p className="italic">Loading recommendations...</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && <Refresh mutate={mutate} isValidating={isValidating} />}
|
||||
{!isLoading && !data?.length && (
|
||||
<p>Could not find any recommendations for you 😬</p>
|
||||
)}
|
||||
{!isLoading && !!data?.length && (
|
||||
<section className="flex flex-wrap gap-x-6 gap-y-9 justify-center">
|
||||
{data.map((recommendation) => (
|
||||
<Recommendation
|
||||
recommendation={recommendation}
|
||||
key={recommendation.movieId}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Recommendations;
|
||||
6
frontend/src/types/Recommendation.tsx
Normal file
6
frontend/src/types/Recommendation.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
interface Recommendation {
|
||||
movieId: number;
|
||||
match: number;
|
||||
}
|
||||
|
||||
export default Recommendation;
|
||||
18
frontend/src/types/TMDBMovie.ts
Normal file
18
frontend/src/types/TMDBMovie.ts
Normal file
@ -0,0 +1,18 @@
|
||||
interface TMDBMovie {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export default TMDBMovie;
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
9
frontend/tailwind.config.ts
Normal file
9
frontend/tailwind.config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user