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