Aller au contenu

TP Médiathèque en ligne « CineBook »

Technologies : Java 17+, Spring Boot 3.x, Thymeleaf, Spring Data JPA, H2, Bootstrap 5 ou une autre version Progressivité : Ce TP est découpé en 6 étapes, chacune s’appuyant sur la précédente.


Contexte

cinéBook page accueil

Vous développez CineBook, une médiathèque en ligne permettant de gérer un catalogue de médias (livres, films, séries). L’interface est entièrement en Thymeleaf avec Bootstrap 5.


Structure du projet attendue

cinebook/
├── src/main/java/com/cinebook/
│   ├── CineBookApplication.java
│   ├── config/
│   │   └── DataInitializer.java
│   ├── controller/
│   │   ├── AccueilController.java
│   │   └── MediaController.java
│   ├── domain/
│   │   ├── Media.java
│   │   └── TypeMedia.java     (enum : LIVRE, FILM, SERIE)
│   ├── dto/
│   │   └── MediaForm.java
│   ├── repository/
│   │   └── MediaRepository.java
│   └── service/
│       └── MediaService.java
└── src/main/resources/
    ├── templates/
    │   ├── fragments/
    │   │   └── layout.html
    │   ├── media/
    │   │   ├── liste.html
    │   │   ├── detail.html
    │   │   └── formulaire.html
    │   ├── error/
    │   │   └── 404.html
    │   └── index.html
    ├── static/
    │   └── css/
    │       └── style.css
    ├── messages.properties
    └── application.properties

Étape 1 — Mise en place et page d’accueil

1.1 Créez le projet Spring Boot

Dépendances à inclure :

1.2 Configurez application.properties

spring.datasource.url=jdbc:h2:mem:cinebook
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.h2.console.enabled=true
spring.thymeleaf.cache=false
server.port=8080

Version YAML :

spring:
  datasource:
    url: jdbc:h2:mem:cinebook
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
  h2:
    console:
      enabled: true

  thymeleaf:
    cache: false
    prefix: classpath:/templates/
    suffix: .html
    encoding: UTF-8
```yaml

### 1.3 Créez l'entité `Media`

L'entité doit avoir :

- `id` (Long, auto-généré)
- `titre` (String, obligatoire, max 255 caractères)
- `realisateurOuAuteur` (String, obligatoire)
- `annee` (Integer, entre 1888 et l'année actuelle)
- `type` (TypeMedia enum : LIVRE, FILM, SERIE)
- `synopsis` (String, optionnel, max 2000 caractères)
- `note` (Double, entre 0.0 et 10.0, optionnel)
- `disponible` (boolean, défaut true)
- `urlImage` (String, optionnel — URL d'une image de couverture)
- `dateAjout` (LocalDate, initialisé à aujourd'hui)

### 1.4 Créez le layout Thymeleaf

Créez `templates/fragments/layout.html` avec :

- Une **navbar Bootstrap** avec le logo "🎬 CineBook" et des liens : Accueil, Catalogue, Ajouter
- Un espace pour les **messages flash** (succès/erreur)
- Une zone de **contenu principal** (`<th:block th:replace="${content}">`)
- Un **footer** avec le texte "© 2024 CineBook"
- L'intégration de **Bootstrap 5** via CDN

### 1.5 Créez la page d'accueil

`AccueilController` avec `GET /` → retourne `index.html`

La page `index.html` doit afficher :

- Un jumbotron (grande bannière) avec un titre de bienvenue
- 3 cartes Bootstrap montrant des statistiques :
  - Nombre total de médias (passé par le controller)
  - Nombre de films
  - Nombre de livres

Passez ces données depuis le controller via le `Model`.

**Résultat attendu :** Page d'accueil avec les statistiques qui s'affichent.

---

## Étape 2 — Liste des médias

### 2.1 Créez le `MediaRepository`

```java
public interface MediaRepository extends JpaRepository<Media, Long> {
    List<Media> findByTypeOrderByTitreAsc(TypeMedia type);
    List<Media> findByTitreContainingIgnoreCaseOrderByTitreAsc(String titre);
    long countByType(TypeMedia type);
}

2.2 Créez le MediaService

Méthodes à implémenter :

2.3 Créez la page de liste

liste des médias du catalogue

MediaControllerGET /cataloguemedia/liste.html

La page doit contenir :

Paramètres URL acceptés :

2.4 Initialisez des données de démo

Créez un CommandLineRunner (ou @EventListener) qui ajoute au démarrage :

Résultat attendu : Tableau complet avec filtres fonctionnels.


Étape 3 — Page de détail

3.1 Page de détail

Détail d'un média

MediaControllerGET /catalogue/{id}media/detail.html

La page doit afficher :

Gérez l’erreur 404 : Si le média n’existe pas, retournez la page error/404.html via un @ControllerAdvice.


Étape 4 — Formulaire d’ajout et de modification

liste des médias du catalogue

4.1 Créez le MediaForm

public class MediaForm {
    private Long id;

    @NotBlank(message = "Le titre est obligatoire")
    @Size(max = 255)
    private String titre;

    @NotBlank(message = "Le réalisateur/auteur est obligatoire")
    private String realisateurOuAuteur;

    @NotNull(message = "L'année est obligatoire")
    @Min(1888) @Max(2030)
    private Integer annee;

    @NotNull(message = "Le type est obligatoire")
    private TypeMedia type;

    @Size(max = 2000)
    private String synopsis;

    @DecimalMin("0.0") @DecimalMax("10.0")
    private Double note;

    private boolean disponible = true;
    private String urlImage;
}

4.2 Formulaire d’ajout

GET /catalogue/nouveaumedia/formulaire.html POST /catalogue/sauvegarder → redirige vers /catalogue/{id} en cas de succès

Le formulaire doit contenir :

4.3 Formulaire de modification

GET /catalogue/{id}/modifier → même template media/formulaire.html

Le template doit être réutilisé pour la création ET la modification :

4.4 Messages flash

Après création → message de succès : “Le média [titre] a été ajouté !” Après modification → “Le média [titre] a été modifié.” Après suppression → “Le média a été supprimé.”

Ces messages doivent apparaître dans le layout et disparaître automatiquement (utilisez Bootstrap + JavaScript setTimeout).

Résultat attendu : Formulaire avec validation affichant les erreurs champ par champ.


Étape 5 — Améliorations CSS et UX

5.1 Créez votre fichier CSS

Dans static/css/style.css, ajoutez :

/* Exemple de ce qui est attendu — personnalisez librement */

/* Carte hover effect */
.media-card {
    transition: transform 0.2s, box-shadow 0.2s;
    cursor: pointer;
}
.media-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}

/* Barre de navigation personnalisée */
.navbar-brand {
    font-size: 1.5rem;
    font-weight: bold;
}

/* Badge de type */
.badge-type { font-size: 0.85rem; }

5.2 Page d’accueil avec cartes cliquables

Sur la page d’accueil, affichez les 6 derniers médias ajoutés sous forme de cartes Bootstrap (3 par ligne, col-md-4). Chaque carte doit :

5.3 Fil d’Ariane (Breadcrumb)

Ajoutez un fil d’Ariane Bootstrap sur les pages détail et formulaire :

Accueil > Catalogue > [Titre du média]

Utilisez les attributs aria-label et aria-current pour l’accessibilité.


Étape 6 — BONUS JavaScript et recherche dynamique

6.1 Disparition automatique des alertes

Dans votre layout, ajoutez un script JavaScript qui fait disparaître les alertes après 4 secondes :

// À compléter : utilisez setTimeout et les classes Bootstrap fade/show

6.2 Compteur de caractères pour le synopsis

Dans le formulaire, ajoutez un compteur qui affiche “X / 2000 caractères” sous le textarea du synopsis, et passe en rouge quand on approche de la limite.

// À compléter : utilisez oninput et textContent

6.3 Recherche dynamique (fetch API)

Créez un endpoint REST dans un @RestController :

GET /api/medias/recherche?q=... → JSON

Dans la page de liste, remplacez le formulaire de recherche classique par une recherche en temps réel :

Résultat attendu : La liste se filtre dynamiquement sans rechargement de page.


Tests rapides à effectuer

# Lancer l'application
mvn spring-boot:run

# Tester les URLs
open http://localhost:8080           # Accueil
open http://localhost:8080/catalogue # Liste
open http://localhost:8080/catalogue/1 # Détail
open http://localhost:8080/catalogue/nouveau # Formulaire ajout
open http://localhost:8080/h2-console # Console H2 (vérifier les données)

# Tester les filtres
open http://localhost:8080/catalogue?type=FILM
open http://localhost:8080/catalogue?q=star

# Tester la validation
# → Soumettez le formulaire sans remplir les champs obligatoires
# → Vérifiez que les erreurs s'affichent champ par champ

# Tester la 404
open http://localhost:8080/catalogue/9999

Conseil pédagogique : Avancez étape par étape. Ne passez à l’étape suivante que quand la précédente fonctionne. Testez dans votre navigateur après chaque modification. Utilisez les outils de développement (F12) pour inspecter le HTML généré par Thymeleaf.

fichier DataInitializer

package com.cinebook.config;

import java.time.LocalDate;
import java.util.List;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.cinebook.domain.Media;
import com.cinebook.domain.TypeMedia;
import com.cinebook.repository.MediaRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Configuration
@RequiredArgsConstructor
@Slf4j
public class DataInitializer {

    @Bean
    CommandLineRunner initDemoData(MediaRepository repo) {
        return args -> {
            if (repo.count() > 0) return;

            List<Media> demos = List.of(
                Media.builder().titre("Inception").realisateurOuAuteur("Christopher Nolan")
                    .annee(2010).type(TypeMedia.FILM).note(8.8).disponible(true)
                    .synopsis("Un voleur qui s'infiltre dans les rêves de ses cibles pour leur dérober leurs secrets.")
                    .urlImage("/images/Inception.jpg")
                    .dateAjout(LocalDate.now()).build(),

                Media.builder().titre("Interstellar").realisateurOuAuteur("Christopher Nolan")
                    .annee(2014).type(TypeMedia.FILM).note(8.6).disponible(true)
                    .synopsis("Un groupe d'astronautes voyage à travers un trou de ver pour trouver un nouveau foyer pour l'humanité.")
                    .dateAjout(LocalDate.now().minusDays(2)).build(),

                Media.builder().titre("The Dark Knight").realisateurOuAuteur("Christopher Nolan")
                    .annee(2008).type(TypeMedia.FILM).note(9.0).disponible(true)
                    .synopsis("Batman affronte le Joker, un criminel anarchiste qui veut plonger Gotham dans le chaos.")
                    .dateAjout(LocalDate.now().minusDays(5)).build(),

                Media.builder().titre("Clean Code").realisateurOuAuteur("Robert C. Martin")
                    .annee(2008).type(TypeMedia.LIVRE).note(9.2).disponible(true)
                    .synopsis("Un manuel incontournable pour écrire du code lisible, maintenable et élégant.")
                    .dateAjout(LocalDate.now().minusDays(1)).build(),

                Media.builder().titre("The Pragmatic Programmer").realisateurOuAuteur("David Thomas & Andrew Hunt")
                    .annee(1999).type(TypeMedia.LIVRE).note(8.9).disponible(true)
                    .synopsis("Guide pratique pour devenir un.e développeur.euse plus efficace et professionnel.")
                    .dateAjout(LocalDate.now().minusDays(3)).build(),

                Media.builder().titre("Dune").realisateurOuAuteur("Frank Herbert")
                    .annee(1965).type(TypeMedia.LIVRE).note(8.7).disponible(false)
                    .synopsis("Sur la planète désertique Arrakis, Paul Atréides devient le messie d'un peuple opprimé.")
                    .dateAjout(LocalDate.now().minusDays(7)).build(),

                Media.builder().titre("Breaking Bad").realisateurOuAuteur("Vince Gilligan")
                    .annee(2008).type(TypeMedia.SERIE).note(9.5).disponible(true)
                    .synopsis("Un professeur de chimie atteint d'un cancer se reconvertit dans la fabrication de méthamphétamine.")
                    .dateAjout(LocalDate.now().minusDays(4)).build(),

                Media.builder().titre("TChernobyl").realisateurOuAuteur("Craig Mazin")
                    .annee(2019).type(TypeMedia.SERIE).note(9.4).disponible(true)
                    .synopsis("Reconstitution de la catastrophe nucléaire de Tchernobyl et de ses conséquences...")
                    .dateAjout(LocalDate.now().minusDays(6)).build(),
                    
                Media.builder().titre("La Vénus électrique").realisateurOuAuteur("Pierre Salvadori")
                    .annee(2026).type(TypeMedia.FILM).note(10.0).disponible(true)
                    .synopsis("Paris, 1928. Antoine Balestro, jeune peintre en vogue, n’arrive plus à travailler"
                    		+ " depuis la mort de son épouse et désespère Armand, son galeriste. Un soir d'ivresse,"
                    		+ " Antoine tente d’entrer en contact avec sa femme par l’intermédiaire d’une voyante."
                    		+ " Sans le savoir, il parle en réalité avec Suzanne, une modeste foraine qui s’est glissée"
                    		+ " dans la roulotte pour y voler de la nourriture. Suzanne se révèle douée pour l’imposture et,"
                    		+ " rapidement secondée par Armand, elle enchaîne les fausses séances. Peu à peu,"
                    		+ " Antoine retrouve l'inspiration, mais pour Suzanne les choses se compliquent"
                    		+ " alors qu’elle tombe doucement amoureuse de l’homme qu’elle manipule...")
                    .urlImage("/images/lavenuselectrique.png")
                    .dateAjout(LocalDate.now().minusDays(1)).build()
            );

            repo.saveAll(demos);
            log.info("{} médias de démonstration chargés.", demos.size());
        };
    }
}

CSS complet

/* ===================================================
   CineBook — Feuille de style personnalisée basique
   =================================================== */

/* --- Bannière d'accueil --- */
.hero-banner {
    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
    border-bottom: 3px solid #e94560;
}

/* --- Cartes de médias cliquables --- */
.media-card {
    transition: transform 0.2s ease, box-shadow 0.2s ease;
    cursor: pointer;
    overflow: hidden;
}

.media-card:hover {
    transform: translateY(-5px);
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15) !important;
}

/* --- Couvertures de médias --- */
.media-cover {
    width: 100%;
    height: 180px;
    object-fit: cover;
    background-color: #6c757d;
}

.media-cover-placeholder {
    width: 100%;
    height: 180px;
    font-size: 3rem;
}

/* --- Navbar --- */
.navbar-brand {
    font-size: 1.4rem;
    letter-spacing: 0.5px;
}

/* --- Tableau --- */
.table tbody tr {
    transition: background-color 0.15s ease;
}

/* --- Formulaire --- */
.form-control:focus,
.form-select:focus {
    border-color: #0d6efd;
    box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
}

/* --- Bouton groupe --- */
.btn-group .btn.active {
    font-weight: bold;
}

/* --- Badges de type --- */
.badge-type {
    font-size: 0.8rem;
}

/* --- Animations alertes flash --- */
.alert {
    animation: slideDown 0.3s ease;
}

@keyframes slideDown {
    from { opacity: 0; transform: translateY(-10px); }
    to   { opacity: 1; transform: translateY(0); }
}

/* --- Footer --- */
footer {
    margin-top: auto;
}

body {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

main, .container {
    flex: 1;
}

Layout

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="fr">
<head th:fragment="head(titre)">
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title th:text="${titre != null ? titre + ' | CineBook' : 'CineBook'}">CineBook</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
    <link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>

<!-- ================================================
     FRAGMENT : navbar
     ================================================ -->
<nav th:fragment="navbar" class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
    <div class="container">
        <a class="navbar-brand fw-bold" th:href="@{/}">
            CinéBook
        </a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMenu">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navMenu">
            <ul class="navbar-nav ms-auto">
                <li class="nav-item">
                    <a class="nav-link" th:href="@{/}">
                        <i class="bi bi-house"></i> Accueil
                    </a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" th:href="@{/catalogue}">
                        <i class="bi bi-collection"></i> Catalogue
                    </a>
                </li>
                <li class="nav-item">
                    <a class="nav-link btn btn-success text-white ms-2 px-3" th:href="@{/catalogue/nouveau}">
                        <i class="bi bi-plus-circle"></i> Ajouter
                    </a>
                </li>
            </ul>
        </div>
    </div>
</nav>

<!-- ================================================
     FRAGMENT : messages flash
     ================================================ -->
<div th:fragment="messages" class="container mt-3">
    <div th:if="${successMessage}"
         class="alert alert-success alert-dismissible fade show d-flex align-items-center"
         role="alert" id="flash-success">
        <i class="bi bi-check-circle-fill me-2"></i>
        <span th:text="${successMessage}">Succès</span>
        <button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
    </div>
    <div th:if="${errorMessage}"
         class="alert alert-danger alert-dismissible fade show d-flex align-items-center"
         role="alert" id="flash-error">
        <i class="bi bi-exclamation-triangle-fill me-2"></i>
        <span th:text="${errorMessage}">Erreur</span>
        <button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
    </div>
</div>

<!-- ================================================
     FRAGMENT : footer
     ================================================ -->
<footer th:fragment="footer" class="bg-dark text-white text-center py-4 mt-5">
    <div class="container">
        <p class="mb-1">🎬 <strong>CineBook</strong> — Votre médiathèque en ligne</p>
        <p class="mb-0 text-muted small">© 2024 — Formation Thymeleaf &amp; Spring Boot</p>
    </div>
</footer>

</body>
</html>

index.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="fr">
<head th:replace="~{fragments/layout :: head('Accueil')}"></head>
<body class="bg-light">

<nav th:replace="~{fragments/layout :: navbar}"></nav>
<div th:replace="~{fragments/layout :: messages}"></div>

<!-- Jumbotron -->
<div class="hero-banner text-white text-center py-5 mb-4">
    <div class="container py-3">
        <h1 class="display-4 fw-bold">🎬 Bienvenue sur CinéBook</h1>
        <p class="lead mb-4">Gérez votre médiathèque : films, livres et séries en un seul endroit.</p>
        <a th:href="@{/catalogue}" class="btn btn-light btn-lg me-2">
            <i class="bi bi-collection"></i> Parcourir le catalogue
        </a>
        <a th:href="@{/catalogue/nouveau}" class="btn btn-outline-light btn-lg">
            <i class="bi bi-plus-circle"></i> Ajouter un média
        </a>
    </div>
</div>

<!-- Statistiques -->
<div class="container mb-5">
    <h2 class="mb-4 text-center">📊 Le catalogue en chiffres</h2>
    <div class="row g-3 justify-content-center">

        <div class="col-6 col-md-3">
            <div class="card text-center shadow-sm border-0 h-100">
                <div class="card-body">
                    <div class="display-5 fw-bold text-primary" th:text="${totalMedias}">0</div>
                    <div class="text-muted">Total médias</div>
                </div>
            </div>
        </div>

        <div class="col-6 col-md-3">
            <div class="card text-center shadow-sm border-0 h-100">
                <div class="card-body">
                    <div class="display-5 fw-bold text-info" th:text="${totalFilms}">0</div>
                    <div class="text-muted">🎬 Films</div>
                </div>
            </div>
        </div>

        <div class="col-6 col-md-3">
            <div class="card text-center shadow-sm border-0 h-100">
                <div class="card-body">
                    <div class="display-5 fw-bold text-success" th:text="${totalLivres}">0</div>
                    <div class="text-muted">📚 Livres</div>
                </div>
            </div>
        </div>

        <div class="col-6 col-md-3">
            <div class="card text-center shadow-sm border-0 h-100">
                <div class="card-body">
                    <div class="display-5 fw-bold text-warning" th:text="${totalSeries}">0</div>
                    <div class="text-muted">📺 Séries</div>
                </div>
            </div>
        </div>
    </div>
</div>

<!-- Derniers ajouts -->
<div class="container mb-5">
    <div class="d-flex justify-content-between align-items-center mb-4">
        <h2 class="mb-0">🆕 Derniers ajouts</h2>
        <a th:href="@{/catalogue}" class="btn btn-outline-primary btn-sm">Voir tout →</a>
    </div>

    <div class="row g-3">
        <div class="col-md-4 col-lg-2" th:each="media : ${derniersMedias}">
            <a th:href="@{/catalogue/{id}(id=${media.id})}" class="text-decoration-none">
                <div class="card media-card h-100 border-0 shadow-sm">
                    <!-- Image de couverture -->
                    <img th:if="${media.urlImage != null and not #strings.isEmpty(media.urlImage)}"
                         th:src="${media.urlImage}"
                         th:alt="${media.titre}"
                         class="card-img-top media-cover"
                         onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">
                    <div th:unless="${media.urlImage != null and not #strings.isEmpty(media.urlImage)}"
                         class="media-cover-placeholder d-flex align-items-center justify-content-center bg-secondary text-white">
                        <span th:switch="${media.type}">
                            <span th:case="'FILM'">🎬</span>
                            <span th:case="'LIVRE'">📚</span>
                            <span th:case="'SERIE'">📺</span>
                            <span th:case="*">🎭</span>
                        </span>
                    </div>
                    <div class="card-body p-2">
                        <p class="card-title small fw-bold mb-1 text-dark"
                           th:text="${#strings.abbreviate(media.titre, 30)}">Titre</p>
                        <span th:switch="${media.type}" class="badge badge-type">
                            <span th:case="'FILM'" class="badge bg-primary">Film</span>
                            <span th:case="'LIVRE'" class="badge bg-success">Livre</span>
                            <span th:case="'SERIE'" class="badge bg-warning text-dark">Série</span>
                        </span>
                    </div>
                </div>
            </a>
        </div>

        <div class="col-12 text-center text-muted py-4" th:if="${#lists.isEmpty(derniersMedias)}">
            <i class="bi bi-inbox fs-1"></i>
            <p class="mt-2">Aucun média dans le catalogue pour l'instant.</p>
            <a th:href="@{/catalogue/nouveau}" class="btn btn-primary">Ajouter le premier</a>
        </div>
    </div>
</div>

<footer th:replace="~{fragments/layout :: footer}"></footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script th:src="@{/js/app.js}"></script>
</body>
</html>