Initial commit - Projet Managerr
This commit is contained in:
commit
848a79a04e
36 changed files with 3850 additions and 0 deletions
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue