Bruno Predot

Merge branch 'feature/detail_page' into develop

1 ------ Dispo à la prochaine release ------------ 1 ------ Dispo à la prochaine release ------------
  2 +- Installation vuelidate et vuelidate/validator.
  3 +- Ajout composant SkeletonMovieDetailLoader.
  4 +- Ajout Model + Interface credit.
  5 +- Ajout composant ScoreAndVote.
  6 +- Ajout composant MovieGender.
  7 +- Ajout composant Poster.
  8 +- Ajout composant BackdropImage.
  9 +- Ajout composant MovieCommentForm.
  10 +- Ajout model + interface MovieComment.
  11 +- Ajout composant MovieCommentForm.
  12 +- Ajout composant MovieCommentList.
  13 +- Ajout dépendance TinyMCE.
  14 +- Ajout composant TinyMceFieldEditor.
  15 +- Ajout composant Loader.
  16 +- Ajout composant MovieCard.
  1 +<script lang="ts" setup>
  2 +//#region --Props--.
  3 +import { useDateFormat } from "@vueuse/core";
  4 +import { FilmIcon } from "lucide-vue-next";
  5 +//#endregion
  6 +
  7 +//#region --Props--.
  8 +defineProps({
  9 + movie: {
  10 + type: Object,
  11 + required: true,
  12 + nullable: false,
  13 + },
  14 +});
  15 +//#endregion
  16 +</script>
  17 +
  18 +<template>
  19 + <section
  20 + class="bg-gray-800 rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:scale-105 cursor-pointer"
  21 + @click="navigateTo(`/movies/${movie.id}`)"
  22 + >
  23 + <div class="relative pb-[150%]">
  24 + <img
  25 + v-if="movie.poster_path"
  26 + :alt="movie.title"
  27 + :src="`https://image.tmdb.org/t/p/w500${movie.poster_path}`"
  28 + class="absolute inset-0 w-full h-full object-cover"
  29 + />
  30 + <div v-else class="absolute inset-0 w-full h-full bg-gray-700 flex items-center justify-center">
  31 + <FilmIcon :size="48" class="text-gray-500" />
  32 + </div>
  33 + <div
  34 + class="absolute top-2 right-2 bg-primary text-white rounded-full w-10 h-10 flex items-center justify-center font-bold"
  35 + >
  36 + {{ movie.vote_average.toFixed(1) }}
  37 + </div>
  38 + </div>
  39 + <div class="p-4">
  40 + <h2 class="text-lg font-bold mb-1 line-clamp-1">{{ movie.title }}</h2>
  41 + <p class="text-sm text-gray-400">{{ useDateFormat(movie.release_date, "DD-MM-YYYY") }}</p>
  42 + </div>
  43 + </section>
  44 +</template>
  45 +
  46 +<style scoped></style>
  1 +<script lang="ts" setup>
  2 +//#region --Import--.
  3 +import type { MovieCommentInterface } from "~/interfaces/movieComment";
  4 +import { MessageSquareIcon } from "lucide-vue-next";
  5 +//#endregion
  6 +
  7 +//#region --Props--.
  8 +const props = defineProps({
  9 + comments: {
  10 + type: Array<MovieCommentInterface>,
  11 + required: true,
  12 + nullable: false,
  13 + },
  14 +});
  15 +//#endregion
  16 +
  17 +//#region --Watch--.
  18 +watch(
  19 + () => props.comments,
  20 + (comments) => {
  21 + nextTick(() => {
  22 + if (comments.length) {
  23 + comments.forEach((comment, index) => {
  24 + const element = document.getElementById(`message${index}`) as HTMLParagraphElement;
  25 + element.innerHTML = comment.message;
  26 + });
  27 + }
  28 + });
  29 + }, { immediate: true }
  30 +);
  31 +//#endregion
  32 +</script>
  33 +
  34 +<template>
  35 + <section>
  36 + <!-- Liste des commentaires -->
  37 + <section v-if="comments.length > 0" class="mt-10">
  38 + <h2>Commentaires publiés</h2>
  39 + <div v-for="(comment, index) in comments" :key="index" class="bg-gray-800 rounded-lg p-6 mb-4">
  40 + <div class="flex justify-between items-start mb-2">
  41 + <section>
  42 + <h4 class="font-bold text-lg">Par {{ comment.username }}</h4>
  43 + <p class="text-sm text-gray-400">Le {{ useDateFormat(comment.createdAt, "DD-MM-YYYY") }}</p>
  44 + </section>
  45 + <section class="bg-primary text-white rounded-full w-10 h-10 flex items-center justify-center font-bold">
  46 + {{ comment.rating }}
  47 + </section>
  48 + </div>
  49 + <p :id="`message${index}`" class="text-gray-300">{{ comment.message }}</p>
  50 + </div>
  51 + </section>
  52 + <!-- Si aucun commentaire -->
  53 + <section v-else class="text-center py-8 bg-gray-800 rounded-lg mt-10">
  54 + <MessageSquareIcon :size="48" class="mx-auto mb-3 text-gray-600" />
  55 + <p class="text-gray-400">Aucun commentaire pour le moment. Soyez le premier à donner votre avis !</p>
  56 + </section>
  57 + </section>
  58 +</template>
  59 +
  60 +<style scoped></style>
1 <script lang="ts" setup> 1 <script lang="ts" setup>
2 //#region --import--. 2 //#region --import--.
3 -import SearchBar from "~/components/SearchBar.vue";  
4 import { onBeforeUnmount, ref } from "vue"; 3 import { onBeforeUnmount, ref } from "vue";
5 import { useTMDB } from "~/composables/tMDB"; 4 import { useTMDB } from "~/composables/tMDB";
6 import { Movie } from "~/models/movie"; 5 import { Movie } from "~/models/movie";
7 -import { FilmIcon, SearchXIcon } from "lucide-vue-next"; 6 +import { SearchXIcon } from "lucide-vue-next";
8 import type { MovieInterface } from "~/interfaces/movie"; 7 import type { MovieInterface } from "~/interfaces/movie";
9 -import { useDateFormat } from "@vueuse/core";  
10 //#endregion 8 //#endregion
11 9
12 //#region --Declaration--. 10 //#region --Declaration--.
@@ -100,7 +98,7 @@ function createIntersectionObserver() { @@ -100,7 +98,7 @@ function createIntersectionObserver() {
100 if (entry.isIntersecting && !isLoadingMore.value && currentPage.value < totalPages.value) { 98 if (entry.isIntersecting && !isLoadingMore.value && currentPage.value < totalPages.value) {
101 if (searchQuery.value) { 99 if (searchQuery.value) {
102 // Continue searching query if already active. 100 // Continue searching query if already active.
103 - search(searchQuery.value, currentPage.value + 1) 101 + search(searchQuery.value, currentPage.value + 1);
104 } else { 102 } else {
105 // Continue fetching popular movies. 103 // Continue fetching popular movies.
106 fetchMovies(currentPage.value + 1); 104 fetchMovies(currentPage.value + 1);
@@ -118,7 +116,7 @@ function handleSearchEvent(event: string) { @@ -118,7 +116,7 @@ function handleSearchEvent(event: string) {
118 } 116 }
119 117
120 function handleClearSearchEvent() { 118 function handleClearSearchEvent() {
121 - searchQuery.value = ''; 119 + searchQuery.value = "";
122 currentPage.value = 1; 120 currentPage.value = 1;
123 // Fetch popular movies after clear. 121 // Fetch popular movies after clear.
124 fetchMovies(1); 122 fetchMovies(1);
@@ -155,41 +153,23 @@ onBeforeUnmount(() => { @@ -155,41 +153,23 @@ onBeforeUnmount(() => {
155 <section> 153 <section>
156 <h1 class="text-4xl font-bold mb-8 text-center">Découvrez les films populaires</h1> 154 <h1 class="text-4xl font-bold mb-8 text-center">Découvrez les films populaires</h1>
157 <!-- Barre de recherche --> 155 <!-- Barre de recherche -->
158 - <search-bar 156 + <ui-components-search-bar
159 placeholder="Rechercher un film..." 157 placeholder="Rechercher un film..."
160 @event:search="handleSearchEvent" 158 @event:search="handleSearchEvent"
161 @event:clear_search="handleClearSearchEvent" 159 @event:clear_search="handleClearSearchEvent"
162 /> 160 />
  161 +
163 <!-- Loading Skeleton --> 162 <!-- Loading Skeleton -->
164 - <skeleton-movies-loader v-if="isInitialLoading" :is-initial-loading="isInitialLoading" :skeleton-number="20" /> 163 + <ui-components-skeleton-movies-loader
  164 + v-if="isInitialLoading"
  165 + :is-initial-loading="isInitialLoading"
  166 + :skeleton-number="20"
  167 + />
  168 +
165 <!-- Liste des films --> 169 <!-- Liste des films -->
166 <div v-else-if="movies.length > 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"> 170 <div v-else-if="movies.length > 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
167 - <div 171 + <div v-for="movie in movies" :key="movie.id">
168 - v-for="movie in movies" 172 + <movie-card :movie="movie" />
169 - :key="movie.id"  
170 - class="bg-gray-800 rounded-lg overflow-hidden shadow-lg transition-transform duration-300 hover:scale-105 cursor-pointer"  
171 - @click="navigateTo(`/movies/${movie.id}`)"  
172 - >  
173 - <div class="relative pb-[150%]">  
174 - <img  
175 - v-if="movie.poster_path"  
176 - :alt="movie.title"  
177 - :src="`https://image.tmdb.org/t/p/w500${movie.poster_path}`"  
178 - class="absolute inset-0 w-full h-full object-cover"  
179 - />  
180 - <div v-else class="absolute inset-0 w-full h-full bg-gray-700 flex items-center justify-center">  
181 - <FilmIcon :size="48" class="text-gray-500" />  
182 - </div>  
183 - <div  
184 - class="absolute top-2 right-2 bg-primary text-white rounded-full w-10 h-10 flex items-center justify-center font-bold"  
185 - >  
186 - {{ movie.vote_average.toFixed(1) }}  
187 - </div>  
188 - </div>  
189 - <div class="p-4">  
190 - <h2 class="text-lg font-bold mb-1 line-clamp-1">{{ movie.title }}</h2>  
191 - <p class="text-sm text-gray-400">{{ useDateFormat(movie.release_date, "DD-MM-YYYY") }}</p>  
192 - </div>  
193 </div> 173 </div>
194 </div> 174 </div>
195 175
@@ -201,9 +181,7 @@ onBeforeUnmount(() => { @@ -201,9 +181,7 @@ onBeforeUnmount(() => {
201 </section> 181 </section>
202 182
203 <!-- Loader pour le chargement de plus de films --> 183 <!-- Loader pour le chargement de plus de films -->
204 - <section v-if="isLoadingMore && !isInitialLoading" class="flex justify-center mt-8"> 184 + <ui-components-loader :is-initial-loading="isInitialLoading" :is-loading="isLoadingMore" />
205 - <div class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />  
206 - </section>  
207 185
208 <!-- Élément observé pour le défilement infini --> 186 <!-- Élément observé pour le défilement infini -->
209 <div ref="loadMoreTrigger" class="h-10 mt-4" /> 187 <div ref="loadMoreTrigger" class="h-10 mt-4" />
  1 +<script lang="ts" setup>
  2 +//#region --Import--.
  3 +import type { Genre } from "~/interfaces/movie";
  4 +//#endregion
  5 +
  6 +//#region --Props--.
  7 +defineProps({
  8 + genres: {
  9 + type: Array<Genre>,
  10 + required: true,
  11 + nullable: false,
  12 + },
  13 +});
  14 +//#endregion
  15 +</script>
  16 +
  17 +<template>
  18 + <section class="mb-6">
  19 + <div class="flex flex-wrap gap-2">
  20 + <span v-for="genre in genres" :key="genre.id" class="px-3 py-1 bg-gray-800 rounded-full text-sm">
  21 + {{ genre.name }}
  22 + </span>
  23 + </div>
  24 + </section>
  25 +</template>
  26 +
  27 +<style scoped></style>
  1 +<script lang="ts" setup>
  2 +//#region --Props--.
  3 +const props = defineProps({
  4 + score: {
  5 + type: Number,
  6 + required: true,
  7 + nullable: false,
  8 + },
  9 + nbVote: {
  10 + type: Number,
  11 + required: true,
  12 + nullable: false,
  13 + },
  14 +});
  15 +//#endregion
  16 +
  17 +//#region --Function--.
  18 +/**
  19 + * Format vote count if > 1000.
  20 + * @param count
  21 + */
  22 +const formatVoteCount = (count: number) => {
  23 + if (count >= 1000) {
  24 + return `${(count / 1000).toFixed(1)}k votes`;
  25 + }
  26 + return `${count} votes`;
  27 +};
  28 +//#endregion
  29 +</script>
  30 +
  31 +<template>
  32 + <section class="flex items-center mb-6">
  33 + <section class="bg-primary text-white rounded-full w-12 h-12 flex items-center justify-center font-bold mr-3">
  34 + {{ score.toFixed(1) }}
  35 + </section>
  36 + <section>
  37 + <p class="font-semibold">Note TMDB</p>
  38 + <div class="text-sm text-gray-400">{{ formatVoteCount(nbVote) }}</div>
  39 + </section>
  40 + </section>
  41 +</template>
  42 +
  43 +<style scoped></style>
  1 +<script lang="ts" setup>
  2 +//#region --Import--.
  3 +import { useVuelidate } from "@vuelidate/core";
  4 +import { helpers, maxLength, maxValue, minLength, minValue, required } from "@vuelidate/validators";
  5 +import type { Comment } from "~/type/commentForm";
  6 +//#endregion
  7 +
  8 +//#region --Emit--.
  9 +const emit = defineEmits(["event:submit"]);
  10 +//#endregion
  11 +
  12 +//#region --Props--.
  13 +defineProps({
  14 + isSubmitting: {
  15 + type: Boolean,
  16 + required: false,
  17 + nullable: false,
  18 + default: false,
  19 + },
  20 +});
  21 +//#endregion
  22 +
  23 +//#region --Data/ref--.
  24 +const initialState: Comment = {
  25 + username: "",
  26 + message: "",
  27 + rating: 5,
  28 +};
  29 +
  30 +// Validation rules
  31 +const rules = {
  32 + username: {
  33 + required: helpers.withMessage("Le nom d'utilisateur est requis", required),
  34 + minLength: helpers.withMessage("Le nom d'utilisateur doit contenir au moins 3 caractères", minLength(3)),
  35 + maxLength: helpers.withMessage("Le nom d'utilisateur ne peut pas dépasser 50 caractères", maxLength(50)),
  36 + alpha: helpers.withMessage(
  37 + "Le nom d'utilisateur ne peut contenir que des lettres",
  38 + helpers.regex(/^[a-zA-ZÀ-ÿ\s]+$/),
  39 + ),
  40 + },
  41 + message: {
  42 + required: helpers.withMessage("Le message est requis", required),
  43 + minLength: helpers.withMessage("Le message doit contenir au moins 3 caractères", minLength(3)),
  44 + maxLength: helpers.withMessage("Le message ne peut pas dépasser 500 caractères", maxLength(500)),
  45 + },
  46 + rating: {
  47 + required: helpers.withMessage("La notation est requise", required),
  48 + minValue: helpers.withMessage("Le message ne être inférieure à 0", minValue(0)),
  49 + maxValue: helpers.withMessage("Le message ne être suppérieur à 10", maxValue(10)),
  50 + },
  51 +};
  52 +
  53 +const formData = reactive({
  54 + ...initialState,
  55 +});
  56 +const v$ = useVuelidate(rules, formData);
  57 +//#endregion
  58 +
  59 +const errormessages = computed(() => {
  60 + return v$.value.message.$errors.map((e) => e.$message);
  61 +});
  62 +
  63 +//#region --Function--.
  64 +async function submitComment() {
  65 + emit("event:submit", formData);
  66 +}
  67 +
  68 +function clear() {
  69 + v$.value.$reset();
  70 + formData.username = initialState.username;
  71 + formData.message = initialState.message;
  72 + formData.rating = initialState.rating;
  73 +}
  74 +
  75 +function handleMessageEvent(event: string) {
  76 + formData.message = event;
  77 + // todo : revoir ici la validation manquante (dû au retour de TinyMCE).
  78 + v$.value.message.$touch();
  79 + // console.log(formData.message.replace(/<[^>]*>/g, ''));
  80 + // console.log(formData.message.replace(/(&lt;([^&gt;]+)&gt;)/ig, ''));
  81 +}
  82 +
  83 +//#endregion
  84 +</script>
  85 +
  86 +<template>
  87 + <section>
  88 + <VForm>
  89 + <v-text-field
  90 + v-model="formData.username"
  91 + :error-messages="v$.username.$errors.map((e) => e.$message) as readonly string[]"
  92 + label="nom d'utilisateur"
  93 + placeholder="nom d'utilisateur"
  94 + required
  95 + @blur="v$.username.$touch()"
  96 + @input="v$.username.$touch()"
  97 + />
  98 + <v-text-field
  99 + v-model="formData.rating"
  100 + :error-messages="v$.rating.$errors.map((e) => e.$message) as readonly string[]"
  101 + label="Note (1-10)"
  102 + placeholder=""
  103 + required
  104 + type="number"
  105 + @blur="v$.rating.$touch"
  106 + @input="v$.rating.$touch"
  107 + />
  108 +<!-- <pre>{{ errormessages }}</pre>-->
  109 + <ui-components-tiny-mce-field-editor
  110 + :error-message="v$?.message?.$errors[0]?.$message ? (v$.message.$errors[0].$message as string) : ''"
  111 + :model-value="formData.message"
  112 + @update:model-value="handleMessageEvent"
  113 + />
  114 + <v-btn
  115 + class="mt-6 mr-4"
  116 + color="primary"
  117 + @click="
  118 + async () => {
  119 + const validForm = await v$.$validate();
  120 + if (validForm) {
  121 + submitComment();
  122 + }
  123 + }
  124 + "
  125 + >
  126 + <span v-if="isSubmitting" class="flex items-center justify-center">
  127 + <span class="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
  128 + Envoi en cours...
  129 + </span>
  130 + <span v-else>Publier le commentaire</span>
  131 + </v-btn>
  132 + <v-btn class="mt-6 mr-4" color="primary" @click="clear"> effacer</v-btn>
  133 + </VForm>
  134 + </section>
  135 +</template>
  136 +
  137 +<style scoped></style>
  1 +import { describe, it, expect } from 'vitest'
  2 +import { mount } from '@vue/test-utils'
  3 +
  4 +import HelloWorld from './HelloWorld.vue'
  5 +
  6 +describe('HelloWorld', () => {
  7 + it('component renders Hello world properly', () => {
  8 + const wrapper = mount(HelloWorld)
  9 + expect(wrapper.text()).toContain('Hello world')
  10 + })
  11 +})
  1 +<script setup lang="ts">
  2 +
  3 +</script>
  4 +
  5 +<template>
  6 + <p>Hello world</p>
  7 +</template>
  8 +
  9 +<style scoped lang="scss">
  10 +
  11 +</style>
  1 +<script lang="ts" setup>
  2 +//#region --Props--.
  3 +defineProps({
  4 + src: {
  5 + type: String,
  6 + required: true,
  7 + nullable: false,
  8 + },
  9 + title: {
  10 + type: String,
  11 + required: true,
  12 + nullable: false,
  13 + },
  14 +});
  15 +//#endregion
  16 +
  17 +//#region --Declaration--.
  18 +const w: Window = window;
  19 +//#endregion
  20 +</script>
  21 +
  22 +<template>
  23 + <section class="absolute inset-0 h-[500px] overflow-hidden z-0">
  24 + <v-img
  25 + v-if="src"
  26 + :alt="title"
  27 + :src="`https://image.tmdb.org/t/p/original${src}`"
  28 + :width="w.screen.width"
  29 + aspect-ratio="16/9"
  30 + class="w-full h-full object-cover opacity-30"
  31 + cover
  32 + max-height="500"
  33 + />
  34 + </section>
  35 +</template>
  36 +
  37 +<style scoped></style>
  1 +<script lang="ts" setup>
  2 +//#region --Props--.
  3 +defineProps({
  4 + isLoading: {
  5 + type: Boolean,
  6 + required: true,
  7 + nullable: false,
  8 + },
  9 + isInitialLoading: {
  10 + type: Boolean,
  11 + required: false,
  12 + nullable: false,
  13 + default: false,
  14 + },
  15 +});
  16 +//#endregion
  17 +</script>
  18 +
  19 +<template>
  20 + <section v-if="isLoading && !isInitialLoading" class="flex justify-center mt-8">
  21 + <div class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
  22 + </section>
  23 +</template>
  24 +
  25 +<style scoped></style>
  1 +<script setup lang="ts">
  2 +//#region --Props--.
  3 +import { FilmIcon } from "lucide-vue-next";
  4 +
  5 +defineProps({
  6 + src: {
  7 + type: String,
  8 + required: true,
  9 + nullable: false,
  10 + },
  11 + title: {
  12 + type: String,
  13 + required: true,
  14 + nullable: false,
  15 + },
  16 +});
  17 +//#endregion
  18 +</script>
  19 +
  20 +<template>
  21 + <section class="w-full md:w-1/3 lg:w-1/4">
  22 + <div class="rounded-lg overflow-hidden shadow-lg bg-gray-800">
  23 + <v-img
  24 + v-if="src"
  25 + :alt="title"
  26 + :src="`https://image.tmdb.org/t/p/w500${src}`"
  27 + class="w-full h-auto"
  28 + />
  29 + <div v-else class="aspect-[2/3] bg-gray-700 flex items-center justify-center">
  30 + <FilmIcon :size="64" class="text-gray-500" />
  31 + </div>
  32 + </div>
  33 + </section>
  34 +</template>
  35 +
  36 +<style scoped>
  37 +
  38 +</style>
  1 +<script lang="ts" setup>
  2 +//#region --Import--.
  3 +import Editor from "@tinymce/tinymce-vue";
  4 +import { ref, watch } from "vue";
  5 +//#endregion
  6 +
  7 +//#region --Declaration--.
  8 +const runtimeConfig = useRuntimeConfig();
  9 +//#endregion
  10 +
  11 +//#region --Emit--.
  12 +const emit = defineEmits<{
  13 + (e: "update:modelValue", value: string): void;
  14 +}>();
  15 +//#endregion
  16 +
  17 +//#region --Props--.
  18 +const props = defineProps<{
  19 + modelValue: string;
  20 + errorMessage: string;
  21 +}>();
  22 +//#endregion
  23 +
  24 +//#region --Data/ref--.
  25 +const content = ref(props.modelValue);
  26 +const init = {
  27 + height: 300,
  28 + menubar: false,
  29 + plugins: [
  30 + // Core editing features
  31 + "advlist",
  32 + "autolink",
  33 + "lists",
  34 + "link",
  35 + "image",
  36 + "charmap",
  37 + "preview",
  38 + "anchor",
  39 + "searchreplace",
  40 + "visualblocks",
  41 + "code",
  42 + "fullscreen",
  43 + "insertdatetime",
  44 + "media",
  45 + "table",
  46 + "code",
  47 + "help",
  48 + "wordcount",
  49 + ],
  50 + toolbar:
  51 + "undo redo | blocks | bold italic underline strikethrough |" +
  52 + "bold italic forecolor | alignleft aligncenter " +
  53 + "alignright alignjustify | bullist numlist outdent indent | " +
  54 + "removeformat | help",
  55 + content_style: "body { font-family:Helvetica,Arial,sans-serif; font-size:14px }",
  56 + skin: "oxide-dark",
  57 + content_css: "dark",
  58 + // forced_root_block: false,
  59 + // valid_elements: [],
  60 + // entity_encoding : "raw",
  61 +};
  62 +//#endregion
  63 +
  64 +//#region --Watch--.
  65 +watch(content, (newValue) => {
  66 + emit("update:modelValue", newValue);
  67 +});
  68 +
  69 +watch(
  70 + () => props.modelValue,
  71 + (newValue) => {
  72 + if (newValue !== content.value) {
  73 + content.value = newValue;
  74 + }
  75 + },
  76 +);
  77 +//#endregion
  78 +</script>
  79 +
  80 +<template>
  81 + <div>
  82 + <editor v-model="content" :api-key="runtimeConfig.public.apiTinyMceSecret" :init="init" />
  83 + </div>
  84 + <div v-if="errorMessage" class="text-red-500 text-sm mt-1">
  85 + {{ errorMessage }}
  86 + </div>
  87 +</template>
  88 +
  89 +<style scoped></style>
  1 +<script setup lang="ts">
  2 +
  3 +</script>
  4 +
  5 +<template>
  6 + <v-container class="bg-gray-900">
  7 + <v-row class="bg-gray-900" >
  8 + <v-col cols="12" sm="4">
  9 + <v-skeleton-loader
  10 + class="mx-auto border bg-gray-800"
  11 + color="#1f2937"
  12 + width="auto"
  13 + height="600px"
  14 + type="paragraph, image"
  15 + />
  16 + </v-col>
  17 + <v-col cols="12" sm="8">
  18 + <v-skeleton-loader
  19 + class="mx-auto mt-10"
  20 + color="#1f2937"
  21 + elevation="12"
  22 + min-height="400px"
  23 + type="table-heading, list-item-two-line, article, actions, table-tfoot"
  24 + />
  25 + </v-col>
  26 + </v-row>
  27 + </v-container>
  28 +</template>
  29 +
  30 +<style scoped>
  31 +
  32 +</style>
1 import type { RuntimeConfig } from "nuxt/schema"; 1 import type { RuntimeConfig } from "nuxt/schema";
2 2
3 -export const useTMDB = function() { 3 +export const useTMDB = function () {
4 const runtimeconfig: RuntimeConfig = useRuntimeConfig(); 4 const runtimeconfig: RuntimeConfig = useRuntimeConfig();
5 const apiUrl = runtimeconfig.public.apiTMDBUrl; 5 const apiUrl = runtimeconfig.public.apiTMDBUrl;
6 const apiKey = runtimeconfig.public.apiTMDBSecret; 6 const apiKey = runtimeconfig.public.apiTMDBSecret;
@@ -11,9 +11,7 @@ export const useTMDB = function() { @@ -11,9 +11,7 @@ export const useTMDB = function() {
11 */ 11 */
12 const fetchPopularMovies = async (page: number) => { 12 const fetchPopularMovies = async (page: number) => {
13 try { 13 try {
14 - const response = await fetch( 14 + const response = await fetch(`${apiUrl}/movie/popular?api_key=${apiKey}&language=fr-FR&page=${page}`);
15 - `${apiUrl}/movie/popular?api_key=${apiKey}&language=fr-FR&page=${page}`,  
16 - );  
17 if (!response.ok) { 15 if (!response.ok) {
18 console.error("An error occurred when fetching popular movies:"); 16 console.error("An error occurred when fetching popular movies:");
19 } else { 17 } else {
@@ -44,5 +42,38 @@ export const useTMDB = function() { @@ -44,5 +42,38 @@ export const useTMDB = function() {
44 } 42 }
45 }; 43 };
46 44
47 - return { fetchPopularMovies, searchMovies } 45 + /**
48 -} 46 + * Fetch movie details by id.
  47 + * @param id
  48 + */
  49 + const fetchMovieDetails = async (id: number | string) => {
  50 + try {
  51 + const response = await fetch(`${apiUrl}/movie/${id}?api_key=${apiKey}&language=fr-FR`);
  52 + if (!response.ok) {
  53 + console.error("An error occurred when fetching movie details:");
  54 + } else {
  55 + return await response.json();
  56 + }
  57 + } catch (error) {
  58 + console.error("Error fetching details:", error);
  59 + }
  60 + };
  61 +
  62 + /**
  63 + * Fetch movie credits
  64 + */
  65 + const fetchMovieCredits = async (id: number | string) => {
  66 + try {
  67 + const response = await fetch(`${apiUrl}/movie/${id}/credits?api_key=${apiKey}&language=fr-FR`);
  68 + if (!response.ok) {
  69 + console.error("An error occurred when fetching movie credits:");
  70 + } else {
  71 + return await response.json();
  72 + }
  73 + } catch (error) {
  74 + console.error("Error fetching movie credits:", error);
  75 + }
  76 + };
  77 +
  78 + return { fetchPopularMovies, searchMovies, fetchMovieDetails, fetchMovieCredits };
  79 +};
  1 +import type { MovieInterface } from "~/interfaces/movie";
  2 +
  3 +export interface CreditInterface {
  4 + id: number;
  5 + name: string;
  6 + job?: string;
  7 + character?: string;
  8 +}
  9 +
  10 +export type CreditsResponse = {
  11 + id: number;
  12 + cast: CreditInterface[],
  13 + crew: CreditInterface[],
  14 + movie_id: unknown;
  15 + movie: MovieInterface;
  16 +}
  1 +import type { CreditInterface, CreditsResponse } from "~/interfaces/credit";
  2 +
1 export interface MovieInterface { 3 export interface MovieInterface {
2 id: number; 4 id: number;
3 - title: string; 5 + adult: boolean;
  6 + backdrop_path: string;
  7 + genre_ids: number[];
  8 + genres: Genre[];
  9 + original_language: string;
  10 + original_title: string;
  11 + overview: string;
  12 + popularity: number;
4 poster_path: string | null; 13 poster_path: string | null;
5 - vote_average: number;  
6 release_date: string; 14 release_date: string;
  15 + runtime: number
  16 + title: string;
  17 + video: boolean;
  18 + vote_average: number;
  19 + vote_count: number;
  20 + credit: CreditsResponse;
  21 +}
  22 +
  23 +export type Genre = {
  24 + id: number,
  25 + name: string,
7 } 26 }
  1 +import type { MovieInterface } from "~/interfaces/movie";
  2 +
  3 +export interface MovieCommentInterface {
  4 + createdAt: string;
  5 + username: string;
  6 + message: string;
  7 + rating: number;
  8 + movie_id: unknown;
  9 + movie: MovieInterface;
  10 +}
  1 +import { Model } from "pinia-orm";
  2 +import { Movie } from "~/models/movie";
  3 +
  4 +export class Credit extends Model {
  5 + /**
  6 + *
  7 + * @return {string}
  8 + */
  9 + static get entity() {
  10 + return "Credit";
  11 + }
  12 +
  13 + /**
  14 + *
  15 + * @return {string}
  16 + */
  17 + static get primaryKey() {
  18 + return "id";
  19 + }
  20 +
  21 + static fields() {
  22 + return {
  23 + // Attributs.
  24 + id: this.number(null),
  25 + cast: this.attr([]),
  26 + crew: this.attr([]),
  27 + // Relations.
  28 + movie_id: this.attr(null),
  29 + movie: this.belongsTo(Movie, "movie_id", "id"),
  30 + };
  31 + }
  32 +
  33 + static piniaOptions = {
  34 + persist: true,
  35 + };
  36 +}
1 import { Model } from "pinia-orm"; 1 import { Model } from "pinia-orm";
  2 +import { Credit } from "~/models/credit";
2 3
3 export class Movie extends Model { 4 export class Movie extends Model {
4 /** 5 /**
@@ -22,24 +23,37 @@ export class Movie extends Model { @@ -22,24 +23,37 @@ export class Movie extends Model {
22 // Attributs. 23 // Attributs.
23 id: this.number(null), 24 id: this.number(null),
24 adult: this.boolean(false), 25 adult: this.boolean(false),
25 - backdrop_pat: this.string(null), 26 + backdrop_path: this.string(null),
  27 + belongs_to_collection: this.attr(null),
  28 + budget: this.number(null),
26 genre_ids: this.attr([]), 29 genre_ids: this.attr([]),
  30 + genres: this.attr([]),
  31 + homepage: this.string(null),
  32 + imdb_id: this.string(null),
  33 + origin_country: this.attr([]),
27 original_language: this.string(null), 34 original_language: this.string(null),
28 original_title: this.string(null), 35 original_title: this.string(null),
29 overview: this.string(null), 36 overview: this.string(null),
30 popularity: this.number(null), 37 popularity: this.number(null),
31 poster_path: this.string(null), 38 poster_path: this.string(null),
  39 + production_companies: this.attr([]),
  40 + production_cuntries: this.attr([]),
32 release_date: this.string(null), 41 release_date: this.string(null),
  42 + revenue: this.number(null),
  43 + runtime: this.number(null),
  44 + spoken_languages: this.attr([]),
  45 + status: this.string(null),
  46 + tagline: this.string(null),
33 title: this.string(null), 47 title: this.string(null),
34 video: this.boolean(false), 48 video: this.boolean(false),
35 vote_average: this.number(null), 49 vote_average: this.number(null),
36 vote_count: this.number(null), 50 vote_count: this.number(null),
37 // Relations. 51 // Relations.
  52 + credit: this.hasOne(Credit, "movie_id", "id"),
38 }; 53 };
39 } 54 }
40 55
41 static piniaOptions = { 56 static piniaOptions = {
42 persist: true, 57 persist: true,
43 }; 58 };
44 -  
45 } 59 }
  1 +import { Model } from "pinia-orm";
  2 +import { Movie } from "~/models/movie";
  3 +
  4 +export class MovieComment extends Model {
  5 + /**
  6 + *
  7 + * @return {string}
  8 + */
  9 + static get entity() {
  10 + return "MovieComment";
  11 + }
  12 +
  13 + /**
  14 + *
  15 + * @return {string}
  16 + */
  17 + static get primaryKey() {
  18 + return "id";
  19 + }
  20 +
  21 + static fields() {
  22 + return {
  23 + // Attributs.
  24 + id: this.uid(),
  25 + createdAt: this.string(''),
  26 + username: this.string(''),
  27 + message: this.string(''),
  28 + rating: this.string(''),
  29 + // Relations.
  30 + movie_id: this.attr(null),
  31 + movie: this.belongsTo(Movie, "movie_id", "id"),
  32 + };
  33 + }
  34 +
  35 + static piniaOptions = {
  36 + persist: true,
  37 + };
  38 +}
@@ -25,6 +25,7 @@ export default defineNuxtConfig({ @@ -25,6 +25,7 @@ export default defineNuxtConfig({
25 "@nuxt/eslint", 25 "@nuxt/eslint",
26 "@nuxt/icon", 26 "@nuxt/icon",
27 "@nuxt/image", 27 "@nuxt/image",
  28 + "@nuxt/test-utils/module",
28 [ 29 [
29 "@pinia/nuxt", 30 "@pinia/nuxt",
30 { 31 {
@@ -68,8 +69,8 @@ export default defineNuxtConfig({ @@ -68,8 +69,8 @@ export default defineNuxtConfig({
68 // Keys within public are also exposed client-side. 69 // Keys within public are also exposed client-side.
69 public: { 70 public: {
70 apiTMDBSecret: process.env.NUXT_ENV_TMDB_API_KEY, 71 apiTMDBSecret: process.env.NUXT_ENV_TMDB_API_KEY,
71 - apiTMDBBearer: process.env.NUXT_ENV_TMDB_BEARER,  
72 apiTMDBUrl: process.env.NUXT_ENV_TMDB_URL, 72 apiTMDBUrl: process.env.NUXT_ENV_TMDB_URL,
  73 + apiTinyMceSecret: process.env.NUXT_ENV_TINY_MCE_API_KEY,
73 }, 74 },
74 }, 75 },
75 76
This diff is collapsed. Click to expand it.
@@ -12,18 +12,23 @@ @@ -12,18 +12,23 @@
12 "lint:js": "eslint --ext \".ts,.vue\" .", 12 "lint:js": "eslint --ext \".ts,.vue\" .",
13 "lint:prettier": "prettier --write .", 13 "lint:prettier": "prettier --write .",
14 "lint": "npm run lint:js && npm run lint:prettier", 14 "lint": "npm run lint:js && npm run lint:prettier",
15 - "format": "prettier --write \"{components,pages,plugins,middleware,layouts,composables,assets}/**/*.{js,jsx,ts,tsx,vue,html,css,scss,json,md}\"" 15 + "format": "prettier --write \"{components,pages,plugins,middleware,layouts,composables,assets}/**/*.{js,jsx,ts,tsx,vue,html,css,scss,json,md}\"",
  16 + "test": "vitest"
16 }, 17 },
17 "dependencies": { 18 "dependencies": {
18 "@nuxt/eslint": "^1.3.0", 19 "@nuxt/eslint": "^1.3.0",
19 "@nuxt/icon": "^1.12.0", 20 "@nuxt/icon": "^1.12.0",
20 "@nuxt/image": "^1.10.0", 21 "@nuxt/image": "^1.10.0",
21 "@nuxt/scripts": "^0.11.6", 22 "@nuxt/scripts": "^0.11.6",
22 - "@nuxt/test-utils": "^3.17.2",  
23 "@nuxt/ui": "^2.22.0", 23 "@nuxt/ui": "^2.22.0",
24 "@pinia-orm/nuxt": "^1.10.2", 24 "@pinia-orm/nuxt": "^1.10.2",
25 "@pinia/nuxt": "^0.9.0", 25 "@pinia/nuxt": "^0.9.0",
  26 + "@tinymce/tinymce-vue": "^5.1.1",
  27 + "@types/vuelidate": "^0.7.22",
26 "@unhead/vue": "^2.0.8", 28 "@unhead/vue": "^2.0.8",
  29 + "@vitejs/plugin-vue": "^5.2.3",
  30 + "@vuelidate/core": "^2.0.3",
  31 + "@vuelidate/validators": "^2.0.4",
27 "@vueuse/core": "^13.1.0", 32 "@vueuse/core": "^13.1.0",
28 "@vueuse/nuxt": "^13.1.0", 33 "@vueuse/nuxt": "^13.1.0",
29 "eslint": "^9.25.1", 34 "eslint": "^9.25.1",
@@ -36,9 +41,15 @@ @@ -36,9 +41,15 @@
36 "vuetify-nuxt-module": "^0.18.6" 41 "vuetify-nuxt-module": "^0.18.6"
37 }, 42 },
38 "devDependencies": { 43 "devDependencies": {
  44 + "@nuxt/test-utils": "^3.17.2",
39 "@nuxtjs/tailwindcss": "^6.13.2", 45 "@nuxtjs/tailwindcss": "^6.13.2",
  46 + "@vue/test-utils": "^2.4.6",
40 "eslint-config-prettier": "^10.1.2", 47 "eslint-config-prettier": "^10.1.2",
41 "eslint-plugin-prettier": "^5.2.6", 48 "eslint-plugin-prettier": "^5.2.6",
42 - "prettier": "^3.5.3" 49 + "happy-dom": "^17.4.4",
  50 + "jsdom": "^26.1.0",
  51 + "playwright-core": "^1.52.0",
  52 + "prettier": "^3.5.3",
  53 + "vitest": "^3.1.2"
43 } 54 }
44 } 55 }
1 -<script setup lang="ts"> 1 +<script lang="ts" setup>
  2 +//#region --import--.
  3 +import { AlertTriangleIcon, ArrowLeftIcon } from "lucide-vue-next";
  4 +import { useTMDB } from "~/composables/tMDB";
  5 +import { computed, onMounted, ref } from "vue";
  6 +import { Movie } from "~/models/movie";
  7 +import type { MovieInterface } from "~/interfaces/movie";
  8 +import { Credit } from "~/models/credit";
  9 +import type { CreditsResponse } from "~/interfaces/credit";
  10 +import type { MovieCommentInterface } from "~/interfaces/movieComment";
  11 +import { MovieComment } from "~/models/movieComment";
  12 +import type { WhereSecondaryClosure } from "pinia-orm";
  13 +//#endregion
2 14
  15 +//#region --Declaration--.
  16 +const { fetchMovieDetails, fetchMovieCredits } = useTMDB();
  17 +//#endregion
  18 +
  19 +//#region --Declaration--.
  20 +const { currentRoute } = useRouter();
  21 +//#endregion
  22 +
  23 +//#region --Data/ref--.
  24 +const isLoading = ref(true);
  25 +const isSubmitting = ref(false);
  26 +//#endregion
  27 +
  28 +//#region --Computed--.
  29 +const movieId = computed(() => {
  30 + if (currentRoute.value.params.id) {
  31 + if (typeof currentRoute.value.params.id === "string") {
  32 + if (typeof Number(+currentRoute.value.params.id) === "number") {
  33 + return +currentRoute.value.params.id as number;
  34 + } else {
  35 + return currentRoute.value.params.id as string;
  36 + }
  37 + } else {
  38 + return null;
  39 + }
  40 + } else {
  41 + return null;
  42 + }
  43 +});
  44 +
  45 +const movie = computed(() => {
  46 + if (unref(movieId)) {
  47 + return useRepo(Movie)
  48 + .query()
  49 + .where("id", movieId.value as WhereSecondaryClosure<never> | null | undefined)
  50 + .withAll()
  51 + .first() as unknown as MovieInterface;
  52 + } else {
  53 + return null;
  54 + }
  55 +});
  56 +
  57 +/**
  58 + * Computed property for director
  59 + */
  60 +const director = computed(() => {
  61 + if (unref(movie)?.credit?.crew) {
  62 + return movie.value?.credit.crew.find((person) => person.job === "Director");
  63 + } else {
  64 + return null;
  65 + }
  66 +});
  67 +
  68 +/**
  69 + * Retourne les commentaires liés au film, du plus récent au plus ancien.
  70 + */
  71 +const comments = computed(() => {
  72 + return useRepo(MovieComment)
  73 + .query()
  74 + .where((comment) => {
  75 + const searched = comment as unknown as MovieCommentInterface;
  76 + return searched.movie_id === unref(movieId);
  77 + })
  78 + .orderBy("createdAt", "desc")
  79 + .get();
  80 +});
  81 +//#endregion
  82 +
  83 +//#region --Function--.
  84 +/**
  85 + * Fetch movie details
  86 + */
  87 +const fetchDetails = async (id: number | string) => {
  88 + try {
  89 + isLoading.value = true;
  90 +
  91 + const data = await fetchMovieDetails(id);
  92 + // Add to store collection.
  93 + useRepo(Movie).save(data);
  94 + } catch (error) {
  95 + console.error("Error fetching movie details:", error);
  96 + } finally {
  97 + isLoading.value = false;
  98 + }
  99 +};
  100 +
  101 +/**
  102 + * Format runtime
  103 + * @param minutes
  104 + */
  105 +const formatRuntime = (minutes: number) => {
  106 + if (!minutes) return "Durée inconnue";
  107 + // Find nb hours.
  108 + const hours = Math.floor(minutes / 60);
  109 + // Find last minutes.
  110 + const mins = minutes % 60;
  111 +
  112 + return `${hours}h ${mins}min`;
  113 +};
  114 +
  115 +async function fetchCredits(id: number | string) {
  116 + try {
  117 + const data = (await fetchMovieCredits(id)) as CreditsResponse;
  118 + data.movie_id = id;
  119 + // Add to store collection.
  120 + useRepo(Credit).save(data);
  121 + } catch (error) {
  122 + console.error("Error fetching movie credits:", error);
  123 + }
  124 +}
  125 +
  126 +//Ce n'est pas le film du siècle cependant, il est suffisamment intéressant pour passer une bonne soirée !
  127 +function handleSubmitEvent(event: MovieCommentInterface) {
  128 + isSubmitting.value = true;
  129 + event.movie_id = unref(movieId);
  130 + event.createdAt = `${new Date(Date.now())}`;
  131 + useRepo(MovieComment).save(event);
  132 + isSubmitting.value = false;
  133 +}
  134 +
  135 +//#endregion
  136 +
  137 +//#region --Global event--.
  138 +onMounted(() => {
  139 + // Fetch data on component mount.
  140 + if (unref(movieId)) {
  141 + const id = unref(movieId) as string | number;
  142 + fetchDetails(id);
  143 + fetchCredits(id);
  144 + }
  145 + // loadComments()
  146 +});
  147 +//#endregion
3 </script> 148 </script>
4 149
5 <template> 150 <template>
6 <section> 151 <section>
7 - composant détail d'un film. 152 + <!-- Skeleton loader pendant le chargement -->
  153 + <ui-components-skeleton-movie-detail-loader v-if="isLoading" />
  154 +
  155 + <!-- Contenu du film -->
  156 + <div v-else-if="movie" class="relative">
  157 + <!-- Backdrop image -->
  158 + <ui-components-backdrop-image v-if="movie.backdrop_path" :src="movie.backdrop_path" :title="movie.title" />
  159 +
  160 + <!-- Contenu principal -->
  161 + <div class="container mx-auto px-4 py-8 relative z-10 pt-20">
  162 + <button
  163 + class="flex items-center text-gray-400 hover:text-white mb-8 transition-colors"
  164 + @click="navigateTo('/')"
  165 + >
  166 + <ArrowLeftIcon :size="20" class="mr-2" />
  167 + Retour
  168 + </button>
  169 +
  170 + <div class="flex flex-col md:flex-row gap-8">
  171 + <!-- Poster -->
  172 + <ui-components-poster v-if="movie.poster_path" :src="movie.poster_path" :title="movie.title" />
  173 +
  174 + <!-- Informations du film -->
  175 + <section class="w-full md:w-2/3 lg:w-3/4">
  176 + <h1 class="text-3xl md:text-4xl font-bold mb-2">{{ movie.title }}</h1>
  177 + <p v-if="movie.release_date" class="text-gray-400 mb-4">
  178 + {{ useDateFormat(movie.release_date, "DD-MM-YYYY") }} • {{ formatRuntime(movie.runtime) }}
  179 + </p>
  180 +
  181 + <!-- Note et votes -->
  182 + <details-score-and-vote :nb-vote="movie.vote_count" :score="movie.vote_average" />
  183 +
  184 + <!-- Genres -->
  185 + <details-movie-gender :genres="movie.genres" />
  186 +
  187 + <!-- Synopsis -->
  188 + <div class="mb-6">
  189 + <h2 class="text-xl font-bold mb-2">Synopsis</h2>
  190 + <p class="text-gray-300">{{ movie.overview || "Aucun synopsis disponible." }}</p>
  191 + </div>
  192 +
  193 + <!-- Réalisateur et têtes d'affiche -->
  194 + <div v-if="movie.credit" class="mb-6">
  195 + <h2 class="text-xl font-bold mb-2">Équipe</h2>
  196 + <div v-if="director" class="mb-2">
  197 + <span class="font-semibold">Réalisateur:</span> {{ director.name }}
  198 + </div>
  199 + <div v-if="movie.credit.cast.length > 0">
  200 + <span class="font-semibold">Têtes d'affiche:</span>
  201 + {{
  202 + movie.credit.cast
  203 + .slice(0, 15)
  204 + .map((person) => person.name)
  205 + .join(", ")
  206 + }}
  207 + <span v-if="movie.credit.cast.length > 15">..</span>
  208 + </div>
  209 + </div>
  210 + <!-- Comments form. -->
  211 + <h3 class="text-xl font-bold mt-8 mb-4">Ajouter un commentaire</h3>
  212 + <form-movie-comment-form @event:submit="handleSubmitEvent" />
  213 +
  214 + <!-- Liste des commentaires -->
  215 + <movie-comment-list :comments="comments as unknown as MovieCommentInterface[]" />
8 </section> 216 </section>
9 -</template> 217 + </div>
  218 + </div>
  219 + </div>
10 220
11 -<style scoped> 221 + <!-- Erreur -->
  222 + <section v-else class="container mx-auto px-4 py-16 text-center">
  223 + <AlertTriangleIcon :size="64" class="mx-auto mb-4 text-red-500" />
  224 + <h2 class="text-2xl font-bold mb-2">Film non trouvé</h2>
  225 + <p class="text-gray-400 mb-6">Nous n'avons pas pu trouver le film que vous cherchez.</p>
  226 + <button
  227 + class="px-6 py-2 bg-primary text-white font-bold rounded-md hover:bg-primary-dark transition-colors"
  228 + @click="navigateTo('/')"
  229 + >
  230 + Retour à l'accueil
  231 + </button>
  232 + </section>
  233 + </section>
  234 +</template>
12 235
13 -</style> 236 +<style scoped></style>
  1 +export type Comment = {
  2 + username: string
  3 + message: string
  4 + rating: number
  5 +}
  1 +// vite.config.js
  2 +import vue from '@vitejs/plugin-vue'
  3 +
  4 +export default {
  5 + plugins: [vue()],
  6 + test: {
  7 + globals: true,
  8 + environment: "jsdom",
  9 + // Additional test configurations can be added here
  10 + },
  11 +}
  1 +import { defineVitestConfig } from '@nuxt/test-utils/config'
  2 +
  3 +export default defineVitestConfig({
  4 + /**
  5 + * Documentation here : https://nuxt.com/docs/getting-started/testing
  6 + * any custom Vitest config you require
  7 + */
  8 + test: {
  9 + environment: 'nuxt',
  10 + // you can optionally set Nuxt-specific environment options
  11 + // environmentOptions: {
  12 + // nuxt: {
  13 + // rootDir: fileURLToPath(new URL('./playground', import.meta.url)),
  14 + // domEnvironment: 'happy-dom', // 'happy-dom' (default) or 'jsdom'
  15 + // overrides: {
  16 + // // other Nuxt config you want to pass
  17 + // }
  18 + // }
  19 + // }
  20 + }
  21 +})