@ -0,0 +1,9 @@ |
|||
/backend/app/uploads/ |
|||
# Ignore all files in the uploads directory |
|||
|
|||
/frontend/node_modules |
|||
/backend/node_modules |
|||
# Ignore node_modules directories in both frontend and backend |
|||
|
|||
/frontend/dist |
|||
# Ignore the build output directory for the frontend |
|||
@ -1,436 +1,502 @@ |
|||
# FreeTube - Video Sharing Platform |
|||
|
|||
 |
|||
|
|||
## 📋 Table of Contents |
|||
- [Overview](#overview) |
|||
- [Features](#features) |
|||
- [Tech Stack](#tech-stack) |
|||
- [Prerequisites](#prerequisites) |
|||
- [Installation](#installation) |
|||
- [Configuration](#configuration) |
|||
- [Usage](#usage) |
|||
- [API Documentation](#api-documentation) |
|||
- [Project Structure](#project-structure) |
|||
- [Development](#development) |
|||
- [Testing](#testing) |
|||
- [Troubleshooting](#troubleshooting) |
|||
- [Contributing](#contributing) |
|||
|
|||
## 🎯 Overview |
|||
|
|||
FreeTube is a modern video sharing platform built as a YouTube competitor. It allows users to upload, watch, and interact with videos through comments and likes. The platform features user authentication, video management, channel subscriptions, and a recommendation system. |
|||
|
|||
This project is part of a 3-part development resit assignment focusing on: |
|||
- **Part 1**: HTTP server serving HTML pages |
|||
- **Part 2**: REST API for video, user, and comment management |
|||
- **Part 3**: Interactive frontend user interface |
|||
|
|||
## ✨ Features |
|||
|
|||
### 🔐 Authentication System |
|||
- User registration with profile picture upload |
|||
- Secure login with JWT tokens |
|||
- Persistent sessions with localStorage |
|||
- Protected routes and authentication guards |
|||
- Profile management and display |
|||
|
|||
### 📹 Video Management |
|||
- Video upload with thumbnail generation |
|||
- Video streaming and playback |
|||
- Video metadata management |
|||
- Video search and filtering |
|||
|
|||
### 👥 User Features |
|||
- User profiles with customizable avatars |
|||
- Channel creation and management |
|||
- Subscription system |
|||
- User activity tracking |
|||
|
|||
### 💬 Social Features |
|||
- Video commenting system |
|||
- Like/dislike functionality |
|||
- Video recommendations |
|||
- Trending videos section |
|||
|
|||
### 🎨 Frontend Features |
|||
- Responsive React-based UI |
|||
- Modern design with Tailwind CSS |
|||
- Client-side routing with React Router |
|||
- Real-time updates and interactions |
|||
|
|||
## 🛠 Tech Stack |
|||
|
|||
### Backend |
|||
- **Runtime**: Node.js |
|||
- **Framework**: Express.js |
|||
- **Database**: PostgreSQL |
|||
- **Authentication**: JWT (JSON Web Tokens) |
|||
- **File Upload**: Multer |
|||
- **Testing**: Vitest |
|||
|
|||
### Frontend |
|||
- **Framework**: React 18 |
|||
- **Build Tool**: Vite |
|||
- **Styling**: Tailwind CSS |
|||
- **Routing**: React Router |
|||
- **State Management**: React Context API |
|||
- **Fonts**: Montserrat, Inter |
|||
|
|||
### Infrastructure |
|||
- **Containerization**: Docker & Docker Compose |
|||
- **Reverse Proxy**: Nginx |
|||
- **Development**: Hot reload for both frontend and backend |
|||
|
|||
## 📋 Prerequisites |
|||
|
|||
Before you begin, ensure you have the following installed: |
|||
- [Docker](https://docs.docker.com/get-docker/) (v20.10+) |
|||
- [Docker Compose](https://docs.docker.com/compose/install/) (v2.0+) |
|||
- [Git](https://git-scm.com/downloads) |
|||
|
|||
## 🚀 Installation |
|||
|
|||
### Quick Start with Docker (Recommended) |
|||
|
|||
1. **Clone the repository** |
|||
```bash |
|||
git clone <repository-url> |
|||
cd 3RESIT_DOCKER |
|||
``` |
|||
|
|||
2. **Set up environment variables** |
|||
Create a `.env` file in the root directory: |
|||
```env |
|||
# Database Configuration |
|||
POSTGRES_USER=freetube_user |
|||
POSTGRES_PASSWORD=your_secure_password |
|||
POSTGRES_DB=freetube_db |
|||
POSTGRES_HOST=db |
|||
|
|||
# Backend Configuration |
|||
BACKEND_PORT=8000 |
|||
JWT_SECRET=your_jwt_secret_key_min_32_chars |
|||
LOG_FILE=/var/log/freetube/access.log |
|||
``` |
|||
|
|||
3. **Build and start the application** |
|||
```bash |
|||
docker-compose up --build |
|||
``` |
|||
|
|||
4. **Access the application** |
|||
- Frontend: http://localhost |
|||
- Backend API: http://localhost:8000 |
|||
- Database: localhost:5432 |
|||
|
|||
### Manual Installation |
|||
|
|||
<details> |
|||
<summary>Click to expand manual installation steps</summary> |
|||
|
|||
#### Backend Setup |
|||
```bash |
|||
cd backend |
|||
npm install |
|||
npm run dev |
|||
``` |
|||
|
|||
#### Frontend Setup |
|||
```bash |
|||
cd frontend |
|||
npm install |
|||
npm run dev |
|||
``` |
|||
## Sommaire |
|||
|
|||
1) Introduction |
|||
2) Description du projet |
|||
3) La pile technologique |
|||
1) Le serveur |
|||
2) Le site web |
|||
3) La base de données |
|||
4) Le serveur |
|||
1) Les dépendances |
|||
2) Le fonctionnement |
|||
5) Le site web |
|||
1) Les dépendances |
|||
2) Le fonctionnement |
|||
6) La base de données |
|||
7) Installation du projet |
|||
1) Docker Compose |
|||
2) Script Shell |
|||
3) Manuellement |
|||
8) Conclusion |
|||
|
|||
## Introduction |
|||
|
|||
Cette documentation est à destination des futurs développeurs travaillant sur Freetube. Elle a pour but d’expliquer le fonctionnement technique de toutes les couches de l’application et à justifier les choix pris pour chaque langage et framework. |
|||
|
|||
La documentation est une ressource indispensable pour tous ceux voulant comprendre le fonctionnement interne de Freetube. Les parties démontrées ici couvrent toutes les couches de l’application. Par ailleurs des diagrammes UML et schémas de base de données vous sont fournies pour une meilleure compréhension du code. Un indexe est disponible en fin de document. |
|||
|
|||
Cette documentation est à un but technique, elle rentre volontairement dans les détails du fonctionnement de chaque partie. Elle n’est pas faite pour être lu par un utilisateur, un manuel utilisateur vous a été fournies pour remplir ce besoin. |
|||
|
|||
De plus les documentations externes sont disponible en fin de document et un **Swagger** est présent sur l'endpoint `/api/api-docs` pour une documentation de l'API plus approfondie. |
|||
|
|||
## Description du projet |
|||
|
|||
Il m’a été demandé de créer une plateforme concurrente à YouTube nommée Freetube. Cette alternative a pour but de mieux remplir les demandes des utilisateurs, pas d’abonnement ni publicité pour consommer ou poster des vidéos sur la plateforme. |
|||
|
|||
Le cahier des charges du projet demande certaines fonctionnalités à implémenter. Les utilisateurs doivent pourvoir regarder des vidéos sans avoir à se connecter où à créer de compte, ils doivent pouvoir créer un compte et le gérer, pouvoir créer une chaîne la gérer et y poster des vidéos. La fonctionnalité de vidéo privé a aussi été demandé. |
|||
|
|||
Pour ce projet j’avais la main libre sur la pile technologique à utiliser tout en respectant les demandes d’efficacité d’une plateforme de streaming vidéo. |
|||
|
|||
## La pile technologique |
|||
|
|||
### Le serveur |
|||
|
|||
Le serveur a été codé en **Nodejs**, j’ai choisi ce langage car il permet d’implémenter une **API REST** efficacement grâce à son implémentation native de l’asynchrone indispensable pour une API REST. NodeJS étant basé sur **Javascript** il n’est pas le plus efficient mais ce n’est pas dérangeant car nous travaillons avec une API, le temps de réponse sera biaisé par la connexion internet de l’utilisateur. |
|||
|
|||
### Le site web |
|||
|
|||
Le site web a lui été codé en **ReactJS** une librairie Javascript permettant de créer des interfaces utilisateur. ReactJS étant lui aussi basé sur Javascript, cela permet une maintenabilité plus simple car les deux parties sont dans le même langages, de plus le site web bénéficie lui aussi de l’implémentation de l’asynchrone. J’ai choisi d’utiliser ReactJS car il permet l’utilisation de **components** permettant le live reload et évite la duplication de code inutile. |
|||
|
|||
### La base de données |
|||
|
|||
#### Database Setup |
|||
- Install PostgreSQL |
|||
- Create database and user |
|||
- Run migrations (if available) |
|||
</details> |
|||
La base de données est en **PostgreSQL**, un langage basé sur **SQL** largement utilisé pour communiquer avec une base de données. Cependant PostgreSQL possède quelques avantages par rapport à une base de données comme **MySQL**, il intègre une très bonne gestion du **JSON** que j’ai beaucoup utilisé dans ce projet et il est **Open Source**. |
|||
|
|||
## ⚙️ Configuration |
|||
## Le serveur |
|||
|
|||
### Environment Variables |
|||
### Les dépendances |
|||
|
|||
| Variable | Description | Default | Required | |
|||
|----------|-------------|---------|----------| |
|||
| `POSTGRES_USER` | Database username | - | ✅ | |
|||
| `POSTGRES_PASSWORD` | Database password | - | ✅ | |
|||
| `POSTGRES_DB` | Database name | - | ✅ | |
|||
| `POSTGRES_HOST` | Database host | db | ✅ | |
|||
| `BACKEND_PORT` | Backend server port | 8000 | ✅ | |
|||
| `JWT_SECRET` | JWT signing secret | - | ✅ | |
|||
| `LOG_FILE` | Log file path | /var/log/freetube/access.log | ❌ | |
|||
Le serveur NodeJS sert de plateforme entre le site web et la base de données, il doit donc pouvoir recevoir des requêtes HTTP et envoyer des requêtes SQL. Pour les requêtes HTTP j’ai choisis le framework **ExpressJS** car il est très connu et a donc beaucoup de contenu disponible sur internet. Pour les requêtes SQL j'ai utilisé la librairie **pg** qui permet de communiquer avec PostgreSQL, cette librairie peux gérer les fermetures de connexion inutile comme les time out ou les oublies. |
|||
|
|||
### Docker Volumes |
|||
Puisque l’API transmet des données sensibles j’ai dû sécuriser les endpoints en vérifiant les données entrantes, pour cela j’ai utilisé **express-validator** qui permet de créer des **middlewares** pour vérifier les données. Pour les images et les fichiers vidéo j’ai utilisé **multer** qui s’intègre très bien avec ExpressJS. Pour l’authentification par **Github** j’ai choisi de passé par **PassportJS** qui s’occupe du passage de token entre GitHub et le backend. |
|||
|
|||
The application uses the following volumes: |
|||
- `./backend/logs:/var/log/freetube` - Application logs |
|||
- `./backend/app/uploads:/app/app/uploads` - Uploaded files (videos, images) |
|||
- Database data volume for PostgreSQL persistence |
|||
Pour la gestion de l’authentification j’ai utilisé **Json Web Token** qui permet de générer et de vérifier des tokens d’authentifications. Pour l’encryption des mots de passe **Bcrypt** a été utilisé. |
|||
|
|||
## 📖 Usage |
|||
### Le fonctionnement |
|||
|
|||
### Getting Started |
|||
Chaque endpoints et diviser en trois parties distinctes, la définition de la route, qui va définir l’URL à appeler, les middlewares qui vont effectuer les vérifications et modifications des données avant leur utilisation, et les controllers qui vont faire les actions (appels de base données, déplacement de fichier...). Des diagrammes UML expliquant les routes unes-à-unes est disponible dans le dossier “Diagramme_UML”. |
|||
|
|||
1. **Create an Account** |
|||
- Navigate to http://localhost |
|||
- Click "Créer un compte" |
|||
- Fill in your details and optionally upload a profile picture |
|||
- Submit the form to register and automatically log in |
|||
Chaque endpoints qui doivent être protégées par l’utilisation d’un compte utilisent le middleware “auth.middleware.js” pour vérifier la validité d’un token. |
|||
|
|||
2. **Login** |
|||
- Click "Se connecter" on the homepage |
|||
- Enter your username and password |
|||
- You'll be redirected to the authenticated homepage |
|||
## Le site web |
|||
|
|||
3. **Upload Videos** |
|||
- Use the API endpoints to upload videos (see API documentation) |
|||
- Videos are stored in the uploads directory |
|||
### Les dépendances |
|||
|
|||
4. **Browse Content** |
|||
- View recommendations on the homepage |
|||
- Search for videos using the search bar |
|||
- Browse trending videos and top creators |
|||
Le site web ne doit faire aucun calcul, tout passe donc par des requête HTTP, j'ai donc utilisé la librairie **fetch** intégré à NodeJS dans sa version 22. |
|||
|
|||
### Authentication Flow |
|||
Pour l’hébergement j’ai choisi **NGINX** car il permet de d’héberger un site et de faire des redirections, il m’a permis de rediriger les requêtes vers l’API en passant par la route “/api/” ce qui évite d’exposer des ports inutilement. |
|||
|
|||
The authentication system works as follows: |
|||
1. User registers/logs in through the frontend forms |
|||
2. Backend validates credentials and returns JWT token |
|||
3. Token is stored in localStorage for persistence |
|||
4. Protected routes check authentication status |
|||
5. Navbar updates to show user profile and logout option |
|||
NGINX permet aussi de mettre en place l’**HTTPS** avec des **certificats SSL** ce qui chiffre les requêtes du site et de l'API. |
|||
|
|||
## 📚 API Documentation |
|||
Le site fonctionnant avec ReactJS nécessite l’utilisation de **Vite** pour le développement et le déploiement. |
|||
|
|||
### Authentication Endpoints |
|||
Freetube est un site multi-page et doit utiliser un système de routage. Pour cela j’ai utilisé **React Router 7** car il est très utilisé et donc très bien documenté. |
|||
|
|||
#### Register User |
|||
```http |
|||
POST /api/users/ |
|||
Content-Type: multipart/form-data |
|||
Pour la partie style du site web, j'ai utilisé TailwindCSS dans sa version 4.0. Tailwind permet de créer des classes CSS directement depuis le JSX et prend en charge le responsive grâce a des **breakpoints**. Il est notamment plus léger que ses concurrents car il créer ses classes CSS au moment du build contrairement, par exemple, a Bootstrap qui à besoin d'un fichier contenant toue les classes CSS de la librairie pour fonctionner. |
|||
|
|||
email: user@example.com |
|||
username: johndoe |
|||
password: securepassword |
|||
picture: [file upload] |
|||
### Le fonctionnement |
|||
|
|||
Les éléments du site sont divisés en plusieurs parties, les pages sont dans le dossier “/src/pages” et servent à accueillir et à mettre en forme les composants et à appeler les services. |
|||
|
|||
Les composants dans le dossier “/src/components” servent à diviser le code et à éviter la duplication, un composant peut être appelé plusieurs fois sur plusieurs pages différentes. Les composants ne font pas d'appel aux services, les événements liés aux composants sont passer en paramètre de ces dernier. |
|||
|
|||
Les modales sont dans le dossier “/src/modals” et sont toujours afficher au-dessus de la vue principale. Comme les composants elles ne fonts aucun appels aux services, les événements liés aux modales sont passer en paramètre de ces derniers. Elles sont toujours appelées en fin de fichier. |
|||
|
|||
Les services présent dans le dossier “/src/services” sont les seuls fichiers faisant appel à l’API (à l’exception du fichier AuthContext.jsx). Les services sont organisés de la même manière que les endpoints. Un service peut être appelé plusieurs fois dans plusieurs pages. |
|||
|
|||
Les routes utilisées par React Router sont présente dans le fichier “/src/routes/route.jsx”. Les routes ayant besoin d’un compte sont protégées par “ProtectedRoute” et rédigeront automatiquement à la page de connexion. React Router n’étant pas directement compatible avec le système de NGINX une configuration supplémentaire est nécessaire, elle est détaillée dans le fichier “/nginx/default.conf”. |
|||
|
|||
## La base de données |
|||
|
|||
La structure de la base de données est créée automatiquement par le serveur au lancement, chaque modification effectuée doit être modifié dans le fichier `/backend/src/utils/database.js` dans la fonction `initDb()`. |
|||
|
|||
La base de données étant relationnelle, elle repose sur beaucoup de clé étrangère détaillés dans le schéma fourni. A savoir que les enfants se détruisent automatiquement si le lien parent est supprimé grâce à la condition “ON CASCADE” présente dans chacun des liens. |
|||
|
|||
Le port de la base de données (5432 par defaut) ne doit jamais être exposé sans pare-feu, seul le serveur doit y avoir accès. Pour cela PostgreSQL propose deux fichiers de configuration. `pg_hba` créer des règles internes en fonction de l’utilisateur, la base cible et l’IP du client et `postgres.conf` qui permet de définir un schéma d’IP autorisé. A savoir que si le projet et lancé via Docker seul localhost peut avoir accès à cette base de données. |
|||
|
|||
## Installation et lancement |
|||
|
|||
**Ces instructions sont prévues pour un serveur tournant sous Ubuntu 24.04/Debian 12.** Par conséquent certaines commandes peuvent être incompatible avec votre système, cependant cotre système d'exploitation fournis des commandes alternatives. |
|||
|
|||
Freetube peut être installé de trois manière différentes : |
|||
- Docker Compose |
|||
- Script Shell |
|||
- Manuellement |
|||
|
|||
### Installation avec Docker Compose |
|||
|
|||
#### Installer Docker et Docker Compose |
|||
|
|||
De part la [documentation officielle de Docker](https://docs.docker.com/engine/install/ubuntu/) |
|||
```bash |
|||
# Add Docker's official GPG key: |
|||
sudo apt-get update |
|||
sudo apt-get install ca-certificates curl |
|||
sudo install -m 0755 -d /etc/apt/keyrings |
|||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc |
|||
sudo chmod a+r /etc/apt/keyrings/docker.asc |
|||
|
|||
# Add the repository to Apt sources: |
|||
echo \ |
|||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ |
|||
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \ |
|||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null |
|||
sudo apt-get update |
|||
|
|||
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin |
|||
``` |
|||
|
|||
#### Login User |
|||
```http |
|||
POST /api/users/login |
|||
Content-Type: application/json |
|||
#### Paramétrages NGINX |
|||
Quelque modification doivent être faites pour le fonctionnement de NGINX |
|||
```nginx |
|||
server { |
|||
#------------------------------ ici ------------------------------- |
|||
server_name <url du serveur>; |
|||
#------------------------------------------------------------------ |
|||
listen 80; |
|||
return 301 https://$host$request_uri; |
|||
} |
|||
|
|||
server { |
|||
#------------------------------ ici ------------------------------- |
|||
server_name <url du serveur>; |
|||
#------------------------------------------------------------------ |
|||
listen 443 ssl; |
|||
|
|||
root /usr/share/nginx/html; |
|||
index index.html index.htm; |
|||
|
|||
client_max_body_size 500M; |
|||
|
|||
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt; |
|||
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key; |
|||
|
|||
ssl_protocols TLSv1.2 TLSv1.3; |
|||
ssl_ciphers HIGH:!aNULL:!MD5; |
|||
|
|||
location /api/ { |
|||
if ($request_method = 'OPTIONS') { |
|||
add_header 'Access-Control-Allow-Origin' '$http_origin' always; |
|||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; |
|||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always; |
|||
add_header 'Access-Control-Allow-Credentials' 'true' always; |
|||
add_header 'Access-Control-Max-Age' 1728000 always; |
|||
add_header 'Content-Type' 'text/plain; charset=utf-8' always; |
|||
add_header 'Content-Length' 0 always; |
|||
return 204; |
|||
} |
|||
|
|||
proxy_pass http://resit_backend:8000; |
|||
proxy_set_header Host $host; |
|||
proxy_set_header X-Real-IP $remote_addr; |
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|||
proxy_set_header X-Forwarded-Proto $scheme; |
|||
proxy_set_header Origin $http_origin; |
|||
proxy_buffering off; |
|||
|
|||
add_header 'Access-Control-Allow-Origin' '$http_origin' always; |
|||
add_header 'Access-Control-Allow-Credentials' 'true' always; |
|||
|
|||
proxy_read_timeout 300s; |
|||
proxy_send_timeout 300s; |
|||
} |
|||
|
|||
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg)$ { |
|||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; |
|||
add_header Pragma "no-cache"; |
|||
add_header Expires "0"; |
|||
try_files $uri =404; |
|||
} |
|||
|
|||
location / { |
|||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; |
|||
try_files $uri $uri/ /index.html; |
|||
} |
|||
|
|||
{ |
|||
"username": "johndoe", |
|||
"password": "securepassword" |
|||
} |
|||
``` |
|||
|
|||
### Media Endpoints |
|||
#### Création de clé d'API Gmail |
|||
|
|||
Rendez-vous sur [Gmail](https://gmail.com) et allez dans le panel d'administration de votre compte |
|||
Dans la barre de recherche tapez `Mot de passe des applications` |
|||
Créer un mot de passe et gardez le de côté il servira pour les variables d'environnements |
|||
|
|||
#### Création Application OAuth Github |
|||
|
|||
Rendez-vous sur [Github](https://github.com) et allez dans les paramètres de votre compte |
|||
En bas du menu à gauche, cliquez sur `Paramètres de développeur` puis cliquez sur `Application OAuth` |
|||
Créez une nouvelle application et gardez les clé de côté, elle serviront pour les variables d'environnements |
|||
|
|||
#### Get Profile Picture |
|||
```http |
|||
GET /api/media/profile/{filename} |
|||
#### Mise en place des variables d'environnements |
|||
|
|||
A la racine du projet créer un fichier `.env` |
|||
```bash |
|||
touch .env |
|||
``` |
|||
|
|||
#### Get Video Thumbnail |
|||
```http |
|||
GET /api/media/thumbnail/{filename} |
|||
A l'aide de l'éditeur de votre choix entrez dans le fichier |
|||
``` |
|||
nano .env |
|||
``` |
|||
|
|||
### Additional Endpoints |
|||
Rentrez les informations dans ce format |
|||
/!\ les valeurs non-entourées de chevrons **ne doivent pas être modifié**. |
|||
```env |
|||
POSTGRES_USER=<utilisateur_de_la_base> |
|||
POSTGRES_PASSWORD=<mot_de_passe> |
|||
POSTGRES_DB=<nom_de_la_base> |
|||
POSTGRES_HOST=db |
|||
|
|||
- **Videos**: `/api/videos/` |
|||
- **Comments**: `/api/comments/` |
|||
- **Channels**: `/api/channels/` |
|||
- **Playlists**: `/api/playlists/` |
|||
- **Recommendations**: `/api/recommendations/` |
|||
BACKEND_PORT=8000 |
|||
|
|||
For detailed API documentation, check the `.http` files in the `backend/requests/` directory. |
|||
JWT_SECRET=<votre_clé_JWT> |
|||
|
|||
## 📁 Project Structure |
|||
LOG_FILE=/var/log/freetube/access.log |
|||
|
|||
``` |
|||
3RESIT_DOCKER/ |
|||
├── backend/ # Node.js Express backend |
|||
│ ├── app/ |
|||
│ │ ├── controllers/ # Request handlers |
|||
│ │ ├── middlewares/ # Express middlewares |
|||
│ │ ├── routes/ # API route definitions |
|||
│ │ ├── uploads/ # File storage |
|||
│ │ └── utils/ # Utility functions |
|||
│ ├── logs/ # Application logs |
|||
│ ├── requests/ # HTTP request examples |
|||
│ └── test/ # Test files |
|||
├── frontend/ # React frontend |
|||
│ ├── src/ |
|||
│ │ ├── components/ # Reusable React components |
|||
│ │ ├── contexts/ # React Context providers |
|||
│ │ ├── pages/ # Page components |
|||
│ │ ├── routes/ # Route configuration |
|||
│ │ └── assets/ # Static assets |
|||
│ └── public/ # Public assets |
|||
├── nginx/ # Nginx configuration |
|||
└── docker-compose.yaml # Docker orchestration |
|||
``` |
|||
GMAIL_USER=<adresse e-mail> |
|||
GMAIL_PASSWORD=<mot_de_passe_créer_precedemment> |
|||
|
|||
FRONTEND_URL=<URL_HTTPS_de_votre_nginx> |
|||
|
|||
## 🔧 Development |
|||
GITHUB_ID=<ID_github> |
|||
|
|||
### Available Scripts |
|||
GITHUB_SECRET=<secret_github> |
|||
|
|||
#### Backend |
|||
|
|||
#### Lancement |
|||
|
|||
Pour lancer le groupe de conteneur |
|||
```bash |
|||
npm run dev # Start development server with hot reload |
|||
npm run start # Start production server |
|||
npm run test # Run tests |
|||
docker compose up -d # pour détacher de la session |
|||
``` |
|||
|
|||
#### Frontend |
|||
### Installation via le Script Shell |
|||
|
|||
#### Ajout des autorisations |
|||
|
|||
Pour ajouter les autorisations nécessaire au lancement du script |
|||
```bash |
|||
npm run dev # Start development server |
|||
npm run build # Build for production |
|||
npm run preview # Preview production build |
|||
npm run lint # Run ESLint |
|||
chmod +x ./deploy.sh |
|||
``` |
|||
|
|||
#### Docker Commands |
|||
```bash |
|||
# Start all services |
|||
docker-compose up --build |
|||
#### Création de clé d'API Gmail |
|||
|
|||
# Stop all services |
|||
docker-compose down |
|||
Rendez-vous sur [Gmail](https://gmail.com) et allez dans le panel d'administration de votre compte |
|||
Dans la barre de recherche tapez `Mot de passe des applications` |
|||
Créer un mot de passe et gardez le de côté il servira pour les variables d'environnements |
|||
|
|||
# View logs |
|||
docker-compose logs [service-name] |
|||
#### Création Application OAuth Github |
|||
|
|||
# Restart specific service |
|||
docker-compose restart [service-name] |
|||
Rendez-vous sur [Github](https://github.com) et allez dans les paramètres de votre compte |
|||
En bas du menu à gauche, cliquez sur `Paramètres de développeur` puis cliquez sur `Application OAuth` |
|||
Créez une nouvelle application et gardez les clé de côté, elle serviront pour les variables d'environnements |
|||
|
|||
# Reset database |
|||
docker-compose down --volumes |
|||
#### Lancer l'installation |
|||
|
|||
Lancer le script et répondez au question |
|||
```bash |
|||
./deploy.sh |
|||
``` |
|||
|
|||
Lancer le projet |
|||
```bash |
|||
cd backend && npm run start |
|||
cd frontend && npx vite build |
|||
|
|||
systemctl enable --now nginx # Pour un démarrage automatique au lancement de la machine |
|||
systemctl enable --now postgresql |
|||
``` |
|||
|
|||
### Development Workflow |
|||
### Installation manuelle |
|||
|
|||
1. **Backend Changes**: Automatically reload with nodemon |
|||
2. **Frontend Changes**: Hot module replacement with Vite |
|||
3. **Database Changes**: Restart containers to apply schema changes |
|||
4. **Nginx Changes**: Restart nginx service |
|||
#### Création de clé d'API Gmail |
|||
|
|||
### File Upload Testing |
|||
Rendez-vous sur [Gmail](https://gmail.com) et allez dans le panel d'administration de votre compte |
|||
Dans la barre de recherche tapez `Mot de passe des applications` |
|||
Créer un mot de passe et gardez le de côté il servira pour les variables d'environnements |
|||
|
|||
Use the provided `.http` files in `backend/requests/` to test API endpoints: |
|||
- `user.http` - User registration and authentication |
|||
- `video.http` - Video management |
|||
- `medias.http` - Media file serving |
|||
- `comment.http` - Comment system |
|||
#### Création Application OAuth Github |
|||
|
|||
## 🧪 Testing |
|||
Rendez-vous sur [Github](https://github.com) et allez dans les paramètres de votre compte |
|||
En bas du menu à gauche, cliquez sur `Paramètres de développeur` puis cliquez sur `Application OAuth` |
|||
Créez une nouvelle application et gardez les clé de côté, elle serviront pour les variables d'environnements |
|||
|
|||
### Running Tests |
|||
#### Mise en place des variables d'environnements |
|||
|
|||
A la racine du projet créer un fichier `.env` |
|||
```bash |
|||
# Backend tests |
|||
cd backend |
|||
npm test |
|||
touch .env |
|||
``` |
|||
|
|||
# Frontend tests (if configured) |
|||
cd frontend |
|||
npm test |
|||
A l'aide de l'éditeur de votre choix entrez dans le fichier |
|||
``` |
|||
nano .env |
|||
``` |
|||
|
|||
Rentrez les informations dans ce format |
|||
/!\ les valeurs non-entourées de chevrons **ne doivent pas être modifié**. |
|||
```env |
|||
POSTGRES_USER=<utilisateur_de_la_base> |
|||
POSTGRES_PASSWORD=<mot_de_passe> |
|||
POSTGRES_DB=<nom_de_la_base> |
|||
POSTGRES_HOST=db |
|||
|
|||
### Test Structure |
|||
BACKEND_PORT=8000 |
|||
|
|||
- **Unit Tests**: Individual component/function testing |
|||
- **Integration Tests**: API endpoint testing |
|||
- **E2E Tests**: Full application workflow testing |
|||
JWT_SECRET=<votre_clé_JWT> |
|||
|
|||
Current test coverage includes: |
|||
- User authentication |
|||
- Video management |
|||
- Comment system |
|||
- Channel operations |
|||
- Playlist functionality |
|||
LOG_FILE=/var/log/freetube/access.log |
|||
|
|||
## 🔍 Troubleshooting |
|||
GMAIL_USER=<adresse e-mail> |
|||
GMAIL_PASSWORD=<mot_de_passe_créer_precedemment> |
|||
|
|||
### Common Issues |
|||
FRONTEND_URL=<URL_HTTPS_de_votre_nginx> |
|||
|
|||
#### Authentication Problems |
|||
- **Blank screen on reload**: Check browser console for context errors |
|||
- **Login not persisting**: Verify JWT token in localStorage |
|||
- **Registration fails**: Check file upload size limits |
|||
GITHUB_ID=<ID_github> |
|||
|
|||
#### Media File Issues |
|||
- **404 on images**: Verify nginx proxy configuration |
|||
- **Upload fails**: Check file permissions and upload directory |
|||
GITHUB_SECRET=<secret_github> |
|||
``` |
|||
|
|||
#### Docker Issues |
|||
- **Containers won't start**: Check port conflicts |
|||
- **Database connection fails**: Verify environment variables |
|||
- **Build failures**: Clear Docker cache with `docker system prune` |
|||
#### Installation des paquets |
|||
|
|||
### Debug Commands |
|||
Pour installer PostgreSQL/NGINX |
|||
```bash |
|||
apt install nginx postgresql |
|||
``` |
|||
|
|||
Pour installer NodeJS de part la [documentation officielle](https://nodejs.org/en/download/) |
|||
```bash |
|||
# Check container status |
|||
docker-compose ps |
|||
# Download and install nvm: |
|||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash |
|||
|
|||
# View service logs |
|||
docker-compose logs -f [service-name] |
|||
# in lieu of restarting the shell |
|||
\. "$HOME/.nvm/nvm.sh" |
|||
|
|||
# Access container shell |
|||
docker-compose exec [service-name] /bin/bash |
|||
# Download and install Node.js: |
|||
nvm install 22 |
|||
|
|||
# Reset everything |
|||
docker-compose down --volumes --rmi all |
|||
docker system prune -a |
|||
# Verify the Node.js version: |
|||
node -v # Should print "v22.19.0". |
|||
nvm current # Should print "v22.19.0". |
|||
|
|||
# Verify npm version: |
|||
npm -v # Should print "10.9.3". |
|||
``` |
|||
|
|||
### Performance Optimization |
|||
#### Installations des dépendances NodeJS |
|||
|
|||
- Enable nginx caching for static assets |
|||
- Implement image optimization for uploads |
|||
- Use CDN for media file delivery |
|||
- Database query optimization |
|||
- Frontend code splitting |
|||
Pour le serveur |
|||
```bash |
|||
cd backend && npm i --production |
|||
``` |
|||
|
|||
## 🤝 Contributing |
|||
Pour le site web |
|||
```bash |
|||
cd frontend && npm i --production |
|||
npx vite build # pour la construction du site |
|||
``` |
|||
|
|||
1. Fork the repository |
|||
2. Create a feature branch (`git checkout -b feature/amazing-feature`) |
|||
3. Commit your changes (`git commit -m 'Add amazing feature'`) |
|||
4. Push to the branch (`git push origin feature/amazing-feature`) |
|||
5. Open a Pull Request |
|||
#### Configuration de NGINX |
|||
|
|||
### Code Style |
|||
Dans `/etc/nginx/conf.d/` ajouter le fichier `freetube.conf` avec cette configuration |
|||
```nginx |
|||
server { |
|||
server_name <url du serveur>; |
|||
listen 80; |
|||
return 301 https://$host$request_uri; |
|||
} |
|||
|
|||
- **Backend**: ESLint with Node.js rules |
|||
- **Frontend**: ESLint with React rules |
|||
- **Formatting**: Prettier for consistent code style |
|||
- **Commits**: Conventional commit messages |
|||
server { |
|||
server_name <url du serveur>; |
|||
listen 443 ssl; |
|||
|
|||
root /usr/share/nginx/html; |
|||
index index.html index.htm; |
|||
|
|||
client_max_body_size 500M; |
|||
|
|||
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt; |
|||
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key; |
|||
|
|||
ssl_protocols TLSv1.2 TLSv1.3; |
|||
ssl_ciphers HIGH:!aNULL:!MD5; |
|||
|
|||
location /api/ { |
|||
if ($request_method = 'OPTIONS') { |
|||
add_header 'Access-Control-Allow-Origin' '$http_origin' always; |
|||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; |
|||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always; |
|||
add_header 'Access-Control-Allow-Credentials' 'true' always; |
|||
add_header 'Access-Control-Max-Age' 1728000 always; |
|||
add_header 'Content-Type' 'text/plain; charset=utf-8' always; |
|||
add_header 'Content-Length' 0 always; |
|||
return 204; |
|||
} |
|||
|
|||
proxy_pass http://resit_backend:8000; |
|||
proxy_set_header Host $host; |
|||
proxy_set_header X-Real-IP $remote_addr; |
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|||
proxy_set_header X-Forwarded-Proto $scheme; |
|||
proxy_set_header Origin $http_origin; |
|||
proxy_buffering off; |
|||
|
|||
add_header 'Access-Control-Allow-Origin' '$http_origin' always; |
|||
add_header 'Access-Control-Allow-Credentials' 'true' always; |
|||
|
|||
proxy_read_timeout 300s; |
|||
proxy_send_timeout 300s; |
|||
} |
|||
|
|||
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg)$ { |
|||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; |
|||
add_header Pragma "no-cache"; |
|||
add_header Expires "0"; |
|||
try_files $uri =404; |
|||
} |
|||
|
|||
location / { |
|||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; |
|||
try_files $uri $uri/ /index.html; |
|||
} |
|||
|
|||
## 📜 License |
|||
} |
|||
``` |
|||
|
|||
This project is part of an educational assignment. All rights reserved. |
|||
#### Création de la base de données |
|||
Pour créer l'utilisateur PostgreSQL |
|||
```postgresql |
|||
CREATE ROLE "<nom_utilisateur>" WITH PASSWORD "<mot_de_passe>"; |
|||
``` |
|||
|
|||
Pour créer la base |
|||
```postgresql |
|||
CREATE DATABASE "<nom_de_la_base>" OWNER "<nom_utilisateur"; |
|||
``` |
|||
|
|||
## 👥 Authors |
|||
#### Créer le fichier de log |
|||
Pour créer le fichier de log |
|||
```bash |
|||
mkdir -p /var/log/freetube/ |
|||
touch /var/log/freetube/access.log |
|||
``` |
|||
|
|||
- **Developer**: [Your Name] |
|||
- **Institution**: [Institution Name] |
|||
- **Course**: 3 RESIT - Web Development |
|||
#### Activer les services |
|||
Pour activer et lancer les services |
|||
```bash |
|||
systemctl enable --now postgresql |
|||
systemctl enable --now nginx |
|||
``` |
|||
|
|||
--- |
|||
#### Lancement |
|||
Pour lancer Freetube |
|||
```bash |
|||
cd backend |
|||
npm run start |
|||
``` |
|||
|
|||
## 📞 Support |
|||
## Conclusion |
|||
|
|||
For support and questions: |
|||
- Create an issue in the repository |
|||
- Check the troubleshooting section |
|||
- Review the API documentation |
|||
## Documentations externes |
|||
|
|||
**Happy coding! 🚀** |
|||
[NodeJS](https://nodejs.org/docs/latest/api/) |
|||
[ReactJS](https://react.dev/) |
|||
[Vite](https://vite.dev/guide/) |
|||
[ExpressJS](https://expressjs.com/en/guide/routing.html) |
|||
[NGINX](https://nginx.org/en/docs/) |
|||
[PostgreSQL](https://www.postgresql.org/docs/) |
|||
[Multer](https://www.npmjs.com/package/multer) |
|||
[TailwindCSS v4.0](https://tailwindcss.com/docs/installation/using-vite) |
|||
[PassportJS](https://www.passportjs.org/docs/) |
|||
[Swagger](https://swagger.io/docs/) |
|||
|
|||
@ -1,22 +1,14 @@ |
|||
FROM node:20-alpine |
|||
# Set the working directory |
|||
FROM node:22-alpine |
|||
|
|||
WORKDIR /app |
|||
# Copy package.json and package-lock.json |
|||
|
|||
COPY package*.json ./ |
|||
|
|||
# Install dependencies |
|||
RUN npm install --production |
|||
# Copy the rest of the application code |
|||
COPY . . |
|||
# Expose the port the app runs on |
|||
EXPOSE 8000 |
|||
|
|||
# Install netcat for health checks |
|||
RUN apk add --no-cache netcat-openbsd |
|||
COPY . . |
|||
|
|||
# Install the cli tools |
|||
RUN chmod +x ./freetube.sh |
|||
RUN cp ./freetube.sh /usr/local/bin/freetube |
|||
EXPOSE 8000 |
|||
|
|||
# Start the application |
|||
CMD ["npm", "start"] |
|||
@ -0,0 +1,159 @@ |
|||
import passport from "passport"; |
|||
import { Strategy as GitHubStrategy } from "passport-github2"; |
|||
import { getClient } from "../utils/database.js"; |
|||
import jwt from "jsonwebtoken"; |
|||
|
|||
export async function login(req, res) { |
|||
passport.authenticate("github", { scope: ["user:email"] })(req, res); |
|||
} |
|||
|
|||
export async function callback(req, res, next) { |
|||
passport.authenticate("github", { failureRedirect: "https://localhost/login?error=oauth_failed" }, async (err, user) => { |
|||
if (err) { |
|||
console.error("OAuth error:", err); |
|||
return res.redirect("https://localhost/login?error=oauth_error"); |
|||
} |
|||
|
|||
if (!user) { |
|||
return res.redirect("https://localhost/login?error=oauth_cancelled"); |
|||
} |
|||
|
|||
try { |
|||
// Extract user information from GitHub profile
|
|||
const githubId = user.id; |
|||
const username = user.username || user.login; |
|||
const email = user.emails[0].value || null; |
|||
const avatarUrl = user.photos && user.photos[0] ? user.photos[0].value : null; |
|||
const displayName = user.displayName || username; |
|||
|
|||
console.log(user); |
|||
|
|||
console.log("GitHub user info:", { |
|||
githubId, |
|||
username, |
|||
email, |
|||
displayName, |
|||
avatarUrl |
|||
}); |
|||
|
|||
const client = await getClient(); |
|||
|
|||
// Check if user already exists by GitHub ID
|
|||
let existingUserQuery = `SELECT * FROM users WHERE github_id = $1`; |
|||
let existingUserResult = await client.query(existingUserQuery, [githubId]); |
|||
|
|||
let dbUser; |
|||
|
|||
if (existingUserResult.rows.length > 0) { |
|||
// User exists, update their information
|
|||
dbUser = existingUserResult.rows[0]; |
|||
|
|||
const updateQuery = `UPDATE users SET
|
|||
username = $1, |
|||
email = $2, |
|||
picture = $3, |
|||
is_verified = true, |
|||
updated_at = NOW() |
|||
WHERE github_id = $4 |
|||
RETURNING id, username, email, picture`;
|
|||
|
|||
const updateResult = await client.query(updateQuery, [ |
|||
username, |
|||
email, |
|||
avatarUrl || "/api/media/profile/default.png", |
|||
githubId |
|||
]); |
|||
|
|||
dbUser = updateResult.rows[0]; |
|||
} else { |
|||
// Check if username already exists
|
|||
const usernameCheck = await client.query(`SELECT id FROM users WHERE username = $1`, [username]); |
|||
let finalUsername = username; |
|||
|
|||
if (usernameCheck.rows.length > 0) { |
|||
// Username exists, append GitHub ID to make it unique
|
|||
finalUsername = `${username}_gh${githubId}`; |
|||
} |
|||
|
|||
// Create new user
|
|||
const insertQuery = `INSERT INTO users (
|
|||
username, |
|||
email, |
|||
picture, |
|||
github_id, |
|||
password, |
|||
is_verified |
|||
) VALUES ($1, $2, $3, $4, $5, $6) |
|||
RETURNING id, username, email, picture`;
|
|||
|
|||
const insertResult = await client.query(insertQuery, [ |
|||
finalUsername, |
|||
email, |
|||
avatarUrl || "/api/media/profile/default.png", |
|||
githubId, |
|||
null, // No password for OAuth users
|
|||
true // OAuth users are automatically verified
|
|||
]); |
|||
|
|||
dbUser = insertResult.rows[0]; |
|||
|
|||
// Create default playlist for new user
|
|||
const playlistQuery = `INSERT INTO playlists (name, owner) VALUES ('A regarder plus tard', $1)`; |
|||
await client.query(playlistQuery, [dbUser.id]); |
|||
} |
|||
|
|||
client.release(); |
|||
|
|||
// Generate JWT token
|
|||
const payload = { |
|||
id: dbUser.id, |
|||
username: dbUser.username, |
|||
}; |
|||
|
|||
const token = jwt.sign(payload, process.env.JWT_SECRET); |
|||
|
|||
// Redirect to frontend with token and user info
|
|||
const userData = encodeURIComponent(JSON.stringify({ |
|||
id: dbUser.id, |
|||
username: dbUser.username, |
|||
email: dbUser.email, |
|||
picture: dbUser.picture |
|||
})); |
|||
|
|||
res.redirect(`https://localhost/login/success?token=${token}&user=${userData}`); |
|||
|
|||
} catch (error) { |
|||
console.error("Error processing GitHub OAuth callback:", error); |
|||
res.redirect("https://localhost/login?error=processing_error"); |
|||
} |
|||
})(req, res, next); |
|||
} |
|||
|
|||
export async function getUserInfo(req, res) { |
|||
try { |
|||
// This endpoint can be used to get current user info from token
|
|||
const token = req.headers.authorization?.split(" ")[1]; |
|||
|
|||
if (!token) { |
|||
return res.status(401).json({ error: "No token provided" }); |
|||
} |
|||
|
|||
const decoded = jwt.verify(token, process.env.JWT_SECRET); |
|||
const client = await getClient(); |
|||
|
|||
const query = `SELECT id, username, email, picture, github_id, is_verified FROM users WHERE id = $1`; |
|||
const result = await client.query(query, [decoded.id]); |
|||
|
|||
if (!result.rows[0]) { |
|||
client.release(); |
|||
return res.status(404).json({ error: "User not found" }); |
|||
} |
|||
|
|||
client.release(); |
|||
res.status(200).json({ user: result.rows[0] }); |
|||
|
|||
} catch (error) { |
|||
console.error("Error getting user info:", error); |
|||
res.status(500).json({ error: "Internal server error" }); |
|||
} |
|||
} |
|||
@ -1,88 +1,277 @@ |
|||
import {getClient} from "../utils/database.js"; |
|||
|
|||
import jwt from 'jsonwebtoken'; |
|||
|
|||
export async function getRecommendations(req, res) { |
|||
|
|||
const token = req.headers.authorization?.split(' ')[1]; |
|||
|
|||
if (!token) { |
|||
if (!req.headers.authorization || !token) { |
|||
|
|||
// GET MOST USED TOKEN
|
|||
// GET MOST USED TAGS
|
|||
let client = await getClient(); |
|||
let queryMostUsedToken = `SELECT id, name FROM tags ORDER BY usage_count DESC LIMIT 3;`; |
|||
let result = await client.query(queryMostUsedToken); |
|||
let queryMostUsedTags = `SELECT id, name FROM tags ORDER BY usage_count DESC LIMIT 3;`; |
|||
let result = await client.query(queryMostUsedTags); |
|||
|
|||
// GET 10 VIDEOS WITH THE TAGS
|
|||
|
|||
const recommendations = result.rows; |
|||
let tagIds = result.rows.map(tag => tag.id); |
|||
let queryVideosWithTags = ` |
|||
|
|||
SELECT |
|||
v.id, |
|||
v.title, |
|||
v.thumbnail, |
|||
v.description AS video_description, |
|||
v.channel, |
|||
v.visibility, |
|||
v.file, |
|||
v.slug, |
|||
v.release_date, |
|||
v.channel AS channel_id, |
|||
c.owner, |
|||
COUNT(h.id) AS views, |
|||
json_build_object( |
|||
'name', c.name, |
|||
'profilePicture', u.picture, |
|||
'description', c.description |
|||
) AS creator, |
|||
'video' AS type |
|||
FROM public.videos v |
|||
INNER JOIN public.video_tags vt ON v.id = vt.video |
|||
INNER JOIN public.tags t ON vt.tag = t.id |
|||
INNER JOIN public.channels c ON v.channel = c.id |
|||
INNER JOIN public.users u ON c.owner = u.id |
|||
LEFT JOIN public.history h ON h.video = v.id |
|||
WHERE t.id = ANY($1::int[]) |
|||
AND v.visibility = 'public' |
|||
GROUP BY |
|||
v.id, |
|||
v.title, |
|||
v.thumbnail, |
|||
v.description, |
|||
v.channel, |
|||
v.visibility, |
|||
v.file, |
|||
v.slug, |
|||
v.release_date, |
|||
c.owner, |
|||
c.name, |
|||
u.picture, |
|||
c.description |
|||
ORDER BY views DESC, v.release_date DESC |
|||
LIMIT 10; |
|||
|
|||
`;
|
|||
let videoResult = await client.query(queryVideosWithTags, [tagIds]); |
|||
const recommendations = videoResult.rows; |
|||
res.status(200).json(recommendations); |
|||
|
|||
} else { |
|||
|
|||
// Recuperer les 20 derniere vu de l'historique
|
|||
let client = await getClient(); |
|||
let queryLastVideos = `SELECT video_id FROM history WHERE user_id = $1 ORDER BY viewed_at DESC LIMIT 20;`; |
|||
// TODO: Implement retrieval of recommendations based on user history and interactions
|
|||
|
|||
// Recuperer les likes de l'utilisateur sur les 20 derniere videos recuperees
|
|||
|
|||
// Recuperer les commentaires de l'utilisateur sur les 20 derniere videos recuperees
|
|||
const claims = jwt.decode(token) |
|||
const query = ` |
|||
-- Recommandation de contenu similaire non vu basée sur les interactions utilisateur |
|||
-- Paramètre: $1 = user_id |
|||
WITH user_interactions AS ( |
|||
-- Récupérer tous les contenus avec lesquels l'utilisateur a interagi |
|||
SELECT DISTINCT v.id as video_id, v.channel, t.id as tag_id, t.name as tag_name |
|||
FROM videos v |
|||
JOIN video_tags vt ON v.id = vt.video |
|||
JOIN tags t ON vt.tag = t.id |
|||
WHERE v.id IN ( |
|||
-- Vidéos likées par l'utilisateur |
|||
SELECT DISTINCT l.video FROM likes l WHERE l.owner = $1 |
|||
UNION |
|||
-- Vidéos commentées par l'utilisateur |
|||
SELECT DISTINCT c.video FROM comments c WHERE c.author = $1 |
|||
UNION |
|||
-- Vidéos ajoutées aux playlists de l'utilisateur |
|||
SELECT DISTINCT pe.video |
|||
FROM playlist_elements pe |
|||
JOIN playlists p ON pe.playlist = p.id |
|||
WHERE p.owner = $1 |
|||
) |
|||
), |
|||
user_preferred_tags AS ( |
|||
-- Tags préférés basés sur les interactions |
|||
SELECT tag_id, tag_name, COUNT(*) as interaction_count |
|||
FROM user_interactions |
|||
GROUP BY tag_id, tag_name |
|||
), |
|||
user_preferred_channels AS ( |
|||
-- Chaînes préférées basées sur les interactions |
|||
SELECT channel, COUNT(*) as interaction_count |
|||
FROM user_interactions |
|||
GROUP BY channel |
|||
), |
|||
unseen_videos AS ( |
|||
-- Vidéos que l'utilisateur n'a jamais vues |
|||
SELECT v.id, v.title, v.thumbnail, v.description, v.channel, v.visibility, |
|||
v.file, v.slug, v.format, v.release_date, ch.owner |
|||
FROM videos v |
|||
JOIN channels ch ON v.channel = ch.id |
|||
WHERE v.visibility = 'public' |
|||
AND v.id NOT IN ( |
|||
-- Exclure les vidéos déjà vues |
|||
SELECT DISTINCT h.video FROM history h WHERE h.user_id = $1 |
|||
UNION |
|||
-- Exclure les vidéos déjà likées |
|||
SELECT DISTINCT l.video FROM likes l WHERE l.owner = $1 |
|||
UNION |
|||
-- Exclure les vidéos déjà commentées |
|||
SELECT DISTINCT c.video FROM comments c WHERE c.author = $1 |
|||
UNION |
|||
-- Exclure les vidéos déjà ajoutées aux playlists |
|||
SELECT DISTINCT pe.video |
|||
FROM playlist_elements pe |
|||
JOIN playlists p ON pe.playlist = p.id |
|||
WHERE p.owner = $1 |
|||
) |
|||
) |
|||
-- Requête principale : recommander du contenu similaire |
|||
SELECT |
|||
uv.id, |
|||
uv.title, |
|||
uv.thumbnail, |
|||
uv.description as video_description, |
|||
uv.channel, |
|||
uv.visibility, |
|||
uv.file, |
|||
uv.slug, |
|||
uv.format, |
|||
uv.release_date, |
|||
uv.channel as channel_id, |
|||
uv.owner, |
|||
COALESCE(view_counts.views::text, '0') as views, |
|||
json_build_object( |
|||
'name', u.username, |
|||
'profilePicture', u.picture, |
|||
'description', ch.description |
|||
) as creator, |
|||
'video' as type |
|||
FROM unseen_videos uv |
|||
JOIN channels ch ON uv.channel = ch.id |
|||
JOIN users u ON ch.owner = u.id |
|||
-- Compter les vues |
|||
LEFT JOIN ( |
|||
SELECT video, COUNT(*) as views |
|||
FROM history |
|||
GROUP BY video |
|||
) view_counts ON uv.id = view_counts.video |
|||
-- Score basé sur les tags similaires |
|||
LEFT JOIN ( |
|||
SELECT |
|||
vt.video, |
|||
SUM(upt.interaction_count * 0.7) as score |
|||
FROM video_tags vt |
|||
JOIN user_preferred_tags upt ON vt.tag = upt.tag_id |
|||
GROUP BY vt.video |
|||
) tag_score ON uv.id = tag_score.video |
|||
-- Score basé sur les chaînes similaires |
|||
LEFT JOIN ( |
|||
SELECT |
|||
uv2.channel, |
|||
MAX(upc.interaction_count * 0.3) as score |
|||
FROM unseen_videos uv2 |
|||
JOIN user_preferred_channels upc ON uv2.channel = upc.channel |
|||
GROUP BY uv2.channel |
|||
) channel_score ON uv.channel = channel_score.channel |
|||
WHERE (tag_score.score > 0 OR channel_score.score > 0) -- Au moins une similarité |
|||
GROUP BY uv.id, uv.title, uv.thumbnail, uv.description, uv.channel, uv.visibility, |
|||
uv.file, uv.slug, uv.format, uv.release_date, uv.owner, u.username, u.picture, |
|||
ch.description, view_counts.views, tag_score.score, channel_score.score |
|||
ORDER BY (COALESCE(tag_score.score, 0) + COALESCE(channel_score.score, 0)) DESC, uv.release_date DESC |
|||
LIMIT 20; |
|||
|
|||
// Recuperer les 3 tags avec lesquels l'utilisateur a le plus interagi
|
|||
`;
|
|||
let result = await client.query(query, [claims.id]); |
|||
|
|||
// Recuperer 10 videos avec les 3 tags ayant le plus d'interaction avec l'utilisateur
|
|||
|
|||
res.status(200).json({ |
|||
message: "Recommendations based on user history and interactions are not yet implemented." |
|||
}); |
|||
client.release() |
|||
res.status(200).json(result.rows); |
|||
} |
|||
|
|||
} |
|||
|
|||
export async function getTrendingVideos(req, res) { |
|||
const client = await getClient(); |
|||
try { |
|||
// GET 10 VIDEOS WITH THE MOST LIKES AND COMMENTS
|
|||
let client = await getClient(); |
|||
// Optimized single query to get all trending video data
|
|||
let queryTrendingVideos = ` |
|||
SELECT v.id, v.title, v.description, v.release_date, v.thumbnail, |
|||
COUNT(DISTINCT l.id) AS like_count, COUNT(DISTINCT c.id) AS comment_count |
|||
SELECT |
|||
v.id, |
|||
v.title, |
|||
v.description, |
|||
v.release_date, |
|||
v.thumbnail, |
|||
v.visibility, |
|||
COUNT(DISTINCT l.id) AS like_count, |
|||
COUNT(DISTINCT c.id) AS comment_count, |
|||
COUNT(DISTINCT h.id) AS views, |
|||
ch.id AS creator_id, |
|||
ch.name AS creator_name, |
|||
u.picture AS creator_profile_picture |
|||
FROM videos v |
|||
LEFT JOIN likes l ON v.id = l.video |
|||
LEFT JOIN comments c ON v.id = c.video |
|||
GROUP BY v.id |
|||
ORDER BY like_count DESC, comment_count DESC |
|||
LIMIT 10; |
|||
LEFT JOIN history h ON v.id = h.video |
|||
LEFT JOIN channels ch ON v.channel = ch.id |
|||
LEFT JOIN users u ON ch.owner = u.id |
|||
WHERE v.visibility = 'public' |
|||
GROUP BY v.id, ch.id, ch.name, u.picture |
|||
ORDER BY like_count DESC, comment_count DESC, views DESC |
|||
LIMIT 10 |
|||
`;
|
|||
let result = await client.query(queryTrendingVideos); |
|||
const trendingVideos = result.rows; |
|||
|
|||
for (let video of trendingVideos) { |
|||
// Get the number of views for each video
|
|||
let viewsQuery = `SELECT COUNT(*) AS view_count FROM history WHERE video = $1;`; |
|||
let viewsResult = await client.query(viewsQuery, [video.id]); |
|||
video.views = viewsResult.rows[0].view_count; |
|||
|
|||
// Get the creator of each video
|
|||
let creatorQuery = `SELECT c.id, c.name FROM channels c JOIN videos v ON c.id = v.channel WHERE v.id = $1;`; |
|||
let creatorResult = await client.query(creatorQuery, [video.id]); |
|||
if (creatorResult.rows.length > 0) { |
|||
video.creator = creatorResult.rows[0]; |
|||
} else { |
|||
video.creator = {id: null, name: "Unknown"}; |
|||
} |
|||
|
|||
// GET THE PROFILE PICTURE OF THE CREATOR
|
|||
let profilePictureQuery = `SELECT u.picture FROM users u JOIN channels c ON u.id = c.owner WHERE c.id = $1;`; |
|||
let profilePictureResult = await client.query(profilePictureQuery, [video.creator.id]); |
|||
if (profilePictureResult.rows.length > 0) { |
|||
video.creator.profilePicture = profilePictureResult.rows[0].picture; |
|||
} else { |
|||
video.creator.profilePicture = null; // Default or placeholder image can be set here
|
|||
} |
|||
|
|||
let result = await client.query(queryTrendingVideos); |
|||
const trendingVideos = result.rows.map(video => ({ |
|||
id: video.id, |
|||
title: video.title, |
|||
description: video.description, |
|||
release_date: video.release_date, |
|||
thumbnail: video.thumbnail, |
|||
visibility: video.visibility, |
|||
like_count: video.like_count, |
|||
comment_count: video.comment_count, |
|||
views: video.views, |
|||
creator: { |
|||
id: video.creator_id, |
|||
name: video.creator_name, |
|||
profilePicture: video.creator_profile_picture |
|||
} |
|||
})); |
|||
|
|||
res.status(200).json(trendingVideos); |
|||
} catch (error) { |
|||
console.error("Error fetching trending videos:", error); |
|||
res.status(500).json({error: "Internal server error while fetching trending videos."}); |
|||
} finally { |
|||
client.release(); |
|||
} |
|||
} |
|||
|
|||
export async function getTopCreators(req, res) { |
|||
const client = await getClient(); |
|||
try { |
|||
// GET TOP 5 CREATORS BASED ON NUMBER OF SUBSCRIBERS
|
|||
let queryTopCreators = ` |
|||
SELECT c.id, c.name, c.description, u.picture AS profilePicture, COUNT(s.id) AS subscriber_count |
|||
FROM channels c |
|||
JOIN users u ON c.owner = u.id |
|||
LEFT JOIN subscriptions s ON c.id = s.channel |
|||
GROUP BY c.id, u.picture |
|||
ORDER BY subscriber_count DESC |
|||
LIMIT 10; |
|||
`;
|
|||
let result = await client.query(queryTopCreators); |
|||
const topCreators = result.rows; |
|||
|
|||
res.status(200).json(topCreators); |
|||
} catch (error) { |
|||
console.error("Error fetching top creators:", error); |
|||
res.status(500).json({error: "Internal server error while fetching top creators."}); |
|||
} finally { |
|||
client.release(); |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
import {Router} from 'express'; |
|||
import { callback, login, getUserInfo } from '../controllers/oauth.controller.js'; |
|||
import { isTokenValid } from '../middlewares/jwt.middleware.js'; |
|||
import { addLogger } from '../middlewares/logger.middleware.js'; |
|||
|
|||
const router = Router(); |
|||
|
|||
router.get('/github', login) |
|||
|
|||
router.get('/callback', callback) |
|||
|
|||
// Get current user info from token
|
|||
router.get('/me', [addLogger, isTokenValid], getUserInfo) |
|||
|
|||
export default router; |
|||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 19 MiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@ -0,0 +1,38 @@ |
|||
import nodemailer from "nodemailer"; |
|||
|
|||
function getTransporter() { |
|||
return nodemailer.createTransport({ |
|||
host: "smtp.gmail.com", |
|||
port: 587, |
|||
secure: false, |
|||
auth: { |
|||
user: process.env.GMAIL_USER, |
|||
pass: "yuuu kvoi ytrf blla", |
|||
}, |
|||
}); |
|||
}; |
|||
|
|||
/** |
|||
* Send an email |
|||
* @param {string} to |
|||
* @param {string} subject |
|||
* @param {string} text |
|||
* @param {string} html |
|||
* @return {Promise<object>} |
|||
*/ |
|||
export function sendEmail(to, subject, text, html = null) { |
|||
const transporter = getTransporter(); |
|||
const mailOptions = { |
|||
from: process.env.GMAIL_USER, |
|||
to, |
|||
subject, |
|||
text, |
|||
}; |
|||
|
|||
// Add HTML if provided
|
|||
if (html) { |
|||
mailOptions.html = html; |
|||
} |
|||
|
|||
return transporter.sendMail(mailOptions); |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
### Get all videos from the three most watched tags |
|||
GET http://127.0.0.1:8000/api/videos/top-tags/videos |
|||
|
|||
### Alternative localhost URL |
|||
GET http://localhost:8000/api/videos/top-tags/videos |
|||
|
|||
### With frontend URL (if using nginx proxy) |
|||
GET http://localhost/api/videos/top-tags/videos |
|||
|
|||
### |
|||
# This endpoint returns: |
|||
# - topTags: The 3 most used tags with their usage count |
|||
# - videos: All public videos that have any of these top 3 tags |
|||
# - totalVideos: Count of videos returned |
|||
# |
|||
# Videos are ordered by popularity score (calculated from views, likes, comments) |
|||
# and then by release date (newest first) |
|||
@ -0,0 +1,4 @@ |
|||
|
|||
CREATE ROLE 'sacha' WITH PASSWORD 'sacha'; |
|||
CREATE DATABASE 'sacha' OWNER 'sacha'; |
|||
|
|||
@ -0,0 +1,4 @@ |
|||
|
|||
CREATE ROLE 'sacha' WITH PASSWORD 'sacha'; |
|||
CREATE DATABASE 'sacha' OWNER 'sacha'; |
|||
|
|||
@ -0,0 +1,68 @@ |
|||
server { |
|||
server_name localhost; |
|||
listen 80; |
|||
|
|||
return 301 https://$host$request_uri; |
|||
} |
|||
|
|||
server { |
|||
server_name localhost; |
|||
listen 443 ssl; |
|||
|
|||
root /usr/share/nginx/html; |
|||
index index.html index.htm; |
|||
|
|||
# Allow large file uploads for videos (up to 500MB) |
|||
client_max_body_size 500M; |
|||
|
|||
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt; |
|||
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key; |
|||
|
|||
ssl_protocols TLSv1.2 TLSv1.3; |
|||
ssl_ciphers HIGH:!aNULL:!MD5; |
|||
|
|||
# API routes - proxy to backend (MUST come before static file rules) |
|||
location /api/ { |
|||
# Handle preflight OPTIONS requests |
|||
if ($request_method = 'OPTIONS') { |
|||
add_header 'Access-Control-Allow-Origin' '$http_origin' always; |
|||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; |
|||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always; |
|||
add_header 'Access-Control-Allow-Credentials' 'true' always; |
|||
add_header 'Access-Control-Max-Age' 1728000 always; |
|||
add_header 'Content-Type' 'text/plain; charset=utf-8' always; |
|||
add_header 'Content-Length' 0 always; |
|||
return 204; |
|||
} |
|||
|
|||
proxy_pass http://localhost:8000; |
|||
proxy_set_header Host $host; |
|||
proxy_set_header X-Real-IP $remote_addr; |
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|||
proxy_set_header X-Forwarded-Proto $scheme; |
|||
proxy_set_header Origin $http_origin; |
|||
proxy_buffering off; |
|||
|
|||
# CORS headers for actual requests |
|||
add_header 'Access-Control-Allow-Origin' '$http_origin' always; |
|||
add_header 'Access-Control-Allow-Credentials' 'true' always; |
|||
|
|||
# Also set timeout for large uploads |
|||
proxy_read_timeout 300s; |
|||
proxy_send_timeout 300s; |
|||
} |
|||
|
|||
# Static assets - NO CACHING for development |
|||
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg)$ { |
|||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; |
|||
add_header Pragma "no-cache"; |
|||
add_header Expires "0"; |
|||
try_files $uri =404; |
|||
} |
|||
|
|||
# Handle React Router - all other routes should serve index.html |
|||
location / { |
|||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; |
|||
try_files $uri $uri/ /index.html; |
|||
} |
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
#!/bin/bash |
|||
if [[ $EUID -ne 0 ]]; then |
|||
echo "Ce script doit être lancé avec les droits root" |
|||
exit 1 |
|||
fi |
|||
|
|||
pwd=$PWD |
|||
|
|||
# Environment variables |
|||
echo -e "\033[0;32m Création des variables d'environnement \033[0m" |
|||
echo -e "\033[1;33m Nom d'utilisateur de la base de données \033[0m" |
|||
read POSTGRES_USER |
|||
echo -e "\033[1;33m Mot de passe de la base de données \033[0m" |
|||
read POSTGRES_PASSWORD |
|||
echo -e "\033[1;33m Nom de la base de données \033[0m" |
|||
read POSTGRES_DB |
|||
|
|||
echo -e "\033[1;33m Clé d'encryption des JWTs \033[0m" |
|||
read JWT_SECRET |
|||
|
|||
echo -e "\033[1;33m Utilisateur Gmail \033[0m" |
|||
read GMAIL_USER |
|||
echo -e "\033[1;33m Mot de passe de l'application Gmail \033[0m" |
|||
read GMAIL_PASSWORD |
|||
|
|||
echo -e "\033[1;33m Url du site web \033[0m" |
|||
read FRONTEND_URL |
|||
|
|||
echo -e "\033[1;33m Client ID de l'application Github OAuth \033[0m" |
|||
read GITHUB_ID |
|||
echo -e "\033[1;33m Client secret de l'application Github OAuth \033[0m" |
|||
read GITHUB_PASSWORD |
|||
|
|||
touch $pwd/backend/.env |
|||
echo " |
|||
POSTGRES_USER=$POSTGRES_USER |
|||
POSTGRES_PASSWORD=$POSTGRES_PASSWORD |
|||
POSTGRES_DB=$POSTGRES_DB |
|||
POSTGRES_HOST=localhost |
|||
|
|||
BACKEND_PORT=8000 |
|||
|
|||
JWT_SECRET=$JWT_SECRET |
|||
|
|||
LOG_FILE=/var/log/freetube/access.log |
|||
|
|||
GMAIL_USER=$GMAIL_USER |
|||
GMAIL_PASSWORD=$GMAIL_PASSWORD |
|||
|
|||
FRONTEND_URL=$FRONTEND_URL |
|||
|
|||
GITHUB_ID=$GITHUB_ID |
|||
GITHUB_SECRET=$GITHUB_PASSWORD |
|||
" > $pwd/backend/.env |
|||
|
|||
# Install dependencies (NodeJS 22/PostgreSQL/Nginx) |
|||
echo -e "\033[0;32m Installation des dépendances... \033[0m" |
|||
apt install postgresql nginx openssl curl && |
|||
|
|||
# Install NVM and NodeJS & NPM |
|||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash && |
|||
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" |
|||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" |
|||
\. "$HOME/.nvm/nvm.sh" && |
|||
nvm install 22 && |
|||
|
|||
# Install node packages for backend |
|||
echo -e "\033[0;32m Installation des paquets NodeJS \033[0m" |
|||
cd $pwd/backend && npm install && |
|||
cd $pwd/frontend && npm install && |
|||
|
|||
echo "Construction du frontend" |
|||
npx vite build |
|||
cd $pwd |
|||
|
|||
# Create Nginx configuration |
|||
echo -e "\033[0;32m Création de la configuration Nginx \033[0m" |
|||
mkdir /etc/nginx/ssl/ |
|||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/nginx/ssl/nginx-selfsigned.key -out /etc/nginx/ssl/nginx-selfsigned.crt |
|||
touch /etc/nginx/conf.d/freetube.conf |
|||
cat $pwd/default.conf > /etc/nginx/conf.d/freetube.conf |
|||
|
|||
echo -e "\033[0;32m Copie des fichiers vers /usr/share/nginx/html \033[0m" |
|||
rm /usr/share/nginx/html/index.html |
|||
mv $pwd/frontend/dist/* /usr/share/nginx/html/ |
|||
|
|||
# Create PostgreSQL database |
|||
echo -e "\033[0;32m Création de l'utilisateur $POSTGRES_USER \033[0m" |
|||
|
|||
sudo -u postgres psql -c "CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';" |
|||
sudo -u postgres psql -c "CREATE ROLE $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';" |
|||
sudo -u postgres psql -c "CREATE DATABASE $POSTGRES_DB OWNER $POSTGRES_USER;" |
|||
|
|||
# Add log file |
|||
mkdir /var/log/freetube |
|||
touch /var/log/freetube/access.log |
|||
systemctl enable nginx |
|||
systemctl enable postgresql |
|||
systemctl start nginx |
|||
systemctl start postgresql |
|||
@ -0,0 +1,49 @@ |
|||
# 3RESIT Freetube |
|||
|
|||
## Sommaire |
|||
|
|||
1) Introduction au projet |
|||
2) Organisation |
|||
2) Les technologies utilisées |
|||
1) Le serveur |
|||
2) Le site web |
|||
3) La base de données |
|||
3) Le serveur |
|||
1) Les dépendances |
|||
2) Le fonctionnement |
|||
4) Le site web |
|||
1) Les dépendances |
|||
2) Le fonctionnement |
|||
5) La base de données |
|||
1) Diagramme des tables |
|||
6) Installation |
|||
1) Docker Compose |
|||
2) Script Shell |
|||
3) Manuelle |
|||
|
|||
## Introduction |
|||
|
|||
Pour ce projet il nous a été demandé de recréer une plateforme similaire a Youtube nommée Freetube. Cette application web devait être gratuite et sans publicités. Nous avions la main libre sur le choix de la pile technologique utilisée et sur l'organisation du projet. |
|||
|
|||
## Organisation |
|||
|
|||
J'ai commencé par faire un plan détaillés de toute les routes et vérifications a effectué pour chaque fonctionnalité, puis faire un plan de la base de données. Ceci allait définir toute la structure du projet. |
|||
|
|||
Pour ce qui est du développement, j'ai choisis de procéder fonctionnalité par fonctionnalité en commençant par le serveur et la base de données pour les intégrer ensuite dans le site web. Pour séparer toute ces parties j'ai utilisé **git** en créant plusieurs branches, une par fonctionnalité. |
|||
|
|||
## Les technologies utilisées |
|||
|
|||
### Le serveur |
|||
Le serveur utilise **NodeJS**, j'ai choisis ce langage car il permet d'implementer une **API REST** efficacement grâce a son système d'asynchronisation natif très performant. Etant créer a partir du **Javascript** il est aussi plus simple a comprendre et a écrire. Même si NodeJS n'est pas le plus connu en terme de rapidité d'éxecution ce n'est pas un problème dans notre situation car nous travaillons avec une **API** qui ajoutera un temps de latence supplémentaire. |
|||
|
|||
### Le site web |
|||
Le site web est programmé en **ReactJS** avec **Vite** ce qui donne un **backend** et un **frontend** dans le même langage ce qui permet une maintenance et des mises à jour plus simple. Tout comme NodeJS, ReactJS et créé a partir de Javascript, il bénéficie donc de la même intégration de l'asynchrone natif. Le système de **composant** de ReactJS permet aussi une gestion de mise à jour de l'interface en temps réel plus simple a mettre en place et évite la duplication de code. |
|||
|
|||
### La base données |
|||
La base de données et une base **PostgreSQL**, un système basé sur le langage SQL largement connu. PostgreSQL est une alternative **OpenSource** à **MySQL**. Il possède une très bonne intégration du **JSON** très utilisé comme moyen d'envoyer des données via **requête HTTP** utilisé dans les API REST. |
|||
|
|||
## Le serveur |
|||
|
|||
### Les dépendances |
|||
|
|||
Pour l'API REST j'ai choisis d'utiliser **ExpressJS** couplé avec **express-validator** |
|||
@ -0,0 +1,2 @@ |
|||
#!/bin/bash |
|||
node ./backend/server.js |
|||
@ -0,0 +1,7 @@ |
|||
# Build stage |
|||
FROM node:22-alpine3.20 as build-stage |
|||
|
|||
WORKDIR /app |
|||
COPY package*.json ./ |
|||
RUN npm install |
|||
COPY . . |
|||
@ -1,9 +1,17 @@ |
|||
FROM node:21-alpine3.20 |
|||
# Build stage |
|||
FROM node:22-alpine3.20 as build-stage |
|||
|
|||
WORKDIR /app |
|||
COPY package*.json ./ |
|||
RUN npm install |
|||
COPY . . |
|||
EXPOSE 5173 |
|||
CMD ["npm", "run", "dev"] |
|||
RUN npm run build |
|||
|
|||
# Production stage |
|||
FROM nginx:alpine |
|||
RUN mkdir -p /etc/nginx/ssl |
|||
COPY --from=build-stage /app/dist /usr/share/nginx/html |
|||
COPY default.conf /etc/nginx/conf.d/default.conf |
|||
COPY nginx-selfsigned.crt nginx-selfsigned.key /etc/nginx/ssl/ |
|||
EXPOSE 80 443 |
|||
CMD ["nginx", "-g", "daemon off;"] |
|||
|
|||
@ -0,0 +1,68 @@ |
|||
server { |
|||
server_name localhost; |
|||
listen 80; |
|||
|
|||
return 301 https://$host$request_uri; |
|||
} |
|||
|
|||
server { |
|||
server_name localhost; |
|||
listen 443 ssl; |
|||
|
|||
root /usr/share/nginx/html; |
|||
index index.html index.htm; |
|||
|
|||
# Allow large file uploads for videos (up to 500MB) |
|||
client_max_body_size 500M; |
|||
|
|||
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt; |
|||
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key; |
|||
|
|||
ssl_protocols TLSv1.2 TLSv1.3; |
|||
ssl_ciphers HIGH:!aNULL:!MD5; |
|||
|
|||
# API routes - proxy to backend (MUST come before static file rules) |
|||
location /api/ { |
|||
# Handle preflight OPTIONS requests |
|||
if ($request_method = 'OPTIONS') { |
|||
add_header 'Access-Control-Allow-Origin' '$http_origin' always; |
|||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; |
|||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always; |
|||
add_header 'Access-Control-Allow-Credentials' 'true' always; |
|||
add_header 'Access-Control-Max-Age' 1728000 always; |
|||
add_header 'Content-Type' 'text/plain; charset=utf-8' always; |
|||
add_header 'Content-Length' 0 always; |
|||
return 204; |
|||
} |
|||
|
|||
proxy_pass http://resit_backend:8000; |
|||
proxy_set_header Host $host; |
|||
proxy_set_header X-Real-IP $remote_addr; |
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|||
proxy_set_header X-Forwarded-Proto $scheme; |
|||
proxy_set_header Origin $http_origin; |
|||
proxy_buffering off; |
|||
|
|||
# CORS headers for actual requests |
|||
add_header 'Access-Control-Allow-Origin' '$http_origin' always; |
|||
add_header 'Access-Control-Allow-Credentials' 'true' always; |
|||
|
|||
# Also set timeout for large uploads |
|||
proxy_read_timeout 300s; |
|||
proxy_send_timeout 300s; |
|||
} |
|||
|
|||
# Static assets - NO CACHING for development |
|||
location ~* ^/(?!api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg)$ { |
|||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; |
|||
add_header Pragma "no-cache"; |
|||
add_header Expires "0"; |
|||
try_files $uri =404; |
|||
} |
|||
|
|||
# Handle React Router - all other routes should serve index.html |
|||
location / { |
|||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; |
|||
try_files $uri $uri/ /index.html; |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
-----BEGIN CERTIFICATE----- |
|||
MIID5zCCAs+gAwIBAgIUXzNzqa/12lyIcoxXf+v371J3fWkwDQYJKoZIhvcNAQEL |
|||
BQAwgYIxCzAJBgNVBAYTAkZSMREwDwYDVQQIDAhOb3JtYW5keTENMAsGA1UEBwwE |
|||
Q2FlbjERMA8GA1UECgwIRnJhbWluZm8xFTATBgNVBAMMDFNhY2hhIEdVRVJJTjEn |
|||
MCUGCSqGSIb3DQEJARYYc2FjaGEuZ3VlcmluQHN1cGluZm8uY29tMB4XDTI1MDcy |
|||
MTEzMzgwMVoXDTI2MDcyMTEzMzgwMVowgYIxCzAJBgNVBAYTAkZSMREwDwYDVQQI |
|||
DAhOb3JtYW5keTENMAsGA1UEBwwEQ2FlbjERMA8GA1UECgwIRnJhbWluZm8xFTAT |
|||
BgNVBAMMDFNhY2hhIEdVRVJJTjEnMCUGCSqGSIb3DQEJARYYc2FjaGEuZ3Vlcmlu |
|||
QHN1cGluZm8uY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvLg7 |
|||
nR0UqRZ7UadhI8jrUjRMV1SZj+ljxEnV6tDOVMsvafsym1MhDZHb+cyv8769yqPv |
|||
CKtIOQKhMH0PkSqau8szNlF1Tg/1UzT+Mkd4zvLvGE5+aW/oDMg7E2LMJZuCyO4X |
|||
9SzWDVA5+b1QFIw6vvb3mCkUOtVDkOFreBBwryZKcWJ0b8o1hT60oB2wr18P14j0 |
|||
0C2/TmHMtim0o4r3gKGvpatqt1fXJo0UlYOwTvfMrYhu2VHqsQ2qP7ocazXEWt5u |
|||
Alf1vNPkAenF0ZV/2UiaL41Q8GMoV1enDP7k7/qfgXvta/hOeYnLtmv5Qpi4XiWz |
|||
xKjSukTUD2sRtSX+YQIDAQABo1MwUTAdBgNVHQ4EFgQUVj9KtmjLFy4xWzkNI9Kq |
|||
NAxNsfUwHwYDVR0jBBgwFoAUVj9KtmjLFy4xWzkNI9KqNAxNsfUwDwYDVR0TAQH/ |
|||
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGpUPMoF/ASIqOOfX5anDtaPvnslj |
|||
DuEVbU7Uoyc4EuSD343DPV7iMUKoFvVLPxJJqMLhSo3aEGJyqOF6q3fvq/vX2VE7 |
|||
9MhwS1t2DBGb5foWRosnT1EuqFU1/S0RJ/Y+GNcoY1PrUES4+r7zqqJJjwKOzneV |
|||
ktUVCdKl0C1gtw6W4Ajxse3fm9DNLxnZZXbyNqn+KbI8QdO0xSEl+gyiycvPu/NT |
|||
+EesdlFoYjO7gdA8dXkmu+Z7R61MYhE9Zvyop5KVMqgU8/Ym04UUWjWQYWWLMyuu |
|||
bxngE4XNEI5fhg+0e/I25xJJ9wVV/ZNAF4+XOylHz/CmU8V/SPKuGXBGHg== |
|||
-----END CERTIFICATE----- |
|||
@ -0,0 +1,28 @@ |
|||
-----BEGIN PRIVATE KEY----- |
|||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8uDudHRSpFntR |
|||
p2EjyOtSNExXVJmP6WPESdXq0M5Uyy9p+zKbUyENkdv5zK/zvr3Ko+8Iq0g5AqEw |
|||
fQ+RKpq7yzM2UXVOD/VTNP4yR3jO8u8YTn5pb+gMyDsTYswlm4LI7hf1LNYNUDn5 |
|||
vVAUjDq+9veYKRQ61UOQ4Wt4EHCvJkpxYnRvyjWFPrSgHbCvXw/XiPTQLb9OYcy2 |
|||
KbSjiveAoa+lq2q3V9cmjRSVg7BO98ytiG7ZUeqxDao/uhxrNcRa3m4CV/W80+QB |
|||
6cXRlX/ZSJovjVDwYyhXV6cM/uTv+p+Be+1r+E55icu2a/lCmLheJbPEqNK6RNQP |
|||
axG1Jf5hAgMBAAECggEAAj+hmDRx6jafAAf67sqi3ZgEGEmBkXNeeLGBTPc/qhxd |
|||
ip6krTELnz8TE26RG5LYXzslasUNrn42nIImvBT5ZkcjcosKpWfEqQEAjc1PQovC |
|||
9eyKnKfw4TpUvvmiveT4T98vCYEOOqHE0/WTdlOoaBY/f+sZKQYu+1NMtAjFcg2r |
|||
vVqwsZb5vGyh7CKmIHZnz3UP8P+7G5digiNRne18pGnE2oTnSoQ3/QIqUWBs69DS |
|||
k5ew+CSyTLiUFFnMnE4adwyg6wAud5fBlzowF6UF2agToX7pxEaGxGvpBGG034kk |
|||
1UXaB/d5YwcsBeH+x5cNMLKZy4zqjoxEEW31Q466NQKBgQDtKk1R/slpTpRqvtBT |
|||
NC7InvjcCBXkXttylQHJRN9glqhmflEOe8iMW1/qRwBPlQgK1wq/sXySanVv2+gO |
|||
JGq8XNRLbHyG3YRyshdnJHP1HoWQE0uedD/rfqgkNaW5S1IvHrD7Q7tOvCrF+KbS |
|||
612pmIgNVzn+inafDXPhMZc4pQKBgQDLtQGAu2eK58ewndyL8+7+VHZSTEtKpt+h |
|||
G/U/ccv+6NGqdxI5YUkrJ7k6vV81VeRMvmN9uUS/i8znORFQmm6noRVkhXytwW5B |
|||
HXq2co4WRvv9b/XqcqS0GSYVPJ1u4YNH6lvtDZ4UWPyBzYl700GdHrGa+erT44yL |
|||
tnibHx9GDQKBgFW1J+Qt85O+9hvtgVPQU+fkq4K42VCCh0PNXavi2+cICyufEqPt |
|||
T/iJPQxpRE9+SD3CoPvNpHs1ReN60U3rEzenRIFNX2NNwoPAoHyBy/YVZac/keBd |
|||
mov8Zb9QM+fWtIiaytLDE3nMvph017T5ogucN+66SxcV6vBn6CzFwySRAoGAcUf2 |
|||
Tv1ohkGAtgIDrLx5cmvL5NZSpHAKOpDOoHqLA/W66v4OX2RviRUtF7JJ6OIb9GWH |
|||
9Fl8Fr0KtKbyrw1CbevRdrYY8JN52bIoFJ+9zjupVHXXnookd5boq7SqpAe6ttpo |
|||
RnplJ1GZEiIXy4lemp6AC/vhD/YhqWxOw4zaGl0CgYBslhqVt5F0EHf94p7NrCuY |
|||
hNHKHaNaULYP0VXKefQamt/ssDuktqb6DNSIvx2rbbB5+33nTlLTya67gimY1lKt |
|||
WeNB33/yBkCjfSP/J5UDD9mE/oPLt3vAOkOUgMCfp2IpC2Wez1QGqLHS260zpotP |
|||
VpgalHuSWtn8D4nO2pk1hg== |
|||
-----END PRIVATE KEY----- |
|||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 764 B |
|
After Width: | Height: | Size: 487 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 120 B |
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 260 B |
|
After Width: | Height: | Size: 253 B |
@ -0,0 +1,33 @@ |
|||
import { useEffect } from 'react'; |
|||
|
|||
export default function Alert({ type, message, onClose }) { |
|||
const alertClass = `cursor-pointer flex items-center gap-4 p-4 rounded-md text-white transition-opacity duration-300 ${ type === "error" ? "glassmorphism-red" : "glassmorphism-green" } `; |
|||
|
|||
useEffect(() => { |
|||
const timer = setTimeout(() => { |
|||
onClose(); |
|||
}, 5000); // 5 seconds |
|||
|
|||
return () => clearTimeout(timer); |
|||
}, [onClose]); |
|||
|
|||
return ( |
|||
<div |
|||
className={alertClass} |
|||
role="alert" |
|||
onClick={onClose} |
|||
> |
|||
|
|||
{ |
|||
type === 'success' ? ( |
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white" ><path d="m10 15.586-3.293-3.293-1.414 1.414L10 18.414l9.707-9.707-1.414-1.414z"></path></svg> |
|||
) : ( |
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white rotate-45" ><path d="M19 11h-6V5h-2v6H5v2h6v6h2v-6h6z"></path></svg> |
|||
) |
|||
} |
|||
|
|||
{message} |
|||
</div> |
|||
); |
|||
|
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import Alert from "./Alert.jsx"; |
|||
|
|||
|
|||
export default function AlertList({ alerts, onCloseAlert }) { |
|||
return ( |
|||
<div className="fixed bottom-2.5 right-0 flex flex-col gap-2 mt-2 mr-4 z-40"> |
|||
{alerts.map((alert, index) => ( |
|||
<Alert |
|||
key={index} |
|||
type={alert.type} |
|||
message={alert.message} |
|||
onClose={() => onCloseAlert(alert)} |
|||
/> |
|||
))} |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
import VideoCard from "./VideoCard.jsx"; |
|||
|
|||
export default function ChannelLastVideos({ videos }) { |
|||
|
|||
return ( |
|||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> |
|||
{ |
|||
videos && videos.length > 0 ? ( |
|||
videos.map((video) => ( |
|||
<VideoCard video={video} /> |
|||
) |
|||
) |
|||
) : ( |
|||
<p>Aucune vidéo trouvée</p> |
|||
) |
|||
} |
|||
</div> |
|||
) |
|||
|
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
|
|||
|
|||
export default function CreatorCard({ creator }) { |
|||
const handleClick = () => { |
|||
window.location.href = `/manage-channel/${creator.id}`; |
|||
}; |
|||
|
|||
return ( |
|||
<div className="flex flex-col glassmorphism w-full p-6 cursor-pointer" onClick={handleClick}> |
|||
<img |
|||
src={creator.profilePicture} |
|||
alt={creator.name} |
|||
className="w-[128px] aspect-square object-cover rounded-full mx-auto" |
|||
/> |
|||
<h2 className="text-2xl font-medium font-inter mt-3 text-white">{creator.name}</h2> |
|||
<div className="text-sm text-gray-400 mt-1"> |
|||
{creator.subscribers} abonné(e)s |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
import oauthService from '../services/oauthService'; |
|||
|
|||
export default function GitHubLoginButton({ className = "" }) { |
|||
const handleGitHubLogin = () => { |
|||
oauthService.loginWithGitHub(); |
|||
}; |
|||
|
|||
return ( |
|||
<button |
|||
onClick={handleGitHubLogin} |
|||
className={`flex items-center justify-center w-full px-4 py-2 text-sm font-medium text-white bg-gray-800 border border-transparent rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors ${className}`} |
|||
> |
|||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"> |
|||
<path |
|||
fillRule="evenodd" |
|||
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" |
|||
clipRule="evenodd" |
|||
/> |
|||
</svg> |
|||
Continuer avec GitHub |
|||
</button> |
|||
); |
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
import {Line} from "react-chartjs-2"; |
|||
import {Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend} from "chart.js"; |
|||
|
|||
ChartJS.register( |
|||
CategoryScale, |
|||
LinearScale, |
|||
PointElement, |
|||
LineElement, |
|||
Title, |
|||
Tooltip, |
|||
Legend |
|||
); |
|||
|
|||
export default function LinearGraph({ dataToGraph, className, legend, borderColor="rgba(75, 192, 192, 1)" }) { |
|||
|
|||
const prepareData = () => { |
|||
if (!dataToGraph || dataToGraph.length === 0) { |
|||
return { |
|||
labels: [], |
|||
datasets: [] |
|||
}; |
|||
} |
|||
|
|||
const labels = dataToGraph.map(item => item.date.split("T")[0]); |
|||
const data = dataToGraph.map(item => item.count); |
|||
|
|||
return { |
|||
labels, |
|||
datasets: [{ |
|||
label: legend, |
|||
data, |
|||
fill: false, |
|||
pointRadius: 3, |
|||
borderColor: borderColor, |
|||
tension: 0, |
|||
stepped: false |
|||
}] |
|||
}; |
|||
} |
|||
|
|||
const data = prepareData(); |
|||
|
|||
const options = { |
|||
|
|||
} |
|||
return ( |
|||
<div className={className}> |
|||
<Line options={options} data={data} className="w-full border-red-500 " /> |
|||
</div> |
|||
) |
|||
|
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
|
|||
|
|||
export default function PlaylistVideoCard({ video, playlistId, navigation, currentVideo }) { |
|||
return ( |
|||
<div className="glassmorphism flex items-center gap-2 p-2" onClick={() => navigation(`/video/${video.id}?playlistId=${playlistId}`)} > |
|||
<div className="relative" > |
|||
<img src={video.thumbnail} alt={video.title} className="h-16 object-cover rounded-sm" /> |
|||
{currentVideo == video.id && ( |
|||
<div className="absolute inset-0 bg-black opacity-80 rounded-sm w-full h-full flex items-center justify-center"> |
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white"><path d="M7 6v12l10-6z"></path></svg> |
|||
</div> |
|||
)} |
|||
</div> |
|||
<h3 className="font-montserrat font-medium text-white">{video.title}</h3> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
import VideoCard from "./VideoCard.jsx"; |
|||
|
|||
|
|||
export default function SeeLater({videos}) { |
|||
|
|||
return ( |
|||
<div className="mt-10"> |
|||
<h2 className="text-3xl font-bold mb-4 text-white">A regarder plus tard</h2> |
|||
<div> |
|||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 mt-2"> |
|||
{videos && videos.length > 0 ? videos.map((video, index) => ( |
|||
<VideoCard key={video.id || index} video={video} /> |
|||
)) : ( |
|||
<p className="text-gray-500">Aucune vidéo à regarder plus tard</p> |
|||
)} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
) |
|||
|
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
import React, { useState } from 'react'; |
|||
|
|||
export default function TabLayout({ tabs }) { |
|||
|
|||
const [activeTab, setActiveTab] = useState(tabs[0].id); |
|||
const onTabChange = (tabId) => { |
|||
setActiveTab(tabId); |
|||
}; |
|||
|
|||
return ( |
|||
|
|||
<div> |
|||
|
|||
{/* TABS */} |
|||
<div className='flex items-center mt-8 gap-3 mb-4' > |
|||
{ |
|||
tabs.map((tab) => ( |
|||
<button |
|||
key={tab.id} |
|||
onClick={() => onTabChange(tab.id)} |
|||
className={` px-4 py-2 font-montserrat font-medium text-lg cursor-pointer ${tab.id === activeTab ? 'bg-white text-black rounded-2xl border-2 border-white' : 'glassmorphism text-white'}`} |
|||
> |
|||
{tab.label} |
|||
</button> |
|||
)) |
|||
} |
|||
</div> |
|||
|
|||
{/* ELEMENT */} |
|||
|
|||
<div className="glassmorphism w-full p-4"> |
|||
{tabs.map((tab) => ( |
|||
<div key={tab.id} className={`tab-content ${tab.id === activeTab ? 'block' : 'hidden'}`}> |
|||
{tab.element()} |
|||
</div> |
|||
))} |
|||
</div> |
|||
|
|||
</div> |
|||
|
|||
) |
|||
|
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
export default function Tag({ tag, onSuppress, doShowControls=true }) { |
|||
|
|||
return ( |
|||
<div className="glassmorphism px-2 py-1 w-max flex flex-row items-center gap-2"> |
|||
<span className="font-inter text-white">#{tag}</span> |
|||
{doShowControls && ( |
|||
<span className="tag-controls cursor-pointer" onClick={onSuppress}> |
|||
<svg |
|||
className="w-6 h-6 fill-white" |
|||
stroke="#FFF" |
|||
viewBox="0 0 24 24" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> |
|||
</svg> |
|||
|
|||
</span> |
|||
)} |
|||
</div> |
|||
) |
|||
|
|||
} |
|||
@ -1,17 +1,26 @@ |
|||
|
|||
|
|||
export default function TopCreators({ creators }) { |
|||
export default function TopCreators({ creators, navigate }) { |
|||
return ( |
|||
<div className="mt-10"> |
|||
<h2 className="text-3xl font-bold mb-4 text-white">Top Creators</h2> |
|||
<div className="flex flex-wrap"> |
|||
{creators && creators.map((creator, index) => ( |
|||
<div key={creator.id || index} className="flex flex-col items-center w-1/4 p-4"> |
|||
<img src={creator.avatar} alt={creator.name} className="w-full h-auto rounded-lg" /> |
|||
<h3 className="text-xl font-bold mt-2">{creator.name}</h3> |
|||
<span className="text-sm text-gray-500">{creator.subscribers} subscribers</span> |
|||
<h2 className="text-3xl font-bold mb-4 text-white">Top Créateurs</h2> |
|||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 mt-8"> |
|||
{creators && creators.length > 0 ? creators.map((creator, index) => ( |
|||
<div |
|||
key={creator.id || index} |
|||
className="flex flex-col items-center glassmorphism py-2" |
|||
onClick={() => navigate(`/channel/${creator.id}`)} |
|||
> |
|||
<img src={creator.profilepicture} alt={creator.name} className="w-[128px] aspect-square rounded-full" /> |
|||
<h3 className="text-xl text-white font-bold mt-1">{creator.name}</h3> |
|||
<span className="text-sm text-gray-500">{creator.subscriber_count} abonné{creator.subscriber_count > 1 ? 's' : ''}</span> |
|||
<p className="text-center text-gray-400"> |
|||
<span>{creator.description.slice(0, 100) + (creator.description.length > 100 ? '...' : '')}</span> |
|||
</p> |
|||
</div> |
|||
))} |
|||
)) : ( |
|||
<p className="text-gray-500">Aucun créateur disponible</p> |
|||
)} |
|||
</div> |
|||
</div> |
|||
); |
|||
|
|||
@ -0,0 +1,20 @@ |
|||
|
|||
|
|||
export default function UserCard({ user, onSubmit, doShowControls, control }) { |
|||
return ( |
|||
<div className="glassmorphism flex items-center justify-between p-2" id={user.id} > |
|||
<div className="flex items-center gap-4"> |
|||
<img src={user.picture || '/default-profile.png'} alt={`${user.username}'s profile`} className="w-10 h-10 rounded-full mr-2" /> |
|||
<span className="text-white text-lg font-montserrat font-semibold">{user.username}</span> |
|||
</div> |
|||
{doShowControls && ( |
|||
|
|||
<div className="flex items-center gap-2" onClick={(e) => { |
|||
onSubmit(user); |
|||
}}> |
|||
{control} |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
|
|||
|
|||
export default function VideoStatListElement ({ video, onClick }) { |
|||
return ( |
|||
<div className="flex p-4 gap-4 glassmorphism cursor-pointer" onClick={onClick} > |
|||
<img |
|||
src={video.thumbnail} |
|||
alt="" |
|||
className="w-1/4 aspect-video rounded-sm" |
|||
/> |
|||
<div> |
|||
<h3 className="text-white text-lg lg:text-2xl font-montserrat font-bold" >{video.title.slice(0, 25)}{video.title.length > 25 ? "...":""}</h3> |
|||
<p className="text-white text-lg font-montserrat font-normal">Vues: {video.views}</p> |
|||
<p className="text-white text-lg font-montserrat font-normal">Likes: {video.likes}</p> |
|||
<p className="text-white text-lg font-montserrat font-normal">Commentaires: {video.comments}</p> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
import {useState} from "react"; |
|||
import { createChannel } from "../services/channel.service.js"; |
|||
|
|||
|
|||
export default function CreateChannelModal({isOpen, onClose, addAlert}) { |
|||
|
|||
const [name, setName] = useState(''); |
|||
const [description, setDescription] = useState(''); |
|||
|
|||
const token = localStorage.getItem('token'); |
|||
const userStored = localStorage.getItem('user'); |
|||
const user = userStored ? JSON.parse(userStored) : {}; |
|||
|
|||
const onSubmit = async (e) => { |
|||
e.preventDefault(); |
|||
|
|||
const body = { |
|||
"name": name, |
|||
"description": description, |
|||
"owner": user.id |
|||
} |
|||
|
|||
const data = await createChannel(body, token, addAlert); |
|||
|
|||
console.log(data); |
|||
|
|||
onClose(); |
|||
} |
|||
|
|||
return isOpen && ( |
|||
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center px-5 lg:px-0" > |
|||
<div className="glassmorphism p-4 w-full lg:w-1/4" > |
|||
<h2 className="text-2xl text-white font-montserrat font-bold" >Créer une chaine</h2> |
|||
<label htmlFor="name" className="block text-xl text-white font-montserrat font-semibold mt-2" >Nom de la chaine</label> |
|||
<input |
|||
type="text" |
|||
id="name" |
|||
name="name" |
|||
placeholder="Nom de la chaine" |
|||
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none" |
|||
onChange={(e) => setName(e.target.value)} |
|||
value={name} |
|||
/> |
|||
|
|||
<label htmlFor="description" className="block text-xl text-white font-montserrat font-semibold mt-2" >Description</label> |
|||
<textarea |
|||
id="description" |
|||
name="description" |
|||
placeholder="Description de votre chaine" |
|||
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none" |
|||
onChange={(e) => setDescription(e.target.value)} |
|||
value={description} |
|||
> |
|||
</textarea> |
|||
<button |
|||
className="bg-primary mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer" |
|||
onClick={(e) => onSubmit(e) } |
|||
> |
|||
Valider |
|||
</button> |
|||
<button |
|||
className="bg-red-500 ml-2 mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer" |
|||
onClick={onClose} |
|||
> |
|||
Annuler |
|||
</button> |
|||
</div> |
|||
</div> |
|||
) |
|||
|
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
import { useState } from "react"; |
|||
import { createPlaylist } from "../services/playlist.service.js"; |
|||
|
|||
export default function CreatePlaylistModal({ isOpen, onClose, addAlert }) { |
|||
|
|||
const [name, setName] = useState(''); |
|||
|
|||
const onSubmit = async (e) => { |
|||
e.preventDefault(); |
|||
const token = localStorage.getItem("token"); |
|||
const body = { name }; |
|||
await createPlaylist(body, token, addAlert); |
|||
onClose(); |
|||
}; |
|||
|
|||
return isOpen && ( |
|||
|
|||
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center px-5 lg:px-0" > |
|||
<div className="glassmorphism p-4 w-full lg:w-1/4" > |
|||
<h2 className="text-2xl text-white font-montserrat font-bold" >Créer une playlist</h2> |
|||
<label htmlFor="name" className="block text-xl text-white font-montserrat font-semibold mt-2" >Nom de la playlist</label> |
|||
<input |
|||
type="text" |
|||
id="name" |
|||
name="name" |
|||
placeholder="Nom de la playlist" |
|||
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none" |
|||
onChange={(e) => setName(e.target.value)} |
|||
value={name} |
|||
/> |
|||
<button |
|||
className="bg-primary mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer" |
|||
onClick={(e) => onSubmit(e) } |
|||
> |
|||
Valider |
|||
</button> |
|||
<button |
|||
className="bg-red-500 ml-2 mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer" |
|||
onClick={onClose} |
|||
> |
|||
Annuler |
|||
</button> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
import React, { useState } from 'react'; |
|||
|
|||
export default function EmailVerificationModal({ isOpen, onSubmit, onClose }) { |
|||
|
|||
const [verificationCode, setVerificationCode] = useState(''); |
|||
|
|||
return isOpen && ( |
|||
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center px-5 lg:px-0" > |
|||
<div className="glassmorphism p-4 w-full lg:w-1/4" > |
|||
<h2 className="text-lg font-bold mb-2 font-montserrat text-white">Vérification de l'email</h2> |
|||
<p className="font-montserrat text-white">Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre boîte de réception.</p> |
|||
<input |
|||
type="text" |
|||
placeholder="Entrez le code de vérification" |
|||
className="glassmorphism w-full px-4 py-2 mt-4 text-white" |
|||
value={verificationCode} |
|||
onChange={(e) => setVerificationCode(e.target.value)} |
|||
/> |
|||
<button className="bg-primary px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer mt-2" onClick={() => { |
|||
console.log("Verification code submitted:", verificationCode); |
|||
onSubmit(verificationCode) |
|||
}}>Vérifier</button> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
|
|||
|
|||
export default function LoadingVideoModal({state, message}) { |
|||
return state === "loading" && ( |
|||
<div className="fixed inset-0 bg-[rgba(0,0,0,0.5)] flex items-center justify-center z-50"> |
|||
<div className="glassmorphism p-8 flex flex-col items-center space-y-4"> |
|||
{/* Spinner */} |
|||
<div className="relative w-16 h-16"> |
|||
<div className="absolute inset-0 border-4 border-gray-300 border-opacity-30 rounded-full"></div> |
|||
<div className="absolute inset-0 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div> |
|||
</div> |
|||
<p className="text-white text-lg font-medium">{message}</p> |
|||
</div> |
|||
</div> |
|||
); |
|||
|
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
|
|||
|
|||
export default function VerificationModal({title, onConfirm, onCancel, isOpen}) { |
|||
if (!isOpen) return null; |
|||
|
|||
return ( |
|||
<div className="fixed z-40 inset-0 flex items-center justify-center px-5 lg:px-0"> |
|||
<div className="glassmorphism w-full lg:w-auto p-6"> |
|||
<h2 className="text-lg text-white font-semibold mb-4">{title}</h2> |
|||
<div className="flex justify-start lg:justify-end"> |
|||
<button |
|||
className="bg-primary px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer" |
|||
onClick={() => onConfirm()} |
|||
> |
|||
Confirmer |
|||
</button> |
|||
<button |
|||
className="bg-red-500 ml-4 px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer" |
|||
onClick={() => onCancel()} |
|||
> |
|||
Annuler |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
@ -0,0 +1,362 @@ |
|||
import Navbar from "../components/Navbar.jsx"; |
|||
import { useEffect, useState } from "react"; |
|||
import Tag from "../components/Tag.jsx"; |
|||
import { getChannel, searchByUsername } from "../services/user.service.js"; |
|||
import { uploadVideo, uploadThumbnail, uploadTags } from "../services/video.service.js"; |
|||
import UserCard from "../components/UserCard.jsx"; |
|||
import LoadingVideoModal from "../modals/LoadingVideoModal.jsx"; |
|||
import { useNavigate } from "react-router-dom"; |
|||
|
|||
export default function AddVideo() { |
|||
|
|||
const storedUser = localStorage.getItem("user"); |
|||
const user = storedUser ? JSON.parse(storedUser) : null; |
|||
const token = localStorage.getItem("token"); |
|||
const navigation = useNavigate(); |
|||
|
|||
const [videoTitle, setVideoTitle] = useState(""); |
|||
const [videoDescription, setVideoDescription] = useState(""); |
|||
const [videoTags, setVideoTags] = useState([]); |
|||
const [visibility, setVisibility] = useState("private"); |
|||
const [videoThumbnail, setVideoThumbnail] = useState(null); |
|||
const [videoFile, setVideoFile] = useState(null); |
|||
const [channel, setChannel] = useState(null); |
|||
const [searchUser, setSearchUser] = useState(""); |
|||
const [authorizedUsers, setAuthorizedUsers] = useState([]); |
|||
const [searchResults, setSearchResults] = useState([]); |
|||
const [alerts, setAlerts] = useState([]); |
|||
const [loadingState, setLoadingState] = useState("none"); |
|||
const [loadingMessage, setLoadingMessage] = useState(""); |
|||
|
|||
useEffect(() => { |
|||
fetchChannel(); |
|||
}, []) |
|||
|
|||
const fetchChannel = async () => { |
|||
const fetchedChannel = await getChannel(user.id, token, addAlert); |
|||
setChannel(fetchedChannel.channel); |
|||
console.log(fetchedChannel.channel); |
|||
} |
|||
|
|||
const handleTagKeyDown = (e) => { |
|||
if (e.key === 'Enter' && videoTags.length < 10) { |
|||
e.preventDefault(); |
|||
const newTag = e.target.value.trim(); |
|||
if (newTag && !videoTags.includes(newTag)) { |
|||
setVideoTags([...videoTags, newTag]); |
|||
e.target.value = ''; |
|||
} |
|||
} |
|||
} |
|||
const handleTagRemove = (tagToRemove) => { |
|||
setVideoTags(videoTags.filter(tag => tag !== tagToRemove)); |
|||
}; |
|||
|
|||
// This function handles the submission of the video form |
|||
const handleSubmit = async (e) => { |
|||
e.preventDefault(); |
|||
|
|||
console.log(channel) |
|||
setLoadingState("loading"); |
|||
setLoadingMessage("Envoie de la vidéo..."); |
|||
|
|||
if (!videoTitle || !videoDescription || !videoThumbnail || !videoFile) { |
|||
addAlert('error', 'Veuillez remplir tous les champs requis.'); |
|||
return; |
|||
} |
|||
if (!channel || !channel.id) { |
|||
addAlert('error', 'Chaîne non valide veuillez recharger la page.'); |
|||
return; |
|||
} |
|||
if (videoTags.length > 10) { |
|||
addAlert('error', 'Vous ne pouvez pas ajouter plus de 10 tags.'); |
|||
return; |
|||
} |
|||
|
|||
const formData = new FormData(); |
|||
formData.append("title", videoTitle); |
|||
formData.append("description", videoDescription); |
|||
formData.append("channel", channel.id.toString()); |
|||
formData.append("visibility", visibility); |
|||
formData.append("authorizedUsers", JSON.stringify(authorizedUsers.map(user => user.id))); |
|||
formData.append("file", videoFile); |
|||
|
|||
const request = await uploadVideo(formData, token, addAlert); |
|||
|
|||
setLoadingMessage("Envoie de la miniature..."); |
|||
|
|||
// If the video was successfully created, we can now upload the thumbnail |
|||
const response = await request.json(); |
|||
const videoId = response.id; |
|||
const thumbnailFormData = new FormData(); |
|||
thumbnailFormData.append("video", videoId); |
|||
thumbnailFormData.append("file", videoThumbnail); |
|||
thumbnailFormData.append("channel", channel.id.toString()); |
|||
await uploadThumbnail(thumbnailFormData, token, addAlert); |
|||
|
|||
setLoadingMessage("Envoie des tags..."); |
|||
// if the thumbnail was successfully uploaded, we can send the tags |
|||
const body = { |
|||
tags: videoTags, |
|||
channel: channel.id.toString() |
|||
}; |
|||
await uploadTags(body, videoId, token, addAlert); |
|||
// If everything is successful, redirect to the video management page |
|||
navigation("/manage-channel/" + channel.id); |
|||
addAlert('success', 'Vidéo ajoutée avec succès !'); |
|||
|
|||
|
|||
}; |
|||
|
|||
const addAlert = (type, message) => { |
|||
const newAlert = { type, message, id: Date.now() }; // Add unique ID |
|||
setAlerts([...alerts, newAlert]); |
|||
}; |
|||
|
|||
const onCloseAlert = (alertToRemove) => { |
|||
setAlerts(alerts.filter(alert => alert !== alertToRemove)); |
|||
}; |
|||
|
|||
const onUserSearch = (e) => { |
|||
const searchUser = e.target.value; |
|||
if (searchUser.trim() !== "") { |
|||
// Call the API to search for users |
|||
searchByUsername(searchUser, token, addAlert) |
|||
.then((results) => { |
|||
console.log(results); |
|||
setSearchResults(results); |
|||
}) |
|||
.catch((error) => { |
|||
addAlert('error', 'Erreur lors de la recherche d\'utilisateurs.'); |
|||
}); |
|||
} else { |
|||
setSearchResults([]); |
|||
} |
|||
} |
|||
|
|||
const onAuthorizedUserAdd = (user) => { |
|||
|
|||
// Verify if user is not already authorized |
|||
if (authorizedUsers.find((u) => u.id === user.id)) { |
|||
addAlert('error', 'Utilisateur déjà autorisé.'); |
|||
setSearchUser("") |
|||
setSearchResults([]); |
|||
return; |
|||
} |
|||
|
|||
setAuthorizedUsers([...authorizedUsers, user]); |
|||
setSearchResults([]); |
|||
setSearchUser("") |
|||
}; |
|||
|
|||
const onAuthorizedUserRemove = (user) => { |
|||
setAuthorizedUsers(authorizedUsers.filter((u) => u.id !== user.id)); |
|||
}; |
|||
|
|||
return ( |
|||
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> |
|||
<Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} /> |
|||
|
|||
<main className="px-5 lg:px-36 pt-[118px]"> |
|||
<h1 className="font-montserrat text-2xl font-black text-white"> |
|||
Ajouter une vidéo |
|||
</h1> |
|||
|
|||
<div className="flex gap-8 mt-8"> |
|||
{/* Left side: Form for adding video details */} |
|||
<form className="flex-1"> |
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoTitle">Titre de la vidéo</label> |
|||
<input |
|||
type="text" |
|||
id="videoTitle" |
|||
name="videoTitle" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
placeholder="Entrez le titre de la vidéo" |
|||
value={videoTitle} |
|||
onChange={(e) => setVideoTitle(e.target.value)} |
|||
required |
|||
/> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoDescription">Description de la vidéo</label> |
|||
<textarea |
|||
id="videoDescription" |
|||
name="videoDescription" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
placeholder="Entrez la description de la vidéo" |
|||
rows="4" |
|||
value={videoDescription} |
|||
onChange={(e) => setVideoDescription(e.target.value)} |
|||
required |
|||
></textarea> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-1" htmlFor="videoDescription">Tags</label> |
|||
<input |
|||
type="text" |
|||
id="videoTags" |
|||
name="videoTags" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
placeholder="Entrez les tags de la vidéo (entrée pour valider) 10 maximum" |
|||
onKeyDown={handleTagKeyDown} |
|||
|
|||
/> |
|||
<div className="flex flex-wrap gap-2 mb-2"> |
|||
{videoTags.map((tag, index) => ( |
|||
<Tag tag={tag} doShowControls={true} key={index} onSuppress={() => handleTagRemove(tag)} /> |
|||
))} |
|||
</div> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="visibility">Visibilité</label> |
|||
<select |
|||
name="visibility" |
|||
id="visibility" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
value={visibility} |
|||
onChange={(e) => setVisibility(e.target.value)} |
|||
> |
|||
<option value="public">Public</option> |
|||
<option value="private">Privé</option> |
|||
</select> |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
{ |
|||
visibility == "private" && ( |
|||
<div className="mb-4 glassmorphism p-4"> |
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="authorizedUsers">Utilisateurs autorisés</label> |
|||
<div className="mb-4"> |
|||
<input |
|||
type="text" |
|||
id="authorizedUsers" |
|||
name="authorizedUsers" |
|||
className="w-full p-2 mb-2 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
placeholder="Rechercher un utilisateur" |
|||
value={searchUser} |
|||
onChange={(e) => setSearchUser(e.target.value)} |
|||
onKeyDown={(e) => { |
|||
onUserSearch(e) |
|||
}} |
|||
/> |
|||
<div> |
|||
{searchResults && searchResults.map((user, index) => ( |
|||
<UserCard |
|||
user={user} |
|||
onSubmit={onAuthorizedUserAdd} |
|||
doShowControls={true} |
|||
key={index} |
|||
control={ |
|||
<button type="button" className="bg-primary text-white font-montserrat p-3 rounded-lg text-lg font-bold cursor-pointer"> |
|||
ajouter |
|||
</button> |
|||
} |
|||
/> |
|||
))} |
|||
</div> |
|||
</div> |
|||
|
|||
|
|||
|
|||
<div className="max-h-40 overflow-y-auto"> |
|||
{authorizedUsers.length > 0 ? authorizedUsers.map((user, index) => ( |
|||
<UserCard |
|||
user={user} |
|||
onSubmit={onAuthorizedUserRemove} |
|||
doShowControls={true} |
|||
key={index} |
|||
control={ |
|||
<button type="button" className="bg-red-500 text-white font-montserrat p-3 rounded-lg text-lg font-bold cursor-pointer"> |
|||
supprimer |
|||
</button> |
|||
} |
|||
/> |
|||
)) : ( |
|||
<p className="text-white">Aucun utilisateur autorisé</p> |
|||
)} |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoThumbnail">Miniature</label> |
|||
<input |
|||
type="file" |
|||
id="videoThumbnail" |
|||
name="videoThumbnail" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
accept="image/*" |
|||
onChange={(e) => setVideoThumbnail(e.target.files[0])} |
|||
required |
|||
/> |
|||
|
|||
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoFile">Fichier vidéo</label> |
|||
<input |
|||
type="file" |
|||
id="videoFile" |
|||
name="videoFile" |
|||
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white" |
|||
accept="video/*" |
|||
onChange={(e) => setVideoFile(e.target.files[0])} |
|||
required |
|||
/> |
|||
|
|||
<button |
|||
type="submit" |
|||
className="bg-primary text-white font-montserrat p-3 rounded-lg text-2xl font-bold w-full cursor-pointer" |
|||
onClick={(e) => { handleSubmit(e) }} |
|||
> |
|||
Ajouter la vidéo |
|||
</button> |
|||
|
|||
</form> |
|||
|
|||
{/* Right side: Preview of the video being added */} |
|||
<div className="flex-1 hidden lg:flex justify-center"> |
|||
<div className="glassmorphism p-4 rounded-lg"> |
|||
<img |
|||
src={videoThumbnail ? URL.createObjectURL(videoThumbnail) : "https://placehold.co/1280x720"} alt={videoTitle} |
|||
className="w-[480px] h-auto mb-4 rounded-lg" |
|||
/> |
|||
<h2 className="text-white text-xl font-montserrat font-semibold mb-2">{videoTitle || "Titre de la vidéo"}</h2> |
|||
|
|||
<div className="glassmorphism p-4 rounded-sm"> |
|||
<p className="text-white font-inter mb-2"> |
|||
{videoDescription || "Description de la vidéo"} |
|||
</p> |
|||
|
|||
<div className="flex flex-wrap gap-2"> |
|||
{videoTags.length > 0 ? ( |
|||
videoTags.map((tag, index) => ( |
|||
<Tag tag={tag} doShowControls={false} key={index} /> |
|||
)) |
|||
) : ( |
|||
<span className="text-gray-400">Aucun tag ajouté</span> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
</main> |
|||
<LoadingVideoModal state={loadingState} message={loadingMessage} /> |
|||
</div> |
|||
); |
|||
|
|||
} |
|||
@ -0,0 +1,115 @@ |
|||
import Navbar from "../components/Navbar.jsx"; |
|||
import {useEffect, useState} from "react"; |
|||
import {useParams} from "react-router-dom"; |
|||
import {fetchChannelDetails, subscribe} from "../services/channel.service.js"; |
|||
import TabLayout from "../components/TabLayout.jsx"; |
|||
import ChannelLastVideos from "../components/ChannelLastVideos.jsx"; |
|||
import { isSubscribed } from "../services/user.service.js"; |
|||
|
|||
|
|||
export default function Channel() { |
|||
|
|||
const id = useParams().id; |
|||
|
|||
const [alerts, setAlerts] = useState([]); |
|||
const [channel, setChannel] = useState(null); |
|||
const [isSubscribedToChannel, setIsSubscribedToChannel] = useState(false); |
|||
const tabs = [ |
|||
{ id: 'last', label: 'Dernières vidéos', element: () => <ChannelLastVideos videos={channel && channel.videos.slice(0, 10)} /> }, |
|||
{ id: 'all', label: 'Toutes les vidéos', element: () => <ChannelLastVideos videos={channel && channel.videos} /> }, |
|||
]; |
|||
|
|||
useEffect(() => { |
|||
async function fetchData() { |
|||
const chan = await fetchChannelDetails(id, addAlert); |
|||
setChannel(chan); |
|||
// If not authenticated, isSubscribed may be undefined -> default to false |
|||
const subscribed = await isSubscribed(id, addAlert); |
|||
setIsSubscribedToChannel(Boolean(subscribed)); |
|||
} |
|||
fetchData(); |
|||
}, [id]) |
|||
|
|||
const addAlert = (type, message) => { |
|||
const newAlert = { type, message, id: Date.now() }; // Add unique ID |
|||
setAlerts(prev => [...prev, newAlert]); |
|||
}; |
|||
const onCloseAlert = (alertToRemove) => { |
|||
setAlerts(alerts.filter(alert => alert !== alertToRemove)); |
|||
} |
|||
|
|||
const handleSubscribe = async () => { |
|||
try { |
|||
const result = await subscribe(id, addAlert); |
|||
// Update local counter from API response |
|||
const newCount = Number(result?.subscriptions ?? (channel?.subscriptions ?? 0)); |
|||
setChannel(prev => (prev ? { ...prev, subscriptions: newCount } : prev)); |
|||
|
|||
// Toggle local subscription state and notify |
|||
const next = !isSubscribedToChannel; |
|||
setIsSubscribedToChannel(next); |
|||
} catch (e) { |
|||
// Error alert already handled in service |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> |
|||
<Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} /> |
|||
|
|||
<main className="pt-[48px] lg:pt-[118px] px-5 lg:px-36" > |
|||
|
|||
{/* Channel Header */} |
|||
<div className="glassmorphism p-4" > |
|||
<div className="lg:flex items-center gap-4" > |
|||
<img |
|||
src={channel && channel.picture} |
|||
alt={channel && channel.name} |
|||
className="w-[96px] lg:w-[192px] aspect-square object-cover rounded-full border-2 border-white" |
|||
/> |
|||
<div className="flex items-center" > |
|||
<div> |
|||
<h1 className="font-montserrat font-bold text-3xl text-white" >{channel && channel.name}</h1> |
|||
<p className="font-montserrat font-medium text-xl text-white" >{channel && channel.subscriptions} abonné(es)</p> |
|||
</div> |
|||
{ |
|||
isSubscribedToChannel ? ( |
|||
<button |
|||
className="ml-5 bg-primary text-white font-montserrat font-bold px-4 py-1 h-1/2 rounded-md cursor-pointer" |
|||
onClick={handleSubscribe} |
|||
> |
|||
se désabonner |
|||
</button> |
|||
) : ( |
|||
<button |
|||
className="ml-5 bg-primary text-white font-montserrat font-bold px-4 py-1 h-1/2 rounded-md cursor-pointer" |
|||
onClick={handleSubscribe} |
|||
> |
|||
s'abonner |
|||
</button> |
|||
) |
|||
} |
|||
</div> |
|||
</div> |
|||
|
|||
<h2 className="font-bold font-montserrat text-white text-xl mt-4" >Description</h2> |
|||
<p className="text-white text-lg font-medium font-montserrat pl-[24px]"> |
|||
{ channel && channel.description } |
|||
</p> |
|||
|
|||
</div> |
|||
|
|||
{/* Tab selector */} |
|||
|
|||
<TabLayout tabs={tabs}/> |
|||
|
|||
{/* 10 Last videos */} |
|||
|
|||
|
|||
|
|||
</main> |
|||
|
|||
</div> |
|||
) |
|||
|
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
import { useEffect, useRef } from 'react'; |
|||
import { useNavigate, useSearchParams } from 'react-router-dom'; |
|||
import { useAuth } from '../contexts/AuthContext'; |
|||
|
|||
export default function LoginSuccess() { |
|||
const navigate = useNavigate(); |
|||
const [searchParams] = useSearchParams(); |
|||
const { loginWithOAuth } = useAuth(); |
|||
const hasProcessed = useRef(false); |
|||
|
|||
useEffect(() => { |
|||
// Prevent multiple executions |
|||
if (hasProcessed.current) return; |
|||
|
|||
const token = searchParams.get('token'); |
|||
const userParam = searchParams.get('user'); |
|||
|
|||
console.log('Processing OAuth callback:', { token: !!token, userParam: !!userParam }); |
|||
|
|||
if (token && userParam) { |
|||
try { |
|||
hasProcessed.current = true; |
|||
|
|||
const userData = JSON.parse(decodeURIComponent(userParam)); |
|||
console.log('Parsed user data:', userData); |
|||
|
|||
// Use the OAuth login method to update auth context |
|||
loginWithOAuth(userData, token); |
|||
|
|||
// Small delay before navigation to ensure state is updated |
|||
setTimeout(() => { |
|||
navigate('/', { replace: true }); |
|||
}, 100); |
|||
|
|||
} catch (error) { |
|||
console.error('Error processing OAuth login:', error); |
|||
hasProcessed.current = true; |
|||
setTimeout(() => { |
|||
navigate('/login?error=invalid_data', { replace: true }); |
|||
}, 100); |
|||
} |
|||
} else { |
|||
console.log('Missing token or user data'); |
|||
hasProcessed.current = true; |
|||
setTimeout(() => { |
|||
navigate('/login?error=missing_data', { replace: true }); |
|||
}, 100); |
|||
} |
|||
}, []); // Remove dependencies to prevent re-runs |
|||
|
|||
return ( |
|||
<div className="min-h-screen flex items-center justify-center bg-gray-50"> |
|||
<div className="text-center"> |
|||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500 mx-auto"></div> |
|||
<p className="mt-4 text-gray-600">Finalisation de la connexion...</p> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||