Initial commit - Projet Managerr

This commit is contained in:
mahek 2025-07-21 15:43:20 +02:00
commit 848a79a04e
36 changed files with 3850 additions and 0 deletions

65
frontend/src/App.vue Normal file
View 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>

View 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;
}
}

View 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;
}

View file

@ -0,0 +1,202 @@
<template>
<div class="calendar">
<h2 class="calendar-title">
{{ currentMonthYear }}
<div class="calendar-nav">
<button @click="previousMonth">&lt;</button>
<button @click="nextMonth">&gt;</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>

View 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>

View 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
View 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')

View 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
View 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
}
})

View 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>

View 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>

View 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>

View 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>

View 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>