Initial commit - Projet Managerr
This commit is contained in:
commit
848a79a04e
36 changed files with 3850 additions and 0 deletions
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Fichiers de logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Fichiers de l'environnement
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Fichiers de build
|
||||||
|
/backend/dist/
|
||||||
|
/frontend/dist/
|
||||||
|
|
||||||
|
# Fichiers et dossiers de cache
|
||||||
|
.npm
|
||||||
|
.eslintcache
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Fichiers du système d'exploitation
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
136
README.md
Normal file
136
README.md
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
# Managerr
|
||||||
|
|
||||||
|
Une application web pour gérer vos films et séries TV en utilisant les API de Sonarr et Radarr.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- **Authentification** : Système complet d'inscription et de connexion pour les utilisateurs
|
||||||
|
- **Tableau de bord** : Affichage d'un agenda mensuel avec les téléchargements prévus
|
||||||
|
- **Films** : Gestion complète de votre collection de films
|
||||||
|
- Liste de tous les films
|
||||||
|
- Films manquants
|
||||||
|
- Films à venir
|
||||||
|
- Historique des téléchargements
|
||||||
|
- **Séries** : Gestion complète de vos séries TV
|
||||||
|
- Liste de toutes les séries
|
||||||
|
- Épisodes manquants
|
||||||
|
- Épisodes à venir
|
||||||
|
- Historique des téléchargements
|
||||||
|
|
||||||
|
## Technologies utilisées
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Node.js** et **Express.js** pour l'API
|
||||||
|
- **MongoDB** pour la base de données
|
||||||
|
- **JWT** pour l'authentification
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Vue.js 3** avec **Composition API**
|
||||||
|
- **Vue Router** pour la navigation
|
||||||
|
- **Vuex** pour la gestion d'état
|
||||||
|
- **Axios** pour les requêtes HTTP
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Node.js (v14 ou supérieur)
|
||||||
|
- MongoDB
|
||||||
|
- Instances fonctionnelles de Sonarr et Radarr avec leurs API accessibles
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Configuration du backend
|
||||||
|
|
||||||
|
1. Cloner le dépôt
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/votre-username/managerr.git
|
||||||
|
cd managerr
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Installer les dépendances du backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Créer un fichier `.env` basé sur le fichier `.env.example`
|
||||||
|
```bash
|
||||||
|
cp ../config/.env.example ../config/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Modifier le fichier `.env` avec vos propres paramètres
|
||||||
|
```
|
||||||
|
PORT=5000
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/managerr
|
||||||
|
JWT_SECRET=votre_secret_jwt
|
||||||
|
SONARR_API_URL=http://votre-ip-sonarr:8989/api/v3
|
||||||
|
SONARR_API_KEY=votre-cle-api-sonarr
|
||||||
|
RADARR_API_URL=http://votre-ip-radarr:7878/api/v3
|
||||||
|
RADARR_API_KEY=votre-cle-api-radarr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration du frontend
|
||||||
|
|
||||||
|
1. Installer les dépendances du frontend
|
||||||
|
```bash
|
||||||
|
cd ../frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Créer un fichier `.env.local` pour la configuration du frontend
|
||||||
|
```bash
|
||||||
|
echo "VUE_APP_API_URL=http://localhost:5000/api" > .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lancement de l'application
|
||||||
|
|
||||||
|
### Démarrer le backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Démarrer le frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run serve
|
||||||
|
```
|
||||||
|
|
||||||
|
L'application sera accessible à l'adresse : http://localhost:8080
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. Construire le backend pour la production
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. Construire le frontend pour la production
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Déployer le dossier `/dist` sur votre serveur web
|
||||||
|
|
||||||
|
## Contribuer
|
||||||
|
|
||||||
|
1. Fork le projet
|
||||||
|
2. Créer une branche pour votre fonctionnalité (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit vos changements (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push vers la branche (`git push origin feature/amazing-feature`)
|
||||||
|
5. Ouvrir une Pull Request
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Distribué sous la licence MIT. Voir `LICENSE` pour plus d'informations.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Votre Nom - [@votre_twitter](https://twitter.com/votre_twitter) - email@exemple.com
|
||||||
|
|
||||||
|
Lien du projet: [https://github.com/votre-username/managerr](https://github.com/votre-username/managerr)
|
||||||
37
TODO.md
Normal file
37
TODO.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Liste des tâches pour le projet Managerr
|
||||||
|
|
||||||
|
## Configuration et mise en place (⏳ En cours)
|
||||||
|
- [x] Définir les exigences du projet
|
||||||
|
- [x] Mettre en place la structure du projet
|
||||||
|
- [ ] Initialiser les dépôts Git
|
||||||
|
|
||||||
|
## Backend (⏳ En cours)
|
||||||
|
- [x] Choisir un framework backend (Express.js)
|
||||||
|
- [x] Configurer la base de données (MongoDB)
|
||||||
|
- [x] Mettre en place l'architecture MVC
|
||||||
|
- [x] Implémenter l'authentification des utilisateurs
|
||||||
|
- [x] Créer les modèles de données (utilisateurs, films, séries)
|
||||||
|
- [x] Développer les APIs pour communiquer avec Sonarr et Radarr
|
||||||
|
- [x] Implémenter la logique métier pour le traitement des films et séries
|
||||||
|
- [ ] Mettre en place les tests unitaires et d'intégration
|
||||||
|
|
||||||
|
## Frontend (⏳ En cours)
|
||||||
|
- [x] Initialiser le projet Vue.js
|
||||||
|
- [x] Configurer Vue Router pour la navigation
|
||||||
|
- [x] Implémenter Vuex pour la gestion d'état
|
||||||
|
- [x] Créer les composants UI réutilisables
|
||||||
|
- [x] Développer la page de connexion/inscription
|
||||||
|
- [x] Construire le tableau de bord avec l'agenda mensuel
|
||||||
|
- [x] Créer la page des films avec les différentes vues
|
||||||
|
- [x] Créer la page des séries avec les différentes vues
|
||||||
|
- [x] Implémenter les appels API vers le backend
|
||||||
|
- [x] Ajouter des validations de formulaire et gestion des erreurs
|
||||||
|
- [x] Appliquer un design responsive
|
||||||
|
- [ ] Optimisation des performances
|
||||||
|
|
||||||
|
## Documentation (⏳ En cours)
|
||||||
|
- [x] Documentation technique
|
||||||
|
- [x] Guide d'utilisation
|
||||||
|
- [ ] Documentation API
|
||||||
|
|
||||||
|
## État général du projet: 🏗️ En développement
|
||||||
100
backend/controllers/authController.js
Normal file
100
backend/controllers/authController.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const User = require('../models/User');
|
||||||
|
|
||||||
|
// Inscription d'un nouvel utilisateur
|
||||||
|
exports.register = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, email, password } = req.body;
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur existe déjà
|
||||||
|
let user = await User.findOne({ email });
|
||||||
|
if (user) {
|
||||||
|
return res.status(400).json({ message: 'Cet utilisateur existe déjà' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer un nouvel utilisateur
|
||||||
|
user = new User({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hashage du mot de passe
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
user.password = await bcrypt.hash(password, salt);
|
||||||
|
|
||||||
|
// Sauvegarder l'utilisateur dans la base de données
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Générer un token JWT
|
||||||
|
const payload = {
|
||||||
|
user: {
|
||||||
|
id: user.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
jwt.sign(
|
||||||
|
payload,
|
||||||
|
process.env.JWT_SECRET || 'secret',
|
||||||
|
{ expiresIn: '24h' },
|
||||||
|
(err, token) => {
|
||||||
|
if (err) throw err;
|
||||||
|
res.json({ token });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
res.status(500).send('Erreur serveur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connexion d'un utilisateur
|
||||||
|
exports.login = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur existe
|
||||||
|
const user = await User.findOne({ email });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(400).json({ message: 'Identifiants invalides' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le mot de passe
|
||||||
|
const isMatch = await bcrypt.compare(password, user.password);
|
||||||
|
if (!isMatch) {
|
||||||
|
return res.status(400).json({ message: 'Identifiants invalides' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer un token JWT
|
||||||
|
const payload = {
|
||||||
|
user: {
|
||||||
|
id: user.id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
jwt.sign(
|
||||||
|
payload,
|
||||||
|
process.env.JWT_SECRET || 'secret',
|
||||||
|
{ expiresIn: '24h' },
|
||||||
|
(err, token) => {
|
||||||
|
if (err) throw err;
|
||||||
|
res.json({ token });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
res.status(500).send('Erreur serveur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer les informations de l'utilisateur connecté
|
||||||
|
exports.getMe = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findById(req.user.id).select('-password');
|
||||||
|
res.json(user);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
res.status(500).send('Erreur serveur');
|
||||||
|
}
|
||||||
|
};
|
||||||
83
backend/controllers/radarrController.js
Normal file
83
backend/controllers/radarrController.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../../config/radarr');
|
||||||
|
|
||||||
|
// Client API pour Radarr
|
||||||
|
const radarrClient = axios.create({
|
||||||
|
baseURL: config.baseURL,
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': config.apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Récupérer tous les films
|
||||||
|
exports.getAllMovies = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await radarrClient.get('/movie');
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des films:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération des films' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer les films manquants
|
||||||
|
exports.getMissingMovies = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await radarrClient.get('/movie');
|
||||||
|
const missingMovies = response.data.filter(movie => !movie.hasFile);
|
||||||
|
res.json(missingMovies);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des films manquants:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération des films manquants' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer les films à venir
|
||||||
|
exports.getUpcomingMovies = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const response = await radarrClient.get('/movie');
|
||||||
|
|
||||||
|
const upcomingMovies = response.data.filter(movie => {
|
||||||
|
const releaseDate = new Date(movie.digitalRelease || movie.physicalRelease || movie.inCinemas);
|
||||||
|
return releaseDate > today;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(upcomingMovies);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des films à venir:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération des films à venir' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer l'historique des téléchargements
|
||||||
|
exports.getHistory = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await radarrClient.get('/history');
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération de l\'historique:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération de l\'historique' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer le calendrier
|
||||||
|
exports.getCalendar = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
const startDate = start || new Date().toISOString();
|
||||||
|
const endDate = end || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const response = await radarrClient.get('/calendar', {
|
||||||
|
params: {
|
||||||
|
start: startDate,
|
||||||
|
end: endDate
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération du calendrier:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération du calendrier' });
|
||||||
|
}
|
||||||
|
};
|
||||||
112
backend/controllers/settingsController.js
Normal file
112
backend/controllers/settingsController.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
const Settings = require('../models/Settings');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// Récupérer les paramètres de l'utilisateur connecté
|
||||||
|
exports.getSettings = async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Rechercher les paramètres pour l'utilisateur connecté
|
||||||
|
const settings = await Settings.findOne({ user: req.user.id });
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return res.status(404).json({ message: "Aucun paramètre trouvé pour cet utilisateur" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ne pas envoyer l'ID de l'utilisateur ni l'ID du document dans la réponse
|
||||||
|
const { sonarr, radarr } = settings;
|
||||||
|
|
||||||
|
res.json({ sonarr, radarr });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des paramètres:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sauvegarder ou mettre à jour les paramètres
|
||||||
|
exports.saveSettings = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sonarr, radarr } = req.body;
|
||||||
|
|
||||||
|
// Validation des entrées
|
||||||
|
if (!sonarr || !radarr) {
|
||||||
|
return res.status(400).json({ message: 'Les configurations Sonarr et Radarr sont requises' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sonarr.url || !sonarr.apiKey || !radarr.url || !radarr.apiKey) {
|
||||||
|
return res.status(400).json({ message: 'Tous les champs sont requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur a déjà des paramètres
|
||||||
|
let settings = await Settings.findOne({ user: req.user.id });
|
||||||
|
|
||||||
|
if (settings) {
|
||||||
|
// Mettre à jour les paramètres existants
|
||||||
|
settings.sonarr = sonarr;
|
||||||
|
settings.radarr = radarr;
|
||||||
|
settings.updatedAt = Date.now();
|
||||||
|
} else {
|
||||||
|
// Créer de nouveaux paramètres
|
||||||
|
settings = new Settings({
|
||||||
|
user: req.user.id,
|
||||||
|
sonarr,
|
||||||
|
radarr
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
res.json({ message: 'Paramètres enregistrés avec succès' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'enregistrement des paramètres:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tester la connexion à une API (Sonarr ou Radarr)
|
||||||
|
exports.testConnection = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, url, apiKey } = req.body;
|
||||||
|
|
||||||
|
// Validation des entrées
|
||||||
|
if (!type || !url || !apiKey) {
|
||||||
|
return res.status(400).json({ message: 'Type, URL et clé API sont requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== 'sonarr' && type !== 'radarr') {
|
||||||
|
return res.status(400).json({ message: 'Type invalide. Doit être "sonarr" ou "radarr"' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentative de connexion à l'API
|
||||||
|
try {
|
||||||
|
// Utiliser le endpoint /system/status qui est disponible à la fois sur Sonarr et Radarr
|
||||||
|
const response = await axios.get(`${url}/system/status`, {
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': apiKey
|
||||||
|
},
|
||||||
|
timeout: 5000 // Timeout de 5 secondes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Connexion établie avec succès',
|
||||||
|
details: {
|
||||||
|
version: response.data.version,
|
||||||
|
appName: response.data.appName
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ success: false, message: 'Échec de la connexion' });
|
||||||
|
}
|
||||||
|
} catch (apiError) {
|
||||||
|
console.error(`Erreur lors du test de l'API ${type}:`, apiError);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Échec de la connexion',
|
||||||
|
error: apiError.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du test de connexion:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
};
|
||||||
76
backend/controllers/sonarrController.js
Normal file
76
backend/controllers/sonarrController.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../../config/sonarr');
|
||||||
|
|
||||||
|
// Client API pour Sonarr
|
||||||
|
const sonarrClient = axios.create({
|
||||||
|
baseURL: config.baseURL,
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': config.apiKey
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Récupérer toutes les séries
|
||||||
|
exports.getAllSeries = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await sonarrClient.get('/series');
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des séries:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération des séries' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer les séries manquantes
|
||||||
|
exports.getMissingSeries = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await sonarrClient.get('/wanted/missing');
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des séries manquantes:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération des séries manquantes' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer les séries à venir
|
||||||
|
exports.getUpcomingSeries = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await sonarrClient.get('/calendar', {
|
||||||
|
params: {
|
||||||
|
start: new Date().toISOString(),
|
||||||
|
end: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() // 30 jours
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des séries à venir:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération des séries à venir' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer l'historique des téléchargements
|
||||||
|
exports.getHistory = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await sonarrClient.get('/history');
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération de l\'historique:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération de l\'historique' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Récupérer le calendrier
|
||||||
|
exports.getCalendar = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { start, end } = req.query;
|
||||||
|
const response = await sonarrClient.get('/calendar', {
|
||||||
|
params: {
|
||||||
|
start: start || new Date().toISOString(),
|
||||||
|
end: end || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération du calendrier:', error);
|
||||||
|
res.status(500).json({ message: 'Erreur lors de la récupération du calendrier' });
|
||||||
|
}
|
||||||
|
};
|
||||||
20
backend/middleware/auth.js
Normal file
20
backend/middleware/auth.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
module.exports = function (req, res, next) {
|
||||||
|
// Récupérer le token du header
|
||||||
|
const token = req.header('x-auth-token');
|
||||||
|
|
||||||
|
// Vérifier si le token existe
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ message: 'Pas de token, autorisation refusée' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le token
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'secret');
|
||||||
|
req.user = decoded.user;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(401).json({ message: 'Token invalide' });
|
||||||
|
}
|
||||||
|
};
|
||||||
39
backend/models/Settings.js
Normal file
39
backend/models/Settings.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const SettingsSchema = new mongoose.Schema({
|
||||||
|
user: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'User',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
sonarr: {
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
radarr: {
|
||||||
|
url: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
trim: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Settings', SettingsSchema);
|
||||||
23
backend/models/User.js
Normal file
23
backend/models/User.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const UserSchema = new mongoose.Schema({
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
unique: true
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('User', UserSchema);
|
||||||
25
backend/package.json
Normal file
25
backend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "managerr-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend pour l'application Managerr - Gestion de films et séries via Sonarr et Radarr",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"mongoose": "^7.5.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.1",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jest": "^29.6.4",
|
||||||
|
"nodemon": "^3.0.1",
|
||||||
|
"supertest": "^6.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/routes/auth.js
Normal file
15
backend/routes/auth.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const authController = require('../controllers/authController');
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Inscription d'un nouvel utilisateur
|
||||||
|
router.post('/register', authController.register);
|
||||||
|
|
||||||
|
// Connexion d'un utilisateur
|
||||||
|
router.post('/login', authController.login);
|
||||||
|
|
||||||
|
// Récupération des informations de l'utilisateur connecté
|
||||||
|
router.get('/me', auth, authController.getMe);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
24
backend/routes/radarr.js
Normal file
24
backend/routes/radarr.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const radarrController = require('../controllers/radarrController');
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Appliquer le middleware d'authentification à toutes les routes
|
||||||
|
router.use(auth);
|
||||||
|
|
||||||
|
// Récupérer tous les films
|
||||||
|
router.get('/movies', radarrController.getAllMovies);
|
||||||
|
|
||||||
|
// Récupérer les films manquants
|
||||||
|
router.get('/movies/missing', radarrController.getMissingMovies);
|
||||||
|
|
||||||
|
// Récupérer les films à venir
|
||||||
|
router.get('/movies/upcoming', radarrController.getUpcomingMovies);
|
||||||
|
|
||||||
|
// Récupérer l'historique des téléchargements
|
||||||
|
router.get('/history', radarrController.getHistory);
|
||||||
|
|
||||||
|
// Récupérer le calendrier
|
||||||
|
router.get('/calendar', radarrController.getCalendar);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
18
backend/routes/settings.js
Normal file
18
backend/routes/settings.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const settingsController = require('../controllers/settingsController');
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Appliquer le middleware d'authentification à toutes les routes
|
||||||
|
router.use(auth);
|
||||||
|
|
||||||
|
// Récupérer les paramètres de l'utilisateur
|
||||||
|
router.get('/', settingsController.getSettings);
|
||||||
|
|
||||||
|
// Enregistrer ou mettre à jour les paramètres
|
||||||
|
router.post('/', settingsController.saveSettings);
|
||||||
|
|
||||||
|
// Tester la connexion à une API
|
||||||
|
router.post('/test-connection', settingsController.testConnection);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
24
backend/routes/sonarr.js
Normal file
24
backend/routes/sonarr.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const sonarrController = require('../controllers/sonarrController');
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Appliquer le middleware d'authentification à toutes les routes
|
||||||
|
router.use(auth);
|
||||||
|
|
||||||
|
// Récupérer toutes les séries
|
||||||
|
router.get('/series', sonarrController.getAllSeries);
|
||||||
|
|
||||||
|
// Récupérer les séries manquantes
|
||||||
|
router.get('/series/missing', sonarrController.getMissingSeries);
|
||||||
|
|
||||||
|
// Récupérer les séries à venir
|
||||||
|
router.get('/series/upcoming', sonarrController.getUpcomingSeries);
|
||||||
|
|
||||||
|
// Récupérer l'historique des téléchargements
|
||||||
|
router.get('/history', sonarrController.getHistory);
|
||||||
|
|
||||||
|
// Récupérer le calendrier
|
||||||
|
router.get('/calendar', sonarrController.getCalendar);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
53
backend/server.js
Normal file
53
backend/server.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
// Chargement des variables d'environnement
|
||||||
|
dotenv.config({ path: '../config/.env' });
|
||||||
|
|
||||||
|
// Initialisation de l'application Express
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const sonarrRoutes = require('./routes/sonarr');
|
||||||
|
const radarrRoutes = require('./routes/radarr');
|
||||||
|
const settingsRoutes = require('./routes/settings');
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/sonarr', sonarrRoutes);
|
||||||
|
app.use('/api/radarr', radarrRoutes);
|
||||||
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
|
||||||
|
// Route de base pour vérifier que l'API fonctionne
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({ message: 'API Managerr fonctionne correctement' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestion des erreurs
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(err.stack);
|
||||||
|
res.status(500).json({ message: 'Une erreur est survenue', error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connexion à la base de données MongoDB
|
||||||
|
const PORT = process.env.PORT || 5000;
|
||||||
|
const DB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/managerr';
|
||||||
|
|
||||||
|
mongoose
|
||||||
|
.connect(DB_URI)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Connecté à la base de données MongoDB');
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Serveur démarré sur le port ${PORT}`);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Erreur de connexion à MongoDB:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
18
config/.env.example
Normal file
18
config/.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Variables d'environnement pour l'application Managerr
|
||||||
|
|
||||||
|
# Port du serveur backend
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# URI MongoDB
|
||||||
|
MONGODB_URI=mongodb://localhost:27017/managerr
|
||||||
|
|
||||||
|
# Secret JWT
|
||||||
|
JWT_SECRET=votre_secret_jwt_securise
|
||||||
|
|
||||||
|
# Configuration API Sonarr
|
||||||
|
SONARR_API_URL=http://localhost:8989/api/v3
|
||||||
|
SONARR_API_KEY=votre_cle_api_sonarr
|
||||||
|
|
||||||
|
# Configuration API Radarr
|
||||||
|
RADARR_API_URL=http://localhost:7878/api/v3
|
||||||
|
RADARR_API_KEY=votre_cle_api_radarr
|
||||||
5
config/radarr.js
Normal file
5
config/radarr.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Configuration pour Radarr API
|
||||||
|
module.exports = {
|
||||||
|
baseURL: process.env.RADARR_API_URL || 'http://localhost:7878/api/v3',
|
||||||
|
apiKey: process.env.RADARR_API_KEY || 'votre-clé-api-radarr'
|
||||||
|
};
|
||||||
5
config/sonarr.js
Normal file
5
config/sonarr.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Configuration pour Sonarr API
|
||||||
|
module.exports = {
|
||||||
|
baseURL: process.env.SONARR_API_URL || 'http://localhost:8989/api/v3',
|
||||||
|
apiKey: process.env.SONARR_API_KEY || 'votre-clé-api-sonarr'
|
||||||
|
};
|
||||||
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "managerr-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build",
|
||||||
|
"lint": "vue-cli-service lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"core-js": "^3.33.0",
|
||||||
|
"vue": "^3.3.0",
|
||||||
|
"vue-router": "^4.2.0",
|
||||||
|
"vuex": "^4.1.0",
|
||||||
|
"date-fns": "^2.30.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/cli-plugin-babel": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-router": "~5.0.0",
|
||||||
|
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||||
|
"@vue/cli-service": "~5.0.0",
|
||||||
|
"eslint": "^8.50.0",
|
||||||
|
"eslint-plugin-vue": "^9.17.0",
|
||||||
|
"sass": "^1.69.0",
|
||||||
|
"sass-loader": "^13.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/public/index.html
Normal file
18
frontend/public/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||||
|
<title>Managerr - Gestion de films et séries</title>
|
||||||
|
<meta name="description" content="Application de gestion de films et séries intégrant Sonarr et Radarr">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>Nous sommes désolés, mais Managerr nécessite JavaScript pour fonctionner. Veuillez l'activer pour continuer.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- construit avec webpack -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
frontend/src/App.vue
Normal file
65
frontend/src/App.vue
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<header v-if="isAuthenticated">
|
||||||
|
<nav-bar />
|
||||||
|
</header>
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import NavBar from '@/components/NavBar.vue'
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'App',
|
||||||
|
components: {
|
||||||
|
NavBar
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['isAuthenticated'])
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$store.dispatch('checkAuth')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
font-family: 'Avenir', Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
color: var(--text-color, #2c3e50);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta tag pour assurer un bon affichage responsive */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 576px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
214
frontend/src/assets/css/responsive.css
Normal file
214
frontend/src/assets/css/responsive.css
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
/* Responsive.css - Styles responsives globaux pour l'application Managerr */
|
||||||
|
|
||||||
|
/* Variables CSS globales */
|
||||||
|
:root {
|
||||||
|
--primary-color: #2c3e50;
|
||||||
|
--secondary-color: #3498db;
|
||||||
|
--background-color: #f5f5f5;
|
||||||
|
--card-bg: white;
|
||||||
|
--text-color: #333;
|
||||||
|
--border-color: #e0e0e0;
|
||||||
|
--success-bg: #d4edda;
|
||||||
|
--success-text: #155724;
|
||||||
|
--warning-bg: #fff3cd;
|
||||||
|
--warning-text: #856404;
|
||||||
|
--danger-bg: #f8d7da;
|
||||||
|
--danger-text: #721c24;
|
||||||
|
--info-bg: #cce5ff;
|
||||||
|
--info-text: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Règles de base pour le responsive design */
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *:before, *:after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Avenir', Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout responsive */
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media queries */
|
||||||
|
|
||||||
|
/* Tablettes */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustement des grilles pour les tablettes */
|
||||||
|
.movie-grid,
|
||||||
|
.series-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustement du tableau de bord */
|
||||||
|
.dashboard-summary {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustement du calendrier */
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 60px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustement des formulaires */
|
||||||
|
.form-container {
|
||||||
|
width: 90% !important;
|
||||||
|
max-width: 500px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobiles */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.navbar {
|
||||||
|
flex-direction: column !important;
|
||||||
|
padding: 0.5rem !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
flex-direction: column !important;
|
||||||
|
width: 100% !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
margin: 0.5rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user {
|
||||||
|
width: 100% !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
margin-top: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.5rem 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustement des grilles pour les mobiles */
|
||||||
|
.movie-grid,
|
||||||
|
.series-grid {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustement des cartes */
|
||||||
|
.movie-card,
|
||||||
|
.series-card {
|
||||||
|
height: auto !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-card .movie-poster,
|
||||||
|
.series-card .series-poster {
|
||||||
|
width: 100px !important;
|
||||||
|
height: 150px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ajustement des tabs */
|
||||||
|
.tabs {
|
||||||
|
overflow-x: auto !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 0.5rem 1rem !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendrier pour mobile */
|
||||||
|
.calendar-grid {
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 50px !important;
|
||||||
|
padding: 0.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables pour mobile */
|
||||||
|
table {
|
||||||
|
display: block !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.5rem !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Petits écrans mobiles */
|
||||||
|
@media (max-width: 360px) {
|
||||||
|
.movie-card,
|
||||||
|
.series-card {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-card .movie-poster,
|
||||||
|
.series-card .series-poster {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 200px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
font-size: 0.7rem !important;
|
||||||
|
padding: 0.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 40px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilitaires responsive */
|
||||||
|
.d-none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-flex {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.d-md-none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.d-md-flex {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.d-sm-none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.d-sm-flex {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend/src/assets/css/theme.css
Normal file
62
frontend/src/assets/css/theme.css
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/* Theme.css - Configuration des thèmes pour l'application Managerr */
|
||||||
|
|
||||||
|
/* Thème clair (défaut) */
|
||||||
|
:root {
|
||||||
|
--primary-color: #2c3e50;
|
||||||
|
--secondary-color: #3498db;
|
||||||
|
--background-color: #f5f5f5;
|
||||||
|
--card-bg: white;
|
||||||
|
--text-color: #333;
|
||||||
|
--border-color: #e0e0e0;
|
||||||
|
--header-bg: #2c3e50;
|
||||||
|
--header-text: white;
|
||||||
|
--success-bg: #d4edda;
|
||||||
|
--success-text: #155724;
|
||||||
|
--warning-bg: #fff3cd;
|
||||||
|
--warning-text: #856404;
|
||||||
|
--danger-bg: #f8d7da;
|
||||||
|
--danger-text: #721c24;
|
||||||
|
--info-bg: #cce5ff;
|
||||||
|
--info-text: #004085;
|
||||||
|
--input-bg: white;
|
||||||
|
--input-border: #ced4da;
|
||||||
|
--input-text: #495057;
|
||||||
|
--button-primary-bg: #2c3e50;
|
||||||
|
--button-primary-text: white;
|
||||||
|
--button-secondary-bg: #e9ecef;
|
||||||
|
--button-secondary-text: #495057;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thème sombre */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--primary-color: #375a7f;
|
||||||
|
--secondary-color: #3498db;
|
||||||
|
--background-color: #222;
|
||||||
|
--card-bg: #333;
|
||||||
|
--text-color: #eee;
|
||||||
|
--border-color: #555;
|
||||||
|
--header-bg: #375a7f;
|
||||||
|
--header-text: white;
|
||||||
|
--success-bg: #204d28;
|
||||||
|
--success-text: #8fd19e;
|
||||||
|
--warning-bg: #533f03;
|
||||||
|
--warning-text: #ffeeba;
|
||||||
|
--danger-bg: #721c24;
|
||||||
|
--danger-text: #f8d7da;
|
||||||
|
--info-bg: #002752;
|
||||||
|
--info-text: #cce5ff;
|
||||||
|
--input-bg: #444;
|
||||||
|
--input-border: #666;
|
||||||
|
--input-text: #eee;
|
||||||
|
--button-primary-bg: #375a7f;
|
||||||
|
--button-primary-text: white;
|
||||||
|
--button-secondary-bg: #555;
|
||||||
|
--button-secondary-text: #eee;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Classes d'utilitaire pour les transitions */
|
||||||
|
.theme-transition {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
202
frontend/src/components/CalendarComponent.vue
Normal file
202
frontend/src/components/CalendarComponent.vue
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
<template>
|
||||||
|
<div class="calendar">
|
||||||
|
<h2 class="calendar-title">
|
||||||
|
{{ currentMonthYear }}
|
||||||
|
<div class="calendar-nav">
|
||||||
|
<button @click="previousMonth"><</button>
|
||||||
|
<button @click="nextMonth">></button>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<div v-for="(day, index) in weekdays" :key="`day-${index}`" class="calendar-header">
|
||||||
|
{{ day }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="day in calendarDays"
|
||||||
|
:key="`day-${day.date}`"
|
||||||
|
class="calendar-day"
|
||||||
|
:class="{ 'current-month': day.currentMonth, 'has-events': day.events.length > 0 }"
|
||||||
|
>
|
||||||
|
<span class="day-number">{{ day.dayNumber }}</span>
|
||||||
|
<div v-if="day.events.length > 0" class="event-count">
|
||||||
|
{{ day.events.length }} élément(s)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, getDay, addDays } from 'date-fns';
|
||||||
|
import { fr } from 'date-fns/locale';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CalendarComponent',
|
||||||
|
props: {
|
||||||
|
events: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentDate: new Date(),
|
||||||
|
weekdays: ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentMonthYear() {
|
||||||
|
return format(this.currentDate, 'MMMM yyyy', { locale: fr });
|
||||||
|
},
|
||||||
|
calendarDays() {
|
||||||
|
// Début et fin du mois courant
|
||||||
|
const startMonth = startOfMonth(this.currentDate);
|
||||||
|
const endMonth = endOfMonth(this.currentDate);
|
||||||
|
|
||||||
|
// Créer tableau de tous les jours du mois
|
||||||
|
const daysInMonth = eachDayOfInterval({ start: startMonth, end: endMonth });
|
||||||
|
|
||||||
|
// Déterminer le premier jour de la semaine (0 = dimanche, 1 = lundi, etc.)
|
||||||
|
let firstDayOfWeek = getDay(startMonth);
|
||||||
|
// Convertir pour que lundi soit le premier jour (0)
|
||||||
|
firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||||
|
|
||||||
|
// Ajouter des jours "vides" avant le début du mois
|
||||||
|
const previousMonthDays = Array(firstDayOfWeek).fill(null).map((_, index) => {
|
||||||
|
const day = addDays(startMonth, -1 * (firstDayOfWeek - index));
|
||||||
|
return {
|
||||||
|
date: format(day, 'yyyy-MM-dd'),
|
||||||
|
dayNumber: format(day, 'd'),
|
||||||
|
currentMonth: false,
|
||||||
|
events: this.getEventsForDay(day)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Jours du mois actuel
|
||||||
|
const currentMonthDays = daysInMonth.map(day => ({
|
||||||
|
date: format(day, 'yyyy-MM-dd'),
|
||||||
|
dayNumber: format(day, 'd'),
|
||||||
|
currentMonth: true,
|
||||||
|
events: this.getEventsForDay(day)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Déterminer combien de jours il faut ajouter à la fin
|
||||||
|
const totalDaysDisplayed = 42; // 6 semaines de 7 jours
|
||||||
|
const remainingDays = totalDaysDisplayed - (previousMonthDays.length + currentMonthDays.length);
|
||||||
|
|
||||||
|
// Jours du mois suivant
|
||||||
|
const nextMonthDays = Array(remainingDays).fill(null).map((_, index) => {
|
||||||
|
const day = addDays(endMonth, index + 1);
|
||||||
|
return {
|
||||||
|
date: format(day, 'yyyy-MM-dd'),
|
||||||
|
dayNumber: format(day, 'd'),
|
||||||
|
currentMonth: false,
|
||||||
|
events: this.getEventsForDay(day)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...previousMonthDays, ...currentMonthDays, ...nextMonthDays];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
previousMonth() {
|
||||||
|
this.currentDate = subMonths(this.currentDate, 1);
|
||||||
|
},
|
||||||
|
nextMonth() {
|
||||||
|
this.currentDate = addMonths(this.currentDate, 1);
|
||||||
|
},
|
||||||
|
getEventsForDay(date) {
|
||||||
|
const dateString = format(date, 'yyyy-MM-dd');
|
||||||
|
return this.events.filter(event => {
|
||||||
|
const eventDate = format(new Date(event.airDateUtc || event.releaseDate), 'yyyy-MM-dd');
|
||||||
|
return eventDate === dateString;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calendar {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav button {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
position: relative;
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-month {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:not(.current-month) {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-events {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
254
frontend/src/components/NavBar.vue
Normal file
254
frontend/src/components/NavBar.vue
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
<template>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="navbar-container">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<router-link to="/dashboard" class="navbar-logo">Managerr</router-link>
|
||||||
|
<button @click="toggleMenu" class="menu-toggle" aria-label="Menu">
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
<span class="bar"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-content" :class="{ 'menu-open': menuOpen }">
|
||||||
|
<div class="navbar-menu">
|
||||||
|
<router-link to="/dashboard" class="nav-item" @click="closeMenu">Tableau de bord</router-link>
|
||||||
|
<router-link to="/movies" class="nav-item" @click="closeMenu">Films</router-link>
|
||||||
|
<router-link to="/series" class="nav-item" @click="closeMenu">Séries</router-link>
|
||||||
|
<router-link to="/settings" class="nav-item" @click="closeMenu">Paramètres</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-user">
|
||||||
|
<theme-toggle />
|
||||||
|
<span v-if="user" class="username">{{ user.username }}</span>
|
||||||
|
<button @click="handleLogout" class="logout-btn">Déconnexion</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapActions, mapGetters } from 'vuex'
|
||||||
|
import ThemeToggle from '@/components/ThemeToggle.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'NavBar',
|
||||||
|
components: {
|
||||||
|
ThemeToggle
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
menuOpen: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['user'])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(['logout']),
|
||||||
|
async handleLogout() {
|
||||||
|
this.closeMenu()
|
||||||
|
await this.logout()
|
||||||
|
this.$router.push('/login')
|
||||||
|
},
|
||||||
|
toggleMenu() {
|
||||||
|
this.menuOpen = !this.menuOpen
|
||||||
|
// Ajouter/retirer une classe au body pour empêcher le défilement quand le menu est ouvert
|
||||||
|
if (this.menuOpen) {
|
||||||
|
document.body.classList.add('menu-open')
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('menu-open')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeMenu() {
|
||||||
|
if (this.menuOpen) {
|
||||||
|
this.menuOpen = false
|
||||||
|
document.body.classList.remove('menu-open')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.navbar {
|
||||||
|
background-color: var(--primary-color, #2c3e50);
|
||||||
|
color: white;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
height: 60px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-logo {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle {
|
||||||
|
display: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
display: block;
|
||||||
|
width: 25px;
|
||||||
|
height: 3px;
|
||||||
|
background-color: white;
|
||||||
|
margin: 5px 0;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: white;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover::after {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active::after {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid white;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles responsives */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-content {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-user {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.navbar-brand {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-logo {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
frontend/src/components/ThemeToggle.vue
Normal file
73
frontend/src/components/ThemeToggle.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<template>
|
||||||
|
<div class="theme-toggle">
|
||||||
|
<button @click="toggleTheme" class="theme-button" :title="buttonTitle">
|
||||||
|
<span v-if="currentTheme === 'light'" class="theme-icon">🌙</span>
|
||||||
|
<span v-else class="theme-icon">☀️</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ThemeToggle',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentTheme: 'light'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
buttonTitle() {
|
||||||
|
return this.currentTheme === 'light'
|
||||||
|
? 'Passer au mode sombre'
|
||||||
|
: 'Passer au mode clair'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleTheme() {
|
||||||
|
this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light'
|
||||||
|
document.documentElement.setAttribute('data-theme', this.currentTheme)
|
||||||
|
localStorage.setItem('theme', this.currentTheme)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// Récupérer le thème actuel
|
||||||
|
const theme = document.documentElement.getAttribute('data-theme') || 'light'
|
||||||
|
this.currentTheme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.theme-button {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
frontend/src/main.js
Normal file
29
frontend/src/main.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import store from './store'
|
||||||
|
|
||||||
|
// Importer les styles
|
||||||
|
import './assets/css/theme.css'
|
||||||
|
import './assets/css/responsive.css'
|
||||||
|
|
||||||
|
// Fonction pour détecter la préférence de thème de l'utilisateur
|
||||||
|
const detectColorScheme = () => {
|
||||||
|
// Vérifie si l'utilisateur a déjà une préférence stockée
|
||||||
|
const savedTheme = localStorage.getItem('theme')
|
||||||
|
if (savedTheme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si l'utilisateur préfère le thème sombre
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer la préférence de thème
|
||||||
|
detectColorScheme()
|
||||||
|
|
||||||
|
// Créer et monter l'application
|
||||||
|
createApp(App).use(store).use(router).mount('#app')
|
||||||
89
frontend/src/router/index.js
Normal file
89
frontend/src/router/index.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import store from '../store'
|
||||||
|
|
||||||
|
// Lazy loading des composants
|
||||||
|
const Home = () => import('../views/Home.vue')
|
||||||
|
const Login = () => import('../views/Login.vue')
|
||||||
|
const Register = () => import('../views/Register.vue')
|
||||||
|
const Dashboard = () => import('../views/Dashboard.vue')
|
||||||
|
const Movies = () => import('../views/Movies.vue')
|
||||||
|
const Series = () => import('../views/Series.vue')
|
||||||
|
const Settings = () => import('../views/Settings.vue')
|
||||||
|
const NotFound = () => import('../views/NotFound.vue')
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: Home
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login,
|
||||||
|
meta: { guestOnly: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'Register',
|
||||||
|
component: Register,
|
||||||
|
meta: { guestOnly: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: Dashboard,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/movies',
|
||||||
|
name: 'Movies',
|
||||||
|
component: Movies,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/series',
|
||||||
|
name: 'Series',
|
||||||
|
component: Series,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: Settings,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
component: NotFound
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(process.env.BASE_URL),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation guards
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const isAuthenticated = store.getters.isAuthenticated
|
||||||
|
|
||||||
|
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
} else if (to.matched.some(record => record.meta.guestOnly)) {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
next('/dashboard')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
122
frontend/src/store/index.js
Normal file
122
frontend/src/store/index.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { createStore } from 'vuex'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_URL = process.env.VUE_APP_API_URL || 'http://localhost:5000/api'
|
||||||
|
|
||||||
|
export default createStore({
|
||||||
|
state: {
|
||||||
|
token: localStorage.getItem('token') || '',
|
||||||
|
user: null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
},
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isAuthenticated: state => !!state.token,
|
||||||
|
user: state => state.user,
|
||||||
|
loading: state => state.loading,
|
||||||
|
error: state => state.error
|
||||||
|
},
|
||||||
|
|
||||||
|
mutations: {
|
||||||
|
AUTH_START(state) {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
AUTH_SUCCESS(state, token) {
|
||||||
|
state.loading = false
|
||||||
|
state.token = token
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
SET_USER(state, user) {
|
||||||
|
state.user = user
|
||||||
|
},
|
||||||
|
AUTH_ERROR(state, error) {
|
||||||
|
state.loading = false
|
||||||
|
state.error = error
|
||||||
|
},
|
||||||
|
CLEAR_ERROR(state) {
|
||||||
|
state.error = null
|
||||||
|
},
|
||||||
|
LOGOUT(state) {
|
||||||
|
state.token = ''
|
||||||
|
state.user = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
// Vérifier si l'utilisateur est authentifié
|
||||||
|
checkAuth({ commit, dispatch }) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
commit('AUTH_SUCCESS', token)
|
||||||
|
dispatch('fetchUser')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Connexion utilisateur
|
||||||
|
async login({ commit, dispatch }, credentials) {
|
||||||
|
commit('AUTH_START')
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_URL}/auth/login`, credentials)
|
||||||
|
const token = response.data.token
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
axios.defaults.headers.common['x-auth-token'] = token
|
||||||
|
commit('AUTH_SUCCESS', token)
|
||||||
|
dispatch('fetchUser')
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
commit('AUTH_ERROR', error.response?.data?.message || 'Erreur de connexion')
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Inscription utilisateur
|
||||||
|
async register({ commit, dispatch }, userData) {
|
||||||
|
commit('AUTH_START')
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_URL}/auth/register`, userData)
|
||||||
|
const token = response.data.token
|
||||||
|
localStorage.setItem('token', token)
|
||||||
|
axios.defaults.headers.common['x-auth-token'] = token
|
||||||
|
commit('AUTH_SUCCESS', token)
|
||||||
|
dispatch('fetchUser')
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
commit('AUTH_ERROR', error.response?.data?.message || 'Erreur d\'inscription')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Récupérer les informations de l'utilisateur
|
||||||
|
async fetchUser({ commit }) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
'x-auth-token': localStorage.getItem('token')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
commit('SET_USER', response.data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération des informations utilisateur', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Déconnexion
|
||||||
|
logout({ commit }) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
delete axios.defaults.headers.common['x-auth-token']
|
||||||
|
commit('LOGOUT')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Effacer les erreurs
|
||||||
|
clearError({ commit }) {
|
||||||
|
commit('CLEAR_ERROR')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
modules: {
|
||||||
|
// Modules supplémentaires pour films et séries si nécessaire
|
||||||
|
}
|
||||||
|
})
|
||||||
306
frontend/src/views/Dashboard.vue
Normal file
306
frontend/src/views/Dashboard.vue
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<h1>Tableau de bord</h1>
|
||||||
|
|
||||||
|
<div class="dashboard-summary">
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>Films</h3>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Total:</span>
|
||||||
|
<span class="stat-value">{{ movieStats.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Manquants:</span>
|
||||||
|
<span class="stat-value">{{ movieStats.missing }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">À venir:</span>
|
||||||
|
<span class="stat-value">{{ movieStats.upcoming }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-card">
|
||||||
|
<h3>Séries</h3>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Total:</span>
|
||||||
|
<span class="stat-value">{{ seriesStats.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Épisodes manquants:</span>
|
||||||
|
<span class="stat-value">{{ seriesStats.missing }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Épisodes à venir:</span>
|
||||||
|
<span class="stat-value">{{ seriesStats.upcoming }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-section">
|
||||||
|
<h2>Calendrier des téléchargements</h2>
|
||||||
|
<calendar-component :events="combinedEvents" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upcoming-section">
|
||||||
|
<h2>Téléchargements à venir (30 prochains jours)</h2>
|
||||||
|
<div class="upcoming-list">
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
Chargement des données...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="combinedEvents.length === 0" class="no-data">
|
||||||
|
Aucun téléchargement prévu pour les 30 prochains jours.
|
||||||
|
</div>
|
||||||
|
<div v-else class="event-cards">
|
||||||
|
<div v-for="event in sortedEvents" :key="event.id" class="event-card">
|
||||||
|
<img :src="event.poster || 'default-poster.jpg'" alt="Poster" class="event-poster">
|
||||||
|
<div class="event-info">
|
||||||
|
<h3 class="event-title">{{ event.title }}</h3>
|
||||||
|
<p class="event-date">{{ formatDate(event.airDateUtc || event.releaseDate) }}</p>
|
||||||
|
<p class="event-type">{{ event.type === 'movie' ? 'Film' : 'Série' }}</p>
|
||||||
|
<p v-if="event.type === 'series'" class="event-episode">
|
||||||
|
S{{ padNumber(event.seasonNumber) }}E{{ padNumber(event.episodeNumber) }} - {{ event.episodeTitle }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CalendarComponent from '@/components/CalendarComponent.vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
|
||||||
|
const API_URL = process.env.VUE_APP_API_URL || 'http://localhost:5000/api'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Dashboard',
|
||||||
|
components: {
|
||||||
|
CalendarComponent
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
movies: [],
|
||||||
|
series: [],
|
||||||
|
movieStats: {
|
||||||
|
total: 0,
|
||||||
|
missing: 0,
|
||||||
|
upcoming: 0
|
||||||
|
},
|
||||||
|
seriesStats: {
|
||||||
|
total: 0,
|
||||||
|
missing: 0,
|
||||||
|
upcoming: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
combinedEvents() {
|
||||||
|
// Fusionner les films et séries à venir en un seul tableau
|
||||||
|
const movieEvents = this.movies.map(movie => ({
|
||||||
|
...movie,
|
||||||
|
type: 'movie',
|
||||||
|
releaseDate: movie.digitalRelease || movie.physicalRelease || movie.inCinemas
|
||||||
|
}))
|
||||||
|
|
||||||
|
const seriesEvents = this.series.map(episode => ({
|
||||||
|
...episode,
|
||||||
|
type: 'series'
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...movieEvents, ...seriesEvents]
|
||||||
|
},
|
||||||
|
sortedEvents() {
|
||||||
|
// Trier les événements par date
|
||||||
|
return [...this.combinedEvents].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.airDateUtc || a.releaseDate)
|
||||||
|
const dateB = new Date(b.airDateUtc || b.releaseDate)
|
||||||
|
return dateA - dateB
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatDate(dateString) {
|
||||||
|
return format(new Date(dateString), 'EEEE d MMMM yyyy', { locale: fr })
|
||||||
|
},
|
||||||
|
padNumber(num) {
|
||||||
|
return String(num).padStart(2, '0')
|
||||||
|
},
|
||||||
|
async fetchData() {
|
||||||
|
try {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
// Configuration des en-têtes pour les requêtes
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
'x-auth-token': localStorage.getItem('token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les données des films
|
||||||
|
const moviesResponse = await axios.get(`${API_URL}/radarr/movies`, config)
|
||||||
|
const missingMoviesResponse = await axios.get(`${API_URL}/radarr/movies/missing`, config)
|
||||||
|
const upcomingMoviesResponse = await axios.get(`${API_URL}/radarr/movies/upcoming`, config)
|
||||||
|
|
||||||
|
// Récupérer les données des séries
|
||||||
|
const seriesResponse = await axios.get(`${API_URL}/sonarr/series`, config)
|
||||||
|
const missingSeriesResponse = await axios.get(`${API_URL}/sonarr/series/missing`, config)
|
||||||
|
const upcomingSeriesResponse = await axios.get(`${API_URL}/sonarr/series/upcoming`, config)
|
||||||
|
|
||||||
|
// Mise à jour des statistiques
|
||||||
|
this.movieStats = {
|
||||||
|
total: moviesResponse.data.length,
|
||||||
|
missing: missingMoviesResponse.data.length,
|
||||||
|
upcoming: upcomingMoviesResponse.data.length
|
||||||
|
}
|
||||||
|
|
||||||
|
this.seriesStats = {
|
||||||
|
total: seriesResponse.data.length,
|
||||||
|
missing: missingSeriesResponse.data.records ? missingSeriesResponse.data.records.length : 0,
|
||||||
|
upcoming: upcomingSeriesResponse.data.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stocker les données des événements à venir
|
||||||
|
this.movies = upcomingMoviesResponse.data
|
||||||
|
this.series = upcomingSeriesResponse.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des données du tableau de bord:', error)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
display: flex;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-poster {
|
||||||
|
width: 80px;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-info {
|
||||||
|
padding: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-date {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-type {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-episode {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
160
frontend/src/views/Login.vue
Normal file
160
frontend/src/views/Login.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-form">
|
||||||
|
<h1>Connexion</h1>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
v-model="email"
|
||||||
|
required
|
||||||
|
placeholder="Votre adresse email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
required
|
||||||
|
placeholder="Votre mot de passe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" :disabled="loading">
|
||||||
|
{{ loading ? 'Connexion en cours...' : 'Se connecter' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<p>Vous n'avez pas de compte? <router-link to="/register">S'inscrire</router-link></p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapActions, mapGetters } from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Login',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['error', 'loading'])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
...mapActions(['login', 'clearError']),
|
||||||
|
async handleLogin() {
|
||||||
|
try {
|
||||||
|
await this.login({
|
||||||
|
email: this.email,
|
||||||
|
password: this.password
|
||||||
|
})
|
||||||
|
this.$router.push('/dashboard')
|
||||||
|
} catch (error) {
|
||||||
|
// L'erreur est gérée dans le store
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.clearError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #1a252f;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #95a5a6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
377
frontend/src/views/Movies.vue
Normal file
377
frontend/src/views/Movies.vue
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
<template>
|
||||||
|
<div class="movie-view">
|
||||||
|
<h1>Films</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'all' }"
|
||||||
|
@click="activeTab = 'all'"
|
||||||
|
>
|
||||||
|
Tous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'missing' }"
|
||||||
|
@click="activeTab = 'missing'"
|
||||||
|
>
|
||||||
|
Manquants
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'upcoming' }"
|
||||||
|
@click="activeTab = 'upcoming'"
|
||||||
|
>
|
||||||
|
À venir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'history' }"
|
||||||
|
@click="activeTab = 'history'"
|
||||||
|
>
|
||||||
|
Historique
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="search"
|
||||||
|
placeholder="Rechercher un film..."
|
||||||
|
@input="filterMovies"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
Chargement des films...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="filteredMovies.length === 0" class="no-data">
|
||||||
|
Aucun film trouvé.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="movie-grid">
|
||||||
|
<div v-for="movie in filteredMovies" :key="movie.id" class="movie-card">
|
||||||
|
<div class="movie-poster">
|
||||||
|
<img :src="movie.images && movie.images[0] ? movie.images[0].url : 'default-poster.jpg'" alt="Poster" />
|
||||||
|
</div>
|
||||||
|
<div class="movie-info">
|
||||||
|
<h3 class="movie-title">{{ movie.title }}</h3>
|
||||||
|
<p class="movie-year">{{ movie.year }}</p>
|
||||||
|
<p class="movie-status">
|
||||||
|
<span :class="getStatusClass(movie)">
|
||||||
|
{{ getStatusText(movie) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="activeTab === 'history'" class="history-list">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Film</th>
|
||||||
|
<th>Événement</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(event, index) in history" :key="index">
|
||||||
|
<td>{{ formatDate(event.date) }}</td>
|
||||||
|
<td>{{ event.movieTitle }}</td>
|
||||||
|
<td>{{ event.eventType }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
|
||||||
|
const API_URL = process.env.VUE_APP_API_URL || 'http://localhost:5000/api'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Movies',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
activeTab: 'all',
|
||||||
|
movies: [],
|
||||||
|
missingMovies: [],
|
||||||
|
upcomingMovies: [],
|
||||||
|
history: [],
|
||||||
|
filteredMovies: [],
|
||||||
|
search: '',
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchData() {
|
||||||
|
try {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
// Configuration des en-têtes pour les requêtes
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
'x-auth-token': localStorage.getItem('token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chargement des données en fonction de l'onglet actif
|
||||||
|
if (this.activeTab === 'all' || this.activeTab === '') {
|
||||||
|
const response = await axios.get(`${API_URL}/radarr/movies`, config)
|
||||||
|
this.movies = response.data
|
||||||
|
this.filteredMovies = this.movies
|
||||||
|
} else if (this.activeTab === 'missing') {
|
||||||
|
const response = await axios.get(`${API_URL}/radarr/movies/missing`, config)
|
||||||
|
this.missingMovies = response.data
|
||||||
|
this.filteredMovies = this.missingMovies
|
||||||
|
} else if (this.activeTab === 'upcoming') {
|
||||||
|
const response = await axios.get(`${API_URL}/radarr/movies/upcoming`, config)
|
||||||
|
this.upcomingMovies = response.data
|
||||||
|
this.filteredMovies = this.upcomingMovies
|
||||||
|
} else if (this.activeTab === 'history') {
|
||||||
|
const response = await axios.get(`${API_URL}/radarr/history`, config)
|
||||||
|
this.history = response.data.records || response.data
|
||||||
|
// Pour l'onglet historique, on n'utilise pas filteredMovies
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si recherche active, appliquer le filtre
|
||||||
|
if (this.search) {
|
||||||
|
this.filterMovies()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des films:', error)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatDate(dateString) {
|
||||||
|
return format(new Date(dateString), 'dd/MM/yyyy HH:mm', { locale: fr })
|
||||||
|
},
|
||||||
|
getStatusText(movie) {
|
||||||
|
if (this.activeTab === 'upcoming') {
|
||||||
|
return 'À venir'
|
||||||
|
} else if (!movie.hasFile) {
|
||||||
|
return 'Manquant'
|
||||||
|
} else {
|
||||||
|
return 'Disponible'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getStatusClass(movie) {
|
||||||
|
if (this.activeTab === 'upcoming') {
|
||||||
|
return 'status-upcoming'
|
||||||
|
} else if (!movie.hasFile) {
|
||||||
|
return 'status-missing'
|
||||||
|
} else {
|
||||||
|
return 'status-available'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filterMovies() {
|
||||||
|
// Fonction pour filtrer les films selon la recherche
|
||||||
|
const searchLower = this.search.toLowerCase()
|
||||||
|
|
||||||
|
// Déterminer la liste source en fonction de l'onglet actif
|
||||||
|
let sourceList = []
|
||||||
|
if (this.activeTab === 'all') {
|
||||||
|
sourceList = this.movies
|
||||||
|
} else if (this.activeTab === 'missing') {
|
||||||
|
sourceList = this.missingMovies
|
||||||
|
} else if (this.activeTab === 'upcoming') {
|
||||||
|
sourceList = this.upcomingMovies
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer le filtre
|
||||||
|
if (searchLower) {
|
||||||
|
this.filteredMovies = sourceList.filter(movie =>
|
||||||
|
movie.title.toLowerCase().includes(searchLower)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.filteredMovies = sourceList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
activeTab() {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.movie-view {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
color: #2c3e50;
|
||||||
|
border-bottom: 2px solid #2c3e50;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-poster {
|
||||||
|
height: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-poster img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-info {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-year {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-status {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.movie-status span {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-available {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-missing {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-upcoming {
|
||||||
|
background-color: #cce5ff;
|
||||||
|
color: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
513
frontend/src/views/Series.vue
Normal file
513
frontend/src/views/Series.vue
Normal file
|
|
@ -0,0 +1,513 @@
|
||||||
|
<template>
|
||||||
|
<div class="series-view">
|
||||||
|
<h1>Séries</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'all' }"
|
||||||
|
@click="activeTab = 'all'"
|
||||||
|
>
|
||||||
|
Toutes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'missing' }"
|
||||||
|
@click="activeTab = 'missing'"
|
||||||
|
>
|
||||||
|
Manquantes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'upcoming' }"
|
||||||
|
@click="activeTab = 'upcoming'"
|
||||||
|
>
|
||||||
|
À venir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-button"
|
||||||
|
:class="{ active: activeTab === 'history' }"
|
||||||
|
@click="activeTab = 'history'"
|
||||||
|
>
|
||||||
|
Historique
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="search"
|
||||||
|
placeholder="Rechercher une série..."
|
||||||
|
@input="filterSeries"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">
|
||||||
|
Chargement des séries...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Vue pour "Toutes" et "Manquantes" -->
|
||||||
|
<div v-if="['all', 'missing'].includes(activeTab)">
|
||||||
|
<div v-if="filteredSeries.length === 0" class="no-data">
|
||||||
|
Aucune série trouvée.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="series-grid">
|
||||||
|
<div v-for="series in filteredSeries" :key="series.id" class="series-card">
|
||||||
|
<div class="series-poster">
|
||||||
|
<img :src="series.images && series.images[0] ? series.images[0].url : 'default-poster.jpg'" alt="Poster" />
|
||||||
|
</div>
|
||||||
|
<div class="series-info">
|
||||||
|
<h3 class="series-title">{{ series.title }}</h3>
|
||||||
|
<p class="series-network">{{ series.network }}</p>
|
||||||
|
<p class="series-status">
|
||||||
|
<span :class="getStatusClass(series)">
|
||||||
|
{{ getStatusText(series) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div class="series-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Saisons:</span>
|
||||||
|
<span class="stat-value">{{ series.statistics ? series.statistics.seasonCount : '?' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Épisodes:</span>
|
||||||
|
<span class="stat-value">{{ series.statistics ? series.statistics.episodeCount : '?' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-label">Manquants:</span>
|
||||||
|
<span class="stat-value">{{ series.statistics ? series.statistics.episodeCount - series.statistics.episodeFileCount : '?' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vue pour "À venir" -->
|
||||||
|
<div v-if="activeTab === 'upcoming'">
|
||||||
|
<div v-if="upcomingEpisodes.length === 0" class="no-data">
|
||||||
|
Aucun épisode à venir.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="episode-list">
|
||||||
|
<div v-for="episode in upcomingEpisodes" :key="episode.id" class="episode-card">
|
||||||
|
<div class="episode-image">
|
||||||
|
<img :src="episode.series.images && episode.series.images[0] ? episode.series.images[0].url : 'default-poster.jpg'" alt="Series Poster" />
|
||||||
|
</div>
|
||||||
|
<div class="episode-info">
|
||||||
|
<h3 class="series-title">{{ episode.series.title }}</h3>
|
||||||
|
<p class="episode-title">S{{ padNumber(episode.seasonNumber) }}E{{ padNumber(episode.episodeNumber) }} - {{ episode.title }}</p>
|
||||||
|
<p class="episode-date">{{ formatDate(episode.airDateUtc) }}</p>
|
||||||
|
<p class="episode-overview">{{ truncateText(episode.overview, 150) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vue pour "Historique" -->
|
||||||
|
<div v-if="activeTab === 'history'" class="history-list">
|
||||||
|
<div v-if="history.length === 0" class="no-data">
|
||||||
|
Aucun historique disponible.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Série</th>
|
||||||
|
<th>Épisode</th>
|
||||||
|
<th>Événement</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(event, index) in history" :key="index">
|
||||||
|
<td>{{ formatDate(event.date) }}</td>
|
||||||
|
<td>{{ event.series ? event.series.title : event.seriesTitle || 'N/A' }}</td>
|
||||||
|
<td>{{ event.episode ? `S${padNumber(event.episode.seasonNumber)}E${padNumber(event.episode.episodeNumber)}` : 'N/A' }}</td>
|
||||||
|
<td>{{ event.eventType }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { fr } from 'date-fns/locale'
|
||||||
|
|
||||||
|
const API_URL = process.env.VUE_APP_API_URL || 'http://localhost:5000/api'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Series',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
activeTab: 'all',
|
||||||
|
series: [],
|
||||||
|
missingSeries: [],
|
||||||
|
upcomingEpisodes: [],
|
||||||
|
history: [],
|
||||||
|
filteredSeries: [],
|
||||||
|
search: '',
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async fetchData() {
|
||||||
|
try {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
// Configuration des en-têtes pour les requêtes
|
||||||
|
const config = {
|
||||||
|
headers: {
|
||||||
|
'x-auth-token': localStorage.getItem('token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chargement des données en fonction de l'onglet actif
|
||||||
|
if (this.activeTab === 'all' || this.activeTab === '') {
|
||||||
|
const response = await axios.get(`${API_URL}/sonarr/series`, config)
|
||||||
|
this.series = response.data
|
||||||
|
this.filteredSeries = this.series
|
||||||
|
} else if (this.activeTab === 'missing') {
|
||||||
|
const response = await axios.get(`${API_URL}/sonarr/series`, config)
|
||||||
|
// Filtrer les séries avec des épisodes manquants
|
||||||
|
this.missingSeries = response.data.filter(series => {
|
||||||
|
return series.statistics &&
|
||||||
|
series.statistics.episodeCount > series.statistics.episodeFileCount
|
||||||
|
})
|
||||||
|
this.filteredSeries = this.missingSeries
|
||||||
|
} else if (this.activeTab === 'upcoming') {
|
||||||
|
const response = await axios.get(`${API_URL}/sonarr/series/upcoming`, config)
|
||||||
|
this.upcomingEpisodes = response.data
|
||||||
|
} else if (this.activeTab === 'history') {
|
||||||
|
const response = await axios.get(`${API_URL}/sonarr/history`, config)
|
||||||
|
this.history = response.data.records || response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si recherche active, appliquer le filtre
|
||||||
|
if (this.search) {
|
||||||
|
this.filterSeries()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des séries:', error)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatDate(dateString) {
|
||||||
|
return format(new Date(dateString), 'dd/MM/yyyy HH:mm', { locale: fr })
|
||||||
|
},
|
||||||
|
padNumber(num) {
|
||||||
|
return String(num).padStart(2, '0')
|
||||||
|
},
|
||||||
|
truncateText(text, maxLength) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
|
||||||
|
},
|
||||||
|
getStatusText(series) {
|
||||||
|
if (!series.statistics) return 'Inconnu'
|
||||||
|
|
||||||
|
if (series.statistics.episodeCount === series.statistics.episodeFileCount) {
|
||||||
|
return 'Complète'
|
||||||
|
} else if (series.statistics.episodeFileCount === 0) {
|
||||||
|
return 'Manquante'
|
||||||
|
} else {
|
||||||
|
return 'Incomplète'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getStatusClass(series) {
|
||||||
|
if (!series.statistics) return 'status-unknown'
|
||||||
|
|
||||||
|
if (series.statistics.episodeCount === series.statistics.episodeFileCount) {
|
||||||
|
return 'status-complete'
|
||||||
|
} else if (series.statistics.episodeFileCount === 0) {
|
||||||
|
return 'status-missing'
|
||||||
|
} else {
|
||||||
|
return 'status-incomplete'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filterSeries() {
|
||||||
|
// Fonction pour filtrer les séries selon la recherche
|
||||||
|
const searchLower = this.search.toLowerCase()
|
||||||
|
|
||||||
|
// Déterminer la liste source en fonction de l'onglet actif
|
||||||
|
let sourceList = []
|
||||||
|
if (this.activeTab === 'all') {
|
||||||
|
sourceList = this.series
|
||||||
|
} else if (this.activeTab === 'missing') {
|
||||||
|
sourceList = this.missingSeries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer le filtre
|
||||||
|
if (searchLower) {
|
||||||
|
this.filteredSeries = sourceList.filter(series =>
|
||||||
|
series.title.toLowerCase().includes(searchLower)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.filteredSeries = sourceList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
activeTab() {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.fetchData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.series-view {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
color: #2c3e50;
|
||||||
|
border-bottom: 2px solid #2c3e50;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-card {
|
||||||
|
display: flex;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-poster {
|
||||||
|
width: 133px; /* Aspect ratio 2:3 pour une hauteur de 200px */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-poster img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-info {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-network {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-status {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-status span {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-complete {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-incomplete {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-missing {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-unknown {
|
||||||
|
background-color: #e2e3e5;
|
||||||
|
color: #383d41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-stats {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour les épisodes à venir */
|
||||||
|
.episode-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card {
|
||||||
|
display: flex;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-image {
|
||||||
|
width: 100px;
|
||||||
|
height: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-info {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-date {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-overview {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour l'historique */
|
||||||
|
.history-list {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
455
frontend/src/views/Settings.vue
Normal file
455
frontend/src/views/Settings.vue
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<h1>Configuration</h1>
|
||||||
|
|
||||||
|
<div v-if="successMessage" class="alert alert-success">
|
||||||
|
{{ successMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="alert alert-danger">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<h2>Configuration API</h2>
|
||||||
|
<p>Configurez les URLs et les clés API pour Sonarr et Radarr</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveSettings" class="settings-form">
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Sonarr</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sonarr-url">URL de l'API Sonarr</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="sonarr-url"
|
||||||
|
v-model="settings.sonarr.url"
|
||||||
|
placeholder="http://localhost:8989/api/v3"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<small class="form-text">L'URL de base de l'API Sonarr (exemple: http://localhost:8989/api/v3)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sonarr-key">Clé API Sonarr</label>
|
||||||
|
<div class="api-key-input">
|
||||||
|
<input
|
||||||
|
:type="showSonarrKey ? 'text' : 'password'"
|
||||||
|
id="sonarr-key"
|
||||||
|
v-model="settings.sonarr.apiKey"
|
||||||
|
placeholder="Votre clé API Sonarr"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-visibility"
|
||||||
|
@click="showSonarrKey = !showSonarrKey"
|
||||||
|
>
|
||||||
|
{{ showSonarrKey ? '🔒' : '👁️' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="form-text">Disponible dans les paramètres de Sonarr sous "Général"</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="button" class="test-button" @click="testConnection('sonarr')">
|
||||||
|
Tester la connexion
|
||||||
|
</button>
|
||||||
|
<span v-if="sonarrStatus === 'success'" class="status-text success">Connexion réussie ✓</span>
|
||||||
|
<span v-if="sonarrStatus === 'error'" class="status-text error">Échec de connexion ✗</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Radarr</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="radarr-url">URL de l'API Radarr</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="radarr-url"
|
||||||
|
v-model="settings.radarr.url"
|
||||||
|
placeholder="http://localhost:7878/api/v3"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<small class="form-text">L'URL de base de l'API Radarr (exemple: http://localhost:7878/api/v3)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="radarr-key">Clé API Radarr</label>
|
||||||
|
<div class="api-key-input">
|
||||||
|
<input
|
||||||
|
:type="showRadarrKey ? 'text' : 'password'"
|
||||||
|
id="radarr-key"
|
||||||
|
v-model="settings.radarr.apiKey"
|
||||||
|
placeholder="Votre clé API Radarr"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-visibility"
|
||||||
|
@click="showRadarrKey = !showRadarrKey"
|
||||||
|
>
|
||||||
|
{{ showRadarrKey ? '🔒' : '👁️' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="form-text">Disponible dans les paramètres de Radarr sous "Général"</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="button" class="test-button" @click="testConnection('radarr')">
|
||||||
|
Tester la connexion
|
||||||
|
</button>
|
||||||
|
<span v-if="radarrStatus === 'success'" class="status-text success">Connexion réussie ✓</span>
|
||||||
|
<span v-if="radarrStatus === 'error'" class="status-text error">Échec de connexion ✗</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="save-button" :disabled="isSaving">
|
||||||
|
{{ isSaving ? 'Enregistrement...' : 'Enregistrer les paramètres' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_URL = process.env.VUE_APP_API_URL || 'http://localhost:5000/api'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SettingsPage',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
sonarr: {
|
||||||
|
url: '',
|
||||||
|
apiKey: ''
|
||||||
|
},
|
||||||
|
radarr: {
|
||||||
|
url: '',
|
||||||
|
apiKey: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showSonarrKey: false,
|
||||||
|
showRadarrKey: false,
|
||||||
|
sonarrStatus: null,
|
||||||
|
radarrStatus: null,
|
||||||
|
successMessage: '',
|
||||||
|
errorMessage: '',
|
||||||
|
isSaving: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadSettings()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadSettings() {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
this.$router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(`${API_URL}/settings`, {
|
||||||
|
headers: {
|
||||||
|
'x-auth-token': token
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
// Si des paramètres existent déjà, les charger
|
||||||
|
if (response.data.sonarr) {
|
||||||
|
this.settings.sonarr = response.data.sonarr
|
||||||
|
}
|
||||||
|
if (response.data.radarr) {
|
||||||
|
this.settings.radarr = response.data.radarr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des paramètres:', error)
|
||||||
|
// Si c'est la première fois que l'utilisateur configure l'app, il n'y aura pas de paramètres
|
||||||
|
// Donc on ne montre pas d'erreur
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async testConnection(type) {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
this.$router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiSettings = this.settings[type]
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_URL}/settings/test-connection`,
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
url: apiSettings.url,
|
||||||
|
apiKey: apiSettings.apiKey
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-auth-token': token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
this[`${type}Status`] = 'success'
|
||||||
|
setTimeout(() => {
|
||||||
|
this[`${type}Status`] = null
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur lors du test de connexion ${type}:`, error)
|
||||||
|
this[`${type}Status`] = 'error'
|
||||||
|
setTimeout(() => {
|
||||||
|
this[`${type}Status`] = null
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveSettings() {
|
||||||
|
try {
|
||||||
|
this.isSaving = true
|
||||||
|
this.successMessage = ''
|
||||||
|
this.errorMessage = ''
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
this.$router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post(
|
||||||
|
`${API_URL}/settings`,
|
||||||
|
this.settings,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-auth-token': token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
this.successMessage = 'Configuration enregistrée avec succès'
|
||||||
|
setTimeout(() => {
|
||||||
|
this.successMessage = ''
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'enregistrement des paramètres:', error)
|
||||||
|
this.errorMessage = 'Une erreur est survenue lors de l\'enregistrement des paramètres'
|
||||||
|
setTimeout(() => {
|
||||||
|
this.errorMessage = ''
|
||||||
|
}, 3000)
|
||||||
|
} finally {
|
||||||
|
this.isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-page {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: var(--text-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px var(--shadow-color);
|
||||||
|
padding: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
color: var(--input-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-input {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-input input {
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-visibility {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 3rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button {
|
||||||
|
background-color: var(--info-bg);
|
||||||
|
color: var(--info-text);
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
margin-left: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text.success {
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text.error {
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
background-color: var(--button-primary-bg);
|
||||||
|
color: var(--button-primary-text);
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: var(--success-bg);
|
||||||
|
color: var(--success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background-color: var(--danger-bg);
|
||||||
|
color: var(--danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles responsifs */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.settings-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
display: block;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
project.md
Executable file
31
project.md
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
Application avec framework JS.
|
||||||
|
De préférence avec VueJS, tu peux choisr le framework pour le backoffice et la base de donnée.
|
||||||
|
|
||||||
|
## Cahier des charges :
|
||||||
|
|
||||||
|
### APIs
|
||||||
|
Utilisation des API de :
|
||||||
|
- https://sonarr.tv/docs/api/
|
||||||
|
- https://radarr.video/docs/api/
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
Avoir un système d'identification
|
||||||
|
Un utilisateur doit être connecter pour accéder à l'application
|
||||||
|
|
||||||
|
### Tableau de bord
|
||||||
|
Un agenda avec tous les jours pour 1 mois dans le futur.
|
||||||
|
Un point par jour avec les films et séries prévu en téléchargement par jour.
|
||||||
|
|
||||||
|
### Films
|
||||||
|
Une page films avec :
|
||||||
|
- Tous les films
|
||||||
|
- Ceux qui sont manquants
|
||||||
|
- Ceux qui sont à venir
|
||||||
|
- Un historique des films télécharger
|
||||||
|
|
||||||
|
### Séries
|
||||||
|
Une page séries avec :
|
||||||
|
- Tous les séries
|
||||||
|
- Ceux qui sont manquants
|
||||||
|
- Ceux qui sont à venir
|
||||||
|
- Un historique des séries télécharger
|
||||||
Loading…
Add table
Add a link
Reference in a new issue