Bruno Predot

Merge branch 'feature/detail_page' into develop

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