First comit
This commit is contained in:
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
.vscode/launch.json
vendored
Normal file
20
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
.vscode/tasks.json
vendored
Normal file
42
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "(.*?)"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
151
README.md
Normal file
151
README.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# TP - Videotheque
|
||||
|
||||
L'objectif est de mettre en pratique les concepts modernes d'Angular, notamment les **Signals**, pour construire une petite application de gestion de films.
|
||||
|
||||
Vous partirez d'une base de code existante qui inclut déjà toute la configuration de l'application et les services de communication avec les APIs (REST et GraphQL).
|
||||
|
||||
Votre mission sera de compléter la logique des composants pour rendre l'application fonctionnelle.
|
||||
|
||||
## Objectifs Pédagogiques
|
||||
|
||||
À la fin de ce TP, vous saurez :
|
||||
|
||||
1. **Consommer des Observables** et les transformer en **Signals** avec `toSignal`.
|
||||
2. Gérer l'état d'un composant (données, chargement, erreur) à l'aide d'un **Signal** unique.
|
||||
3. Utiliser des **Signals `computed`** pour dériver des données de l'état principal.
|
||||
4. Déclencher des appels asynchrones et gérer leurs cycles de vie avec `effect`.
|
||||
5. Interagir avec des services pour récupérer et envoyer des données.
|
||||
6. Utiliser la nouvelle syntaxe de template (`@if`, `@for`) pour afficher des données réactives.
|
||||
7. Mettre en place et gérer un **formulaire réactif** dans un contexte moderne.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Ouvrir le projet sous WebStorm.
|
||||
2. Ouvrir un terminal dans WebStorm.
|
||||
3. Lancer la commande `npm install` pour installer les dépendances.
|
||||
4. Lancer la commande `ng serve` pour démarrer le serveur de développement.
|
||||
5. Ouvrir le navigateur à l'adresse `http://localhost:4200/`.
|
||||
|
||||
Note : les services APIs (fournis séparément) doivent être en cours d'exécution.
|
||||
|
||||
## Votre mission
|
||||
|
||||
Votre travail consistera à chercher les commentaires `// TODO:` dans les fichiers du dossier `src/app/features/movie` et à implémenter la logique manquante.
|
||||
|
||||
Voici un aperçu de la navigation entre les fonctionnalités que vous allez construire :
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Liste des films /"] -->|Clic sur un film| B["Détail du film /detail/:id"]
|
||||
A -->|"Clic Ajouter"| C["Formulaire ajout /add"]
|
||||
C -->|Sauvegarde réussie| B
|
||||
```
|
||||
|
||||
### Étape 1 : afficher la liste des films
|
||||
|
||||
Le premier objectif est d'afficher la liste des films sur la page d'accueil.
|
||||
|
||||
**Fichiers à modifier :**
|
||||
* `src/app/features/movie/movie-list/movie-list.component.ts`
|
||||
* `src/app/features/movie/movie-list/movie-list.component.html`
|
||||
|
||||
**Instructions :**
|
||||
1. Dans le `component.ts`, utilisez `toSignal` pour transformer le flux de données provenant de `MovieApiService` en un signal.
|
||||
2. Dans le `component.html`, utilisez une boucle `@for` pour itérer sur le signal de films et afficher chaque film dans la liste.
|
||||
|
||||
**Logique attendue pour l'étape 1 :**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Service
|
||||
A[MovieApiService.getMovies] -- "Renvoie Observable<Movie[]>" --> B
|
||||
end
|
||||
subgraph Component
|
||||
B[toSignal] -- "Transforme en Signal<Movie[]>" --> C[filmsSignal]
|
||||
end
|
||||
subgraph Template
|
||||
C -- "Utilisé par" --> D["@for (film of filmsSignal())"]
|
||||
end
|
||||
```
|
||||
|
||||
### Étape 2 : afficher le détail d'un film
|
||||
|
||||
Quand un utilisateur clique sur un film, il doit voir une page de détail avec les informations du film et une analyse générée par une IA (simulée par une API GraphQL plus lente).
|
||||
|
||||
**Fichiers à modifier :**
|
||||
* `src/app/features/movie/movie-detail/movie-detail.component.ts`
|
||||
* `src/app/features/movie/movie-detail/movie-detail.component.html`
|
||||
|
||||
**Instructions :**
|
||||
1. Dans le `component.ts`, complétez les signaux `computed` pour extraire les données (film, analyse, état de chargement) du signal d'état principal `state`.
|
||||
2. Complétez la méthode `fetchData` pour mettre à jour le signal `state` dans les callbacks `next` et `error` des deux appels API.
|
||||
3. Dans le `component.html`, utilisez des blocs `@if` pour gérer les états de chargement et afficher les données des signaux que vous venez de créer.
|
||||
|
||||
**Logique attendue pour l'étape 2 :**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Paramètre de route :id"] -->|Déclenche| B[fetchData]
|
||||
B -->|Met à jour| E["state.set({loading: true})"]
|
||||
|
||||
subgraph "Appels API parallèles"
|
||||
B -->|Appel 1| C["movieApiService.getMovie(id)"]
|
||||
B -->|Appel 2| D["movieApiService.getAnalysis(id)"]
|
||||
end
|
||||
|
||||
subgraph "Mises à jour du signal 'state'"
|
||||
C -- "next" --> F["state.set({data: movie})"]
|
||||
D -- "next" --> G["state.set({analysis: analysis})"]
|
||||
C -- "error" --> H["state.set({error: err})"]
|
||||
D -- "error" --> H
|
||||
end
|
||||
|
||||
subgraph "Consommation dans le template"
|
||||
I["Signal state"] --> J["Computed film"]
|
||||
I --> K["Computed analysis"]
|
||||
I --> L["Computed loading"]
|
||||
M["Template @if"] -- "Lit" --> J
|
||||
M -- "Lit" --> K
|
||||
M -- "Lit" --> L
|
||||
end
|
||||
```
|
||||
|
||||
-----
|
||||
|
||||
### Étape 3 : Créer le formulaire d'ajout
|
||||
|
||||
Enfin, vous allez rendre le formulaire de création de film fonctionnel.
|
||||
|
||||
**Fichiers à modifier :**
|
||||
* `src/app/features/movie/movie-form/movie-form.component.ts`
|
||||
* `src/app/features/movie/movie-form/movie-form.component.html`
|
||||
|
||||
**Instructions :**
|
||||
1. Dans le `component.ts`, utilisez `toSignal` pour récupérer les listes de genres et de participants nécessaires pour les listes déroulantes du formulaire.
|
||||
2. Créez un signal `isSaving` pour suivre l'état de la soumission du formulaire.
|
||||
3. Implémentez la logique de la méthode `saveMovie` pour appeler le service, mettre à jour le signal `isSaving`, et naviguer vers la page du film nouvellement créé en cas de succès.
|
||||
4. Dans le `component.html`, liez les contrôles de formulaire, gérez l'événement `(ngSubmit)` et utilisez le signal `isSaving` pour afficher un message de chargement et désactiver le bouton de soumission.
|
||||
|
||||
**Logique attendue pour l'étape 3 :**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Utilisateur clique Sauvegarder"] -->|Déclenche| B["(ngSubmit)"]
|
||||
B --> C[saveMovie]
|
||||
C --> D["isSaving.set true"]
|
||||
C --> E["movieApiService.save formValue"]
|
||||
|
||||
subgraph Résultat de l'appel
|
||||
E -- "Succès" --> F["isSaving.set false"]
|
||||
F --> G["Navigation vers détail du film"]
|
||||
E -- "Erreur" --> H["isSaving.set false"]
|
||||
H --> I[Affichage erreur]
|
||||
end
|
||||
|
||||
subgraph "Liaison Template"
|
||||
J["Bouton disabled"] -- "Lié à" --> K["Signal isSaving"]
|
||||
L["Spinner de chargement"] -- "Lié à" --> K
|
||||
end
|
||||
```
|
||||
|
||||
Bon courage !
|
||||
98
angular.json
Normal file
98
angular.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"videotheque": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "videotheque:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "videotheque:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
image/setup.png
Normal file
BIN
image/setup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
10252
package-lock.json
generated
Normal file
10252
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "videotheque",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"prettier": {
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^20.3.0",
|
||||
"@angular/compiler": "^20.3.0",
|
||||
"@angular/core": "^20.3.0",
|
||||
"@angular/forms": "^20.3.0",
|
||||
"@angular/platform-browser": "^20.3.0",
|
||||
"@angular/router": "^20.3.0",
|
||||
"@apollo/client": "^3.14.0",
|
||||
"apollo-angular": "^11.0.0",
|
||||
"graphql": "^16.11.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^20.3.3",
|
||||
"@angular/cli": "^20.3.3",
|
||||
"@angular/compiler-cli": "^20.3.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.9.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
25
src/app/app.component.ts
Normal file
25
src/app/app.component.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { HeaderComponent } from './core/layout/header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
template: `
|
||||
<app-header />
|
||||
<main class="container">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
`,
|
||||
styles: `
|
||||
.container {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterOutlet, HeaderComponent],
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'videotheque';
|
||||
}
|
||||
14
src/app/app.config.ts
Normal file
14
src/app/app.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { routes } from './app.routes';
|
||||
import { graphqlProvider } from './core/graphql/graphql.provider';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
graphqlProvider,
|
||||
],
|
||||
};
|
||||
20
src/app/app.routes.ts
Normal file
20
src/app/app.routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'movies',
|
||||
// Lazy loading des routes de la fonctionnalité "films"
|
||||
loadChildren: () =>
|
||||
import('./features/movie/movie.routes').then((m) => m.MOVIE_ROUTES),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'movies',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
// Redirection pour les routes inconnues
|
||||
path: '**',
|
||||
redirectTo: 'movies',
|
||||
},
|
||||
];
|
||||
23
src/app/core/graphql/graphql.provider.ts
Normal file
23
src/app/core/graphql/graphql.provider.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApplicationConfig, inject } from '@angular/core';
|
||||
import { Apollo, APOLLO_OPTIONS } from 'apollo-angular';
|
||||
import { HttpLink } from 'apollo-angular/http';
|
||||
import { ApolloClientOptions, InMemoryCache } from '@apollo/client/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
const uri = environment.apiGraphqlUrl;
|
||||
|
||||
export function apolloOptionsFactory(): ApolloClientOptions<any> {
|
||||
const httpLink = inject(HttpLink);
|
||||
return {
|
||||
link: httpLink.create({ uri }),
|
||||
cache: new InMemoryCache(),
|
||||
};
|
||||
}
|
||||
|
||||
export const graphqlProvider: ApplicationConfig['providers'] = [
|
||||
Apollo,
|
||||
{
|
||||
provide: APOLLO_OPTIONS,
|
||||
useFactory: apolloOptionsFactory,
|
||||
},
|
||||
];
|
||||
13
src/app/core/graphql/graphql.queries.ts
Normal file
13
src/app/core/graphql/graphql.queries.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { gql } from 'apollo-angular';
|
||||
|
||||
export const ANALYZE_MOVIE_QUERY = gql`
|
||||
query AnalyzeMovie($movieId: ID!) {
|
||||
analyzeMovie(movieId: $movieId) {
|
||||
id
|
||||
aiSummary
|
||||
aiOpinionSummary
|
||||
aiBestGenre
|
||||
aiTags
|
||||
}
|
||||
}
|
||||
`;
|
||||
43
src/app/core/layout/header/header.component.scss
Normal file
43
src/app/core/layout/header/header.component.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
:host {
|
||||
display: block;
|
||||
background-color: #1a1a1a;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #ccc;
|
||||
font-size: 1rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: #fff;
|
||||
border-bottom-color: #e50914;
|
||||
}
|
||||
}
|
||||
20
src/app/core/layout/header/header.component.ts
Normal file
20
src/app/core/layout/header/header.component.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
template: `
|
||||
<header class="header">
|
||||
<nav class="header-nav">
|
||||
<a class="logo" routerLink="/">🍿 Vidéothèque</a>
|
||||
<a class="nav-link" routerLink="/movies" routerLinkActive="active">
|
||||
Films
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
`,
|
||||
styleUrls: ['./header.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink],
|
||||
})
|
||||
export class HeaderComponent {}
|
||||
61
src/app/core/models/api.models.ts
Normal file
61
src/app/core/models/api.models.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Interfaces pour l'API REST
|
||||
|
||||
export interface Genre {
|
||||
id: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: number;
|
||||
last_name: string;
|
||||
first_name: string | null;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
login: string;
|
||||
}
|
||||
|
||||
export interface Opinion {
|
||||
id: number;
|
||||
note: number;
|
||||
comment: string;
|
||||
movie_id: number;
|
||||
member: Member;
|
||||
}
|
||||
|
||||
export interface Movie {
|
||||
id: number;
|
||||
title: string;
|
||||
year: number;
|
||||
duration: number | null;
|
||||
synopsis: string | null;
|
||||
genre: Genre;
|
||||
director: Person;
|
||||
actors: Person[];
|
||||
opinions: Opinion[];
|
||||
}
|
||||
|
||||
export interface MovieCreate {
|
||||
title: string;
|
||||
year: number;
|
||||
duration: number | null;
|
||||
synopsis: string | null;
|
||||
genre_id: number;
|
||||
director_id: number;
|
||||
actors_ids: number[];
|
||||
}
|
||||
|
||||
// Interface pour l'API GraphQL
|
||||
export interface MovieAnalysis {
|
||||
id: string;
|
||||
aiSummary: string | null;
|
||||
aiOpinionSummary: string | null;
|
||||
aiBestGenre: string | null;
|
||||
aiTags: string[] | null;
|
||||
}
|
||||
|
||||
// Interface pour les réponses GraphQL
|
||||
export interface AnalyzeMovieResponse {
|
||||
analyzeMovie: MovieAnalysis;
|
||||
}
|
||||
23
src/app/core/services/ai-api.service.ts
Normal file
23
src/app/core/services/ai-api.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Apollo } from 'apollo-angular';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { ANALYZE_MOVIE_QUERY } from '../graphql/graphql.queries';
|
||||
import { AnalyzeMovieResponse, MovieAnalysis } from '../models/api.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AiApiService {
|
||||
private readonly apollo = inject(Apollo);
|
||||
|
||||
getMovieAnalysis(movieId: number): Observable<MovieAnalysis> {
|
||||
return this.apollo
|
||||
.query<AnalyzeMovieResponse>({
|
||||
query: ANALYZE_MOVIE_QUERY,
|
||||
variables: {
|
||||
movieId: movieId.toString(),
|
||||
},
|
||||
// Optionnel mais recommandé : 'network-only' pour toujours avoir des données fraîches
|
||||
fetchPolicy: 'network-only'
|
||||
})
|
||||
.pipe(map((result) => result.data.analyzeMovie));
|
||||
}
|
||||
}
|
||||
15
src/app/core/services/genre-api.service.ts
Normal file
15
src/app/core/services/genre-api.service.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Genre } from '../models/api.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GenreApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiUrl = `${environment.apiRestUrl}/genres`;
|
||||
|
||||
getGenres(): Observable<Genre[]> {
|
||||
return this.http.get<Genre[]>(this.apiUrl);
|
||||
}
|
||||
}
|
||||
23
src/app/core/services/movie-api.service.ts
Normal file
23
src/app/core/services/movie-api.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Movie, MovieCreate } from '../models/api.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MovieApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiUrl = `${environment.apiRestUrl}/movies`;
|
||||
|
||||
getMovies(): Observable<Movie[]> {
|
||||
return this.http.get<Movie[]>(this.apiUrl);
|
||||
}
|
||||
|
||||
getMovieById(id: number): Observable<Movie> {
|
||||
return this.http.get<Movie>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
createMovie(movie: MovieCreate): Observable<Movie> {
|
||||
return this.http.post<Movie>(this.apiUrl, movie);
|
||||
}
|
||||
}
|
||||
15
src/app/core/services/participant-api.service.ts
Normal file
15
src/app/core/services/participant-api.service.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { Person } from '../models/api.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ParticipantApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiUrl = `${environment.apiRestUrl}/participants`;
|
||||
|
||||
getParticipants(): Observable<Person[]> {
|
||||
return this.http.get<Person[]>(this.apiUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<div class="detail-container">
|
||||
@if (isMovieLoading()) {
|
||||
<app-spinner />
|
||||
} @else if (movieError()) {
|
||||
<div class="error-message">{{ movieError() }}</div>
|
||||
} @else if (movie(); as m) {
|
||||
<section class="movie-header">
|
||||
<div class="title-section">
|
||||
<h1 class="movie-title">{{ m.title }}</h1>
|
||||
<div class="subtitle">
|
||||
<span>{{ m.year }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ m.genre.label }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ m.duration | duration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="main-content">
|
||||
<section>
|
||||
<h2>Synopsis</h2>
|
||||
<p>{{ m.synopsis }}</p>
|
||||
</section>
|
||||
<section class="info-section">
|
||||
<div>
|
||||
<h3>Réalisateur</h3>
|
||||
<p>{{ m.director.first_name }} {{ m.director.last_name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Acteurs principaux</h3>
|
||||
<p>{{ actors() }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Avis des membres ({{ m.opinions.length }})</h2>
|
||||
<div class="opinions-list">
|
||||
@for (opinion of m.opinions; track opinion.id) {
|
||||
<div class="opinion-card">
|
||||
<div class="opinion-header">
|
||||
<strong>{{ opinion.member.login }}</strong>
|
||||
<span class="opinion-note">{{ opinion.note }}/5 ★</span>
|
||||
</div>
|
||||
<p class="opinion-comment">"{{ opinion.comment }}"</p>
|
||||
</div>
|
||||
} @empty {
|
||||
<p>Aucun avis pour le moment.</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar-content">
|
||||
<section class="ai-analysis-card">
|
||||
<h2>🤖 Analyse par l'IA</h2>
|
||||
|
||||
@if (state().analysis.loading) {
|
||||
<div class="ai-loading">
|
||||
<app-spinner />
|
||||
<p>Analyse en cours...</p>
|
||||
</div>
|
||||
} @else if (state().analysis.error) {
|
||||
<p class="error-ai">L'analyse IA a échoué.</p>
|
||||
} @else if (analysis(); as an) {
|
||||
@if(an.aiSummary) {
|
||||
<div class="ai-section">
|
||||
<h3>Résumé IA</h3>
|
||||
<p>{{ an.aiSummary }}</p>
|
||||
</div>
|
||||
}
|
||||
@if(an.aiOpinionSummary) {
|
||||
<div class="ai-section">
|
||||
<h3>Synthèse des avis</h3>
|
||||
<p>{{ an.aiOpinionSummary }}</p>
|
||||
</div>
|
||||
}
|
||||
@if(an.aiBestGenre) {
|
||||
<div class="ai-section">
|
||||
<h3>Genre suggéré</h3>
|
||||
<p>{{ an.aiBestGenre }}</p>
|
||||
</div>
|
||||
}
|
||||
@if(an.aiTags && an.aiTags.length > 0) {
|
||||
<div class="ai-section">
|
||||
<h3>Mots-clés</h3>
|
||||
<div class="tags-container">
|
||||
@for(tag of an.aiTags; track tag) {
|
||||
<span class="tag">{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
131
src/app/features/movie/movie-detail/movie-detail.component.scss
Normal file
131
src/app/features/movie/movie-detail/movie-detail.component.scss
Normal file
@@ -0,0 +1,131 @@
|
||||
.detail-container {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.movie-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.movie-title {
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: #ccc;
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 2px solid #e50914;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
margin: 2rem 0;
|
||||
|
||||
h3 {
|
||||
color: #ccc;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.opinions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.opinion-card {
|
||||
background-color: #2c2c2c;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #e50914;
|
||||
}
|
||||
|
||||
.opinion-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.opinion-note {
|
||||
color: #ffb400;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.opinion-comment {
|
||||
font-style: italic;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.ai-analysis-card {
|
||||
background-color: #2c2c2c;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
}
|
||||
|
||||
.ai-section {
|
||||
margin-bottom: 1.5rem;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
h3 {
|
||||
color: #ccc;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background-color: #555;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e50914;
|
||||
background-color: #2c2c2c;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ai-loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
.error-ai {
|
||||
color: #e50914;
|
||||
}
|
||||
|
||||
110
src/app/features/movie/movie-detail/movie-detail.component.ts
Normal file
110
src/app/features/movie/movie-detail/movie-detail.component.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Movie, MovieAnalysis } from '../../../core/models/api.models';
|
||||
import { AiApiService } from '../../../core/services/ai-api.service';
|
||||
import { MovieApiService } from '../../../core/services/movie-api.service';
|
||||
import { SpinnerComponent } from '../../../shared/components/spinner/spinner.component';
|
||||
import { DurationPipe } from '../../../shared/pipes/duration.pipe';
|
||||
|
||||
// Nouvelle structure pour l'état de nos données
|
||||
interface AsyncState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface MovieDetailState {
|
||||
movie: AsyncState<Movie>;
|
||||
analysis: AsyncState<MovieAnalysis>;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-movie-detail',
|
||||
templateUrl: './movie-detail.component.html',
|
||||
styleUrls: ['./movie-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SpinnerComponent, DurationPipe],
|
||||
})
|
||||
export class MovieDetailComponent {
|
||||
id = input.required<number>(); // ID reçu en entrée (transmis par le routeur)
|
||||
|
||||
private readonly movieApiService = inject(MovieApiService);
|
||||
private readonly aiApiService = inject(AiApiService);
|
||||
|
||||
// Le signal d'état est initialisé avec la nouvelle structure
|
||||
readonly state = signal<MovieDetailState>({
|
||||
movie: { data: null, loading: true, error: null },
|
||||
analysis: { data: null, loading: true, error: null },
|
||||
});
|
||||
|
||||
// TODO 2: Créer les signaux `computed` pour lire l'état plus facilement.
|
||||
readonly movie = computed(() => this.state().movie.data);
|
||||
readonly analysis = computed(() => this.state().analysis.data);
|
||||
readonly isMovieLoading = computed(() => this.state().movie.loading);
|
||||
readonly movieError = computed(() => this.state().movie.error);
|
||||
readonly actors = computed(() =>
|
||||
this.state().movie.data?.actors.map(a => `${a.first_name} ${a.last_name}`).join(', ')
|
||||
);
|
||||
|
||||
// On utilise un tableau pour gérer les souscriptions
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
// On nettoie toutes les souscriptions précédentes
|
||||
onCleanup(() => this.subscriptions.forEach(sub => sub.unsubscribe()));
|
||||
|
||||
const movieId = this.id();
|
||||
this.fetchData(movieId);
|
||||
});
|
||||
}
|
||||
|
||||
private fetchData(movieId: number): void {
|
||||
// On réinitialise l'état au début de chaque fetch
|
||||
this.state.set({
|
||||
movie: { data: null, loading: true, error: null },
|
||||
analysis: { data: null, loading: true, error: null },
|
||||
});
|
||||
|
||||
// === SOUSCRIPTION N°1 : DÉTAILS DU FILM (RAPIDE) ===
|
||||
const movieSub = this.movieApiService.getMovieById(movieId).subscribe({
|
||||
next: (movieData) => {
|
||||
|
||||
// TODO 3: Mettre à jour le signal `state` en cas de succès de l'appel `getMovieById`.
|
||||
// - La partie `movie` de l'état doit contenir `movieData`.
|
||||
// - `loading` doit passer à `false`.
|
||||
// - `error` doit être `null`.
|
||||
this.state.update(s => ({...s}));
|
||||
},
|
||||
error: (err) => {
|
||||
// TODO 4: Mettre à jour le signal `state` en cas d'erreur.
|
||||
// - `loading` doit passer à `false`.
|
||||
// - `error` doit contenir le message d'erreur (`err.message`).
|
||||
this.state.update(s => ({...s}));
|
||||
},
|
||||
});
|
||||
|
||||
// === SOUSCRIPTION N°2 : ANALYSE IA (PLUS LENT) ===
|
||||
const analysisSub = this.aiApiService.getMovieAnalysis(movieId).subscribe({
|
||||
next: (analysisData) => {
|
||||
// TODO 5: Mettre à jour le signal `state` pour la partie `analysis` en cas de succès.
|
||||
this.state.update(s => ({...s}));
|
||||
},
|
||||
error: (err) => {
|
||||
// TODO 6: Mettre à jour le signal `state` pour la partie `analysis` en cas d'erreur.
|
||||
this.state.update(s => ({...s}));
|
||||
},
|
||||
});
|
||||
|
||||
// On stocke les souscriptions pour les nettoyer plus tard
|
||||
this.subscriptions = [movieSub, analysisSub];
|
||||
}
|
||||
}
|
||||
88
src/app/features/movie/movie-form/movie-form.component.html
Normal file
88
src/app/features/movie/movie-form/movie-form.component.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h1>Ajouter un nouveau film</h1>
|
||||
<a routerLink="/movies" class="btn btn-secondary">Annuler</a>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="movieForm" (ngSubmit)="saveMovie()">
|
||||
<div class="form-group">
|
||||
<label for="title">Titre</label>
|
||||
<input id="title" type="text" formControlName="title" class="form-control">
|
||||
@if (movieForm.get('title')?.invalid && movieForm.get('title')?.touched) {
|
||||
<small class="form-error">Le titre est requis (2 caractères min).</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="year">Année</label>
|
||||
<input id="year" type="number" formControlName="year" class="form-control">
|
||||
@if (movieForm.get('year')?.invalid && movieForm.get('year')?.touched) {
|
||||
<small class="form-error">L'année est requise.</small>
|
||||
}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="duration">Durée (minutes)</label>
|
||||
<input id="duration" type="number" formControlName="duration" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="synopsis">Synopsis</label>
|
||||
<textarea id="synopsis" formControlName="synopsis" class="form-control" rows="5"></textarea>
|
||||
@if (movieForm.get('synopsis')?.invalid && movieForm.get('synopsis')?.touched) {
|
||||
<small class="form-error">Le synopsis est requis (10 caractères min).</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="genre_id">Genre</label>
|
||||
<select id="genre_id" formControlName="genre_id" class="form-control">
|
||||
<option [value]="0" disabled>Sélectionner un genre</option>
|
||||
@for (genre of genres(); track genre.id) {
|
||||
<option [value]="genre.id">{{ genre.label }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (movieForm.get('genre_id')?.invalid && movieForm.get('genre_id')?.touched) {
|
||||
<small class="form-error">Le genre est requis.</small>
|
||||
}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="director_id">Réalisateur</label>
|
||||
<select id="director_id" formControlName="director_id" class="form-control">
|
||||
<option [value]="0" disabled>Sélectionner un réalisateur</option>
|
||||
@for (p of participants(); track p.id) {
|
||||
<option [value]="p.id">{{ p.first_name }} {{ p.last_name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (movieForm.get('director_id')?.invalid && movieForm.get('director_id')?.touched) {
|
||||
<small class="form-error">Le réalisateur est requis.</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="actors_ids">Acteurs</label>
|
||||
<select id="actors_ids" formControlName="actors_ids" class="form-control" multiple size="8">
|
||||
@for (p of participants(); track p.id) {
|
||||
<option [value]="p.id">{{ p.first_name }} {{ p.last_name }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (movieForm.get('actors_ids')?.invalid && movieForm.get('actors_ids')?.touched) {
|
||||
<small class="form-error">Au moins un acteur est requis.</small>
|
||||
}
|
||||
</div>
|
||||
-->
|
||||
<div class="form-actions">
|
||||
@if (isSaving()) {
|
||||
<app-spinner />
|
||||
} @else {
|
||||
<button type="submit" class="btn btn-primary" [disabled]="movieForm.invalid">
|
||||
Enregistrer le film
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
94
src/app/features/movie/movie-form/movie-form.component.scss
Normal file
94
src/app/features/movie/movie-form/movie-form.component.scss
Normal file
@@ -0,0 +1,94 @@
|
||||
.form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: #2c2c2c;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #ff4d4d;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: #f43642;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #555;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: #777;
|
||||
}
|
||||
}
|
||||
87
src/app/features/movie/movie-form/movie-form.component.ts
Normal file
87
src/app/features/movie/movie-form/movie-form.component.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { GenreApiService } from '../../../core/services/genre-api.service';
|
||||
import { ParticipantApiService } from '../../../core/services/participant-api.service';
|
||||
import { MovieApiService } from '../../../core/services/movie-api.service';
|
||||
import { SpinnerComponent } from '../../../shared/components/spinner/spinner.component';
|
||||
import { MovieCreate } from '../../../core/models/api.models';
|
||||
import { catchError, of } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-movie-form',
|
||||
templateUrl: './movie-form.component.html',
|
||||
styleUrls: ['./movie-form.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReactiveFormsModule, RouterLink, SpinnerComponent],
|
||||
})
|
||||
export class MovieFormComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
private readonly genreApiService = inject(GenreApiService);
|
||||
private readonly participantApiService = inject(ParticipantApiService);
|
||||
private readonly movieApiService = inject(MovieApiService);
|
||||
|
||||
// TODO 7: Utiliser `toSignal` pour récupérer la liste des genres.
|
||||
// Gérer l'erreur avec `catchError` et pipe pour retourner un tableau vide en cas de problème.
|
||||
// readonly genres = toSignal(/* ...votre code ici... */, { initialValue: [] });
|
||||
|
||||
// TODO 8: Faire de même pour la liste des participants.
|
||||
// readonly participants = toSignal(/* ...votre code ici... */,{ initialValue: [] });
|
||||
|
||||
readonly isSaving = signal(false);
|
||||
|
||||
|
||||
readonly movieForm = this.fb.nonNullable.group({
|
||||
title: ['', [Validators.required, Validators.minLength(2)]],
|
||||
year: [new Date().getFullYear(), [Validators.required, Validators.min(1888)]],
|
||||
// Le champ `duration` n'est pas requis, donc on ne le met pas dans le groupe nonNullable.
|
||||
// On le définit séparément ou on le laisse tel quel s'il peut être null.
|
||||
// Pour simplifier, nous le laissons ici, mais nous le gérons dans le payload.
|
||||
duration: [null as number | null, [Validators.min(1)]],
|
||||
synopsis: ['', [Validators.required, Validators.minLength(10)]],
|
||||
// On doit initialiser les selects requis avec une valeur non-nulle pour que nonNullable fonctionne
|
||||
genre_id: [0, [Validators.required, Validators.min(1)]], // min(1) pour s'assurer qu'une vraie option est choisie
|
||||
director_id: [0, [Validators.required, Validators.min(1)]],
|
||||
actors_ids: [[] as number[], [Validators.required, Validators.minLength(1)]],
|
||||
});
|
||||
|
||||
saveMovie(): void {
|
||||
// La validation du formulaire vérifie maintenant que les IDs sont > 0
|
||||
if (this.movieForm.invalid) {
|
||||
this.movieForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO 9: Mettre le signal `isSaving` à `true` au début de la soumission.
|
||||
|
||||
const formValue = this.movieForm.getRawValue();
|
||||
|
||||
// On s'assure que tout est bien typé avant l'envoi
|
||||
const moviePayload: MovieCreate = {
|
||||
title: formValue.title,
|
||||
year: formValue.year,
|
||||
duration: formValue.duration,
|
||||
synopsis: formValue.synopsis,
|
||||
genre_id: Number(formValue.genre_id),
|
||||
director_id: Number(formValue.director_id),
|
||||
actors_ids: (formValue.actors_ids || []).map(id => Number(id)),
|
||||
};
|
||||
|
||||
this.movieApiService.createMovie(moviePayload).subscribe({
|
||||
next: createdMovie => {
|
||||
// TODO 10: En cas de succès :
|
||||
// 1. Mettre `isSaving` à `false`.
|
||||
// 2. Naviguer (this.router.navigate) vers la page de détail du film qui vient d'être créé.
|
||||
},
|
||||
error: err => {
|
||||
// TODO 11: En cas d'erreur :
|
||||
// 1. Mettre `isSaving` à `false`.
|
||||
// 2. Afficher l'erreur dans la console.
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
27
src/app/features/movie/movie-list/movie-list.component.html
Normal file
27
src/app/features/movie/movie-list/movie-list.component.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="list-container">
|
||||
<div class="list-header">
|
||||
<h1>Catalogue de films</h1>
|
||||
<a routerLink="/movies/new" class="btn btn-primary">Ajouter un film</a>
|
||||
</div>
|
||||
|
||||
<!-- @if (movies().length > 0) { -->
|
||||
<div class="movie-grid">
|
||||
<!-- @for (movie of movies(); track movie.id) { -->
|
||||
<a class="movie-card"> <!-- [routerLink]="['/movies', movie.id]" -->
|
||||
<div class="movie-card-content">
|
||||
<h2 class="movie-title"><!-- TODO : afficher movie title et year --></h2>
|
||||
<p class="movie-director">
|
||||
De <!-- TODO : afficher movie.director first_name et last_name -->
|
||||
</p>
|
||||
<div class="movie-meta">
|
||||
<span class="movie-genre"><!-- {{ movie.genre.label }} --></span>
|
||||
<span class="movie-duration"><!-- {{ movie.duration }} --></span> <!-- TODO : utiliser le pipe duration -->
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<!-- } -->
|
||||
</div>
|
||||
<!-- } @else { -->
|
||||
<app-spinner />
|
||||
<!-- } -->
|
||||
</div>
|
||||
88
src/app/features/movie/movie-list/movie-list.component.scss
Normal file
88
src/app/features/movie/movie-list/movie-list.component.scss
Normal file
@@ -0,0 +1,88 @@
|
||||
// Styles pour l'en-tête de la liste (titre + bouton)
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
// Style du titre principal, ajusté pour l'alignement avec le bouton
|
||||
.list-container h1 {
|
||||
margin-bottom: 0;
|
||||
font-size: 2rem;
|
||||
border-bottom: 2px solid #e50914;
|
||||
padding-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// Styles pour le bouton "Ajouter un film"
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: #f43642;
|
||||
}
|
||||
}
|
||||
|
||||
// Styles pour la grille des films
|
||||
.movie-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Styles pour chaque carte de film
|
||||
.movie-card {
|
||||
background-color: #2c2c2c;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 1px solid #444;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.movie-card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.movie-title {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.movie-director {
|
||||
font-style: italic;
|
||||
color: #bbb;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.movie-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.movie-genre {
|
||||
background-color: #e50914;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
27
src/app/features/movie/movie-list/movie-list.component.ts
Normal file
27
src/app/features/movie/movie-list/movie-list.component.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MovieApiService } from '../../../core/services/movie-api.service';
|
||||
import { SpinnerComponent } from '../../../shared/components/spinner/spinner.component';
|
||||
import { DurationPipe } from '../../../shared/pipes/duration.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-movie-list',
|
||||
templateUrl: './movie-list.component.html',
|
||||
styleUrls: ['./movie-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, SpinnerComponent, DurationPipe],
|
||||
})
|
||||
export class MovieListComponent {
|
||||
private readonly movieApiService = inject(MovieApiService);
|
||||
|
||||
// TODO 1: Convertir l'Observable de `movieApiService.getMovies()` en un Signal.
|
||||
// N'oubliez pas de fournir une `initialValue` pour que le signal soit synchrone dès le départ.
|
||||
// movies = toSignal(/* ...votre code ici... */, { initialValue: [] });
|
||||
|
||||
}
|
||||
23
src/app/features/movie/movie.routes.ts
Normal file
23
src/app/features/movie/movie.routes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { MovieListComponent } from './movie-list/movie-list.component';
|
||||
import { MovieDetailComponent } from './movie-detail/movie-detail.component';
|
||||
import { MovieFormComponent } from './movie-form/movie-form.component';
|
||||
|
||||
export const MOVIE_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: MovieListComponent,
|
||||
title: 'Liste des films',
|
||||
},
|
||||
// ATTENTION : ordre important, new doit être placé AVANT :id
|
||||
{
|
||||
path: 'new',
|
||||
component: MovieFormComponent,
|
||||
title: 'Ajouter un film',
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
component: MovieDetailComponent,
|
||||
title: 'Détail du film',
|
||||
},
|
||||
];
|
||||
18
src/app/shared/components/spinner/spinner.component.scss
Normal file
18
src/app/shared/components/spinner/spinner.component.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.spinner {
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top: 4px solid #e50914;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
9
src/app/shared/components/spinner/spinner.component.ts
Normal file
9
src/app/shared/components/spinner/spinner.component.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-spinner',
|
||||
template: `<div class="spinner"></div>`,
|
||||
styleUrls: ['./spinner.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SpinnerComponent {}
|
||||
16
src/app/shared/pipes/duration.pipe.ts
Normal file
16
src/app/shared/pipes/duration.pipe.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'duration',
|
||||
standalone: true,
|
||||
})
|
||||
export class DurationPipe implements PipeTransform {
|
||||
transform(minutes: number | null | undefined): string {
|
||||
if (minutes === null || minutes === undefined || minutes <= 0) {
|
||||
return '';
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
}
|
||||
7
src/environments/environment.ts
Normal file
7
src/environments/environment.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
// URL du service REST FastAPI
|
||||
apiRestUrl: 'http://localhost:8000/api/v1',
|
||||
// URL du service GraphQL
|
||||
apiGraphqlUrl: 'http://localhost:8002/graphql',
|
||||
};
|
||||
13
src/index.html
Normal file
13
src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Videotheque</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
6
src/main.ts
Normal file
6
src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
28
src/styles.scss
Normal file
28
src/styles.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-color: #e50914;
|
||||
--background-color: #141414;
|
||||
--surface-color: #2c2c2c;
|
||||
--text-color: #ffffff;
|
||||
--text-secondary-color: #cccccc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
15
tsconfig.app.json
Normal file
15
tsconfig.app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
tsconfig.spec.json
Normal file
14
tsconfig.spec.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user