Showing
30 changed files
with
1043 additions
and
56 deletions
| 1 | +0.3.0: | ||
| 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. | ||
| 17 | + | ||
| 1 | 0.2.0: | 18 | 0.2.0: |
| 2 | - Mise en place du CHANGELOG_RELEASE. | 19 | - Mise en place du CHANGELOG_RELEASE. |
| 3 | - Ajout page index. | 20 | - Ajout page index. |
components/MovieCard.vue
0 → 100644
| 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> |
components/MovieCommentList.vue
0 → 100644
| 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" /> |
components/details/MovieGender.vue
0 → 100644
| 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> |
components/details/ScoreAndVote.vue
0 → 100644
| 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> |
components/form/MovieCommentForm.vue
0 → 100644
| 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(/(<([^>]+)>)/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> |
components/test/HelloWorld.spec.ts
0 → 100644
| 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 | +}) |
components/test/HelloWorld.vue
0 → 100644
components/ui-components/BackdropImage.vue
0 → 100644
| 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> |
components/ui-components/Loader.vue
0 → 100644
| 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> |
components/ui-components/Poster.vue
0 → 100644
| 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 | +}; |
interfaces/credit.ts
0 → 100644
| 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 | } |
interfaces/movieComment.ts
0 → 100644
models/credit.ts
0 → 100644
| 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 | } |
models/movieComment.ts
0 → 100644
| 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.
| 1 | { | 1 | { |
| 2 | "name": "nuxt-app", | 2 | "name": "nuxt-app", |
| 3 | - "version": "0.2.0", | 3 | + "version": "0.3.0", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| @@ -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> |
type/commentForm.ts
0 → 100644
vite.config.ts
0 → 100644
vitest.config.m.ts
0 → 100644
| 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 | +}) |
-
Please register or login to post a comment