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.
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.
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
Dépendances à inclure :
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); }
MediaService
Méthodes à implémenter :
findAll()
findById(Long id)
findByType(TypeMedia type)
rechercher(String titre)
MediaController → GET /catalogue → media/liste.html
MediaController
GET /catalogue
media/liste.html
La page doit contenir :
q
th:href
@{}
th:text
#numbers.formatDecimal
th:switch/case
badge bg-primary
badge bg-success
badge bg-warning text-dark
th:if/unless
th:if="${#lists.isEmpty(medias)}"
Paramètres URL acceptés :
/catalogue
/catalogue?type=FILM
/catalogue?q=star
Créez un CommandLineRunner (ou @EventListener) qui ajoute au démarrage :
CommandLineRunner
@EventListener
Résultat attendu : Tableau complet avec filtres fonctionnels.
MediaController → GET /catalogue/{id} → media/detail.html
GET /catalogue/{id}
media/detail.html
La page doit afficher :
urlImage
th:src
th:if
th:utext
th:each
${#numbers.sequence(1, media.note.intValue())}
onsubmit="return confirm(...)"
Gérez l’erreur 404 : Si le média n’existe pas, retournez la page error/404.html via un @ControllerAdvice.
error/404.html
@ControllerAdvice
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; }
GET /catalogue/nouveau → media/formulaire.html POST /catalogue/sauvegarder → redirige vers /catalogue/{id} en cas de succès
GET /catalogue/nouveau
media/formulaire.html
POST /catalogue/sauvegarder
/catalogue/{id}
Le formulaire doit contenir :
TypeMedia.values()
th:errorclass="is-invalid"
<div class="invalid-feedback" th:errors="*{champ}">
GET /catalogue/{id}/modifier → même template media/formulaire.html
GET /catalogue/{id}/modifier
Le template doit être réutilisé pour la création ET la modification :
modeEdition
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).
setTimeout
Résultat attendu : Formulaire avec validation affichant les erreurs champ par champ.
Dans static/css/style.css, ajoutez :
static/css/style.css
/* 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; }
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 :
col-md-4
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é.
aria-label
aria-current
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
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
Créez un endpoint REST dans un @RestController :
@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.
# 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.
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()); }; } }
/* =================================================== 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; }
<!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 & Spring Boot</p> </div> </footer> </body> </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>