Bruno Predot

Merge branch 'hotfix/0.4.0'

module.exports = {
// https://dev.to/tao/adding-eslint-and-prettier-to-nuxt-3-2023-5bg
root: true,
extends: ["@nuxtjs/eslint-config", "plugin:prettier/recommended"],
extends: ["@nuxtjs/eslint-config"],
env: {
browser: true,
node: true,
},
parser: "vue-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
},
plugins: [],
// add your custom rules here
rules: {},
};
... ...
node_modules
logs
*.log*
.output
.nuxt
.nitro
.cache
dist
.gitignore
.env
.env.*
!.env.example
CHANGELOG.md
CHANGELOG_RELEASE.md
.idea
\ No newline at end of file
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 120,
"arrowParens": "always",
"useTabs": false,
"tabWidth": 2
}
0.4.0:
- Modification de la config eslint avec la suppression de tout ce qui concerne prettier, suppression du module @nuxt/eslint, contenant le module eslint/recommanded, et remplacement par le module @antfu/eslint-config, plus complet et simple.
- Lintfix selon les règles de @antfu/eslint-config.
- Typage des props.
- Ré écriture des emits.
- Personalisation de rules antfu.
- Factorisation en incluant les guards clauses et ternaires.
- Nettoyage.
0.3.4:
- Mise à jour dépendance avec ajout de typescript-eslint + fix
0.3.3:
- Modif des script dans le package.json.
- Ajout lint exception.
- Fin config es-lint.
0.3.2:
- Amélioration config es-lint + fix.
0.3.1:
- ajout fichier de test MovieGender.spec.ts.
- ajout fichier de test ScoreAndVote.spec.ts.
... ...
<script lang="ts" setup>
//#region --Props--.
import type { MovieInterface } from "~/interfaces/movie";
// #region --Props--.
import { useDateFormat } from "@vueuse/core";
import { FilmIcon } from "lucide-vue-next";
//#endregion
// #endregion
//#region --Props--.
defineProps({
movie: {
type: Object,
required: true,
nullable: false,
},
});
//#endregion
// #region --Props--.
/** Typescript typage */
defineProps<{
movie: MovieInterface;
}>();
// #endregion
</script>
<template>
... ... @@ -26,9 +24,15 @@ defineProps({
: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
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"
... ...
<script lang="ts" setup>
//#region --Import--.
// #region --Import--.
import type { MovieCommentInterface } from "~/interfaces/movieComment";
import { MessageSquareIcon } from "lucide-vue-next";
//#endregion
// #endregion
//#region --Props--.
const props = defineProps({
comments: {
type: Array<MovieCommentInterface>,
required: true,
nullable: false,
},
});
//#endregion
// #region --Props--.
/** Typescript typage */
const props = defineProps<{
comments: Array<MovieCommentInterface>;
}>();
// #endregion
//#region --Watch--.
// #region --Watch--.
watch(
() => props.comments,
(comments) => {
... ... @@ -29,33 +26,55 @@ watch(
},
{ immediate: true },
);
//#endregion
// #endregion
</script>
<template>
<section>
<!-- Liste des commentaires -->
<section v-if="comments.length > 0" class="mt-10">
<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
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>
<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">
<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
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>
... ...
<script lang="ts" setup>
//#region --import--.
import type { MovieInterface } from "~/interfaces/movie";
import { SearchXIcon } from "lucide-vue-next";
// #region --import--.
import { onBeforeUnmount, ref } from "vue";
import { useTMDB } from "~/composables/tMDB";
import { Movie } from "~/models/movie";
import { SearchXIcon } from "lucide-vue-next";
import type { MovieInterface } from "~/interfaces/movie";
//#endregion
// #endregion
//#region --Declaration--.
// #region --Declaration--.
const { fetchPopularMovies, searchMovies } = useTMDB();
//#endregion
// #endregion
//#region --Data/refs--.
// #region --Data/refs--.
const isInitialLoading = ref(true);
const isLoadingMore = ref(false);
const currentPage = ref(1);
... ... @@ -21,47 +21,43 @@ const searchQuery = ref("");
const loadMoreTrigger = ref<HTMLElement | null>(null);
/** Instance de IntersectionObserver */
const observer = ref<IntersectionObserver | null>(null);
//#endregion
// #endregion
//#region --Computed--.
// #region --Computed--.
const movies = computed(() => {
return useRepo(Movie).query().orderBy("popularity", "desc").get() as unknown as MovieInterface[];
});
//#endregion
// #endregion
//#region --Function--.
// #region --Function--.
/**
* Fetch popular movies
* @param page
*/
const fetchMovies = async (page: number) => {
async function fetchMovies(page: number) {
try {
isLoadingMore.value = true;
const data = await fetchPopularMovies(page);
// Save in Movie model.
if (isInitialLoading.value) {
// First fetch, erase old data before save.
useRepo(Movie).fresh(data.results);
} else {
// Add to store collection.
useRepo(Movie).save(data.results);
}
// Save in Movie model. If first fetch, erase old data before save or, add to store collection.
isInitialLoading.value ? useRepo(Movie).fresh(data.results) : useRepo(Movie).save(data.results);
totalPages.value = data.total_pages;
currentPage.value = page;
} catch (error) {
console.error("Error fetching popular movies:", error);
} finally {
}
catch (error) {
throw new Error(`Error fetching popular movies: ${error}`);
}
finally {
isInitialLoading.value = false;
isLoadingMore.value = false;
}
};
}
/**
* Search movies
* @param query
* @param page
*/
const search = async (query: string, page: number) => {
async function search(query: string, page: number) {
// If empty search, fetch popular movies.
if (!query.trim()) {
await fetchMovies(1);
... ... @@ -69,41 +65,29 @@ const search = async (query: string, page: number) => {
}
try {
isLoadingMore.value = true;
if (page === 1) {
isInitialLoading.value = true;
}
if (page === 1) isInitialLoading.value = true;
const data = await searchMovies(query, page);
// Save in Movie model.
if (isInitialLoading.value) {
// First fetch, erase old data before save.
useRepo(Movie).fresh(data.results);
} else {
// Add to store collection.
useRepo(Movie).save(data.results);
}
// Save in Movie model. If first fetch, erase old data before save or, add to store collection.
isInitialLoading.value ? useRepo(Movie).fresh(data.results) : useRepo(Movie).save(data.results);
totalPages.value = data.total_pages;
currentPage.value = page;
} catch (error) {
console.error("Error searching movies:", error);
} finally {
}
catch (error) {
throw new Error(`Error searching movies: ${error}`);
}
finally {
isInitialLoading.value = false;
isLoadingMore.value = false;
}
};
}
function createIntersectionObserver() {
return new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && !isLoadingMore.value && currentPage.value < totalPages.value) {
if (searchQuery.value) {
// Continue searching query if already active.
search(searchQuery.value, currentPage.value + 1);
} else {
// Continue fetching popular movies.
fetchMovies(currentPage.value + 1);
}
}
// Continue searching query if already active or, continue fetching popular movies.
if (entry.isIntersecting && !isLoadingMore.value && currentPage.value < totalPages.value) searchQuery.value ? search(searchQuery.value, currentPage.value + 1) : fetchMovies(currentPage.value + 1);
},
{ threshold: 1.0 },
);
... ... @@ -122,41 +106,37 @@ function handleClearSearchEvent() {
fetchMovies(1);
}
//#endregion
// #endregion
//#region --Global event--.
// #region --Global event--.
onMounted(() => {
// First loading.
fetchMovies(1);
// Création et stockage dans la ref de l'instance IntersectionObserver.
observer.value = createIntersectionObserver();
if (loadMoreTrigger.value) {
// Début d'observation de la div pour le défilement infini.
observer.value.observe(loadMoreTrigger.value);
}
// Début d'observation de la div pour le défilement infini.
if (loadMoreTrigger.value) observer.value.observe(loadMoreTrigger.value);
if (loadMoreTrigger.value) {
observer.value.observe(loadMoreTrigger.value);
}
if (loadMoreTrigger.value) observer.value.observe(loadMoreTrigger.value);
});
onBeforeUnmount(() => {
// Disconnect the observer when the component is unmounted.
if (observer.value) {
observer.value.disconnect();
}
if (observer.value) observer.value.disconnect();
});
//#endregion
// #endregion
</script>
<template>
<section>
<h1 class="text-4xl font-bold mb-8 text-center">Découvrez les films populaires</h1>
<h1 class="text-4xl font-bold mb-8 text-center">
Découvrez les films populaires
</h1>
<!-- Barre de recherche -->
<ui-components-search-bar
placeholder="Rechercher un film..."
@event:search="handleSearchEvent"
@event:clear_search="handleClearSearchEvent"
@event-search="handleSearchEvent"
@event-clear-search="handleClearSearchEvent"
/>
<!-- Loading Skeleton -->
... ... @@ -167,24 +147,46 @@ onBeforeUnmount(() => {
/>
<!-- 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">
<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"
>
<movie-card :movie="movie" />
</div>
</div>
<!-- Message si aucun film trouvé -->
<section v-else-if="searchQuery && !movies.length" class="text-center py-12">
<SearchXIcon :size="64" class="mx-auto mb-4 text-gray-600" />
<h3 class="text-xl font-bold mb-2">Aucun film trouvé</h3>
<p class="text-gray-400">Essayez avec un autre terme de recherche</p>
<section
v-else-if="searchQuery && !movies.length"
class="text-center py-12"
>
<SearchXIcon
:size="64"
class="mx-auto mb-4 text-gray-600"
/>
<h3 class="text-xl font-bold mb-2">
Aucun film trouvé
</h3>
<p class="text-gray-400">
Essayez avec un autre terme de recherche
</p>
</section>
<!-- Loader pour le chargement de plus de films -->
<ui-components-loader :is-initial-loading="isInitialLoading" :is-loading="isLoadingMore" />
<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" />
<div
ref="loadMoreTrigger"
class="h-10 mt-4"
/>
</section>
</template>
... ...
<script lang="ts" setup>
//#region --Import--.
// #region --Import--.
import type { Genre } from "~/interfaces/movie";
//#endregion
// #endregion
//#region --Props--.
defineProps({
genres: {
type: Array<Genre>,
required: true,
nullable: false,
},
});
//#endregion
// #region --Props--.
defineProps<{
genres: Array<Genre>;
}>();
// #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">
<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>
... ...
<script lang="ts" setup>
//#region --Props--.
defineProps({
score: {
type: Number,
required: true,
nullable: false,
},
nbVote: {
type: Number,
required: true,
nullable: false,
},
});
//#endregion
// #region --Props--.
/** Typescript typage */
defineProps<{
score: number;
nbVote: number;
}>();
// #endregion
//#region --Function--.
// #region --Function--.
/**
* Format vote count if > 1000.
* @param count
*/
const formatVoteCount = (count: number) => {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k votes`;
}
function formatVoteCount(count: number) {
if (count >= 1000) return `${(count / 1000).toFixed(1)}k votes`;
return `${count} votes`;
};
//#endregion
}
// #endregion
</script>
<template>
... ... @@ -34,7 +25,9 @@ const formatVoteCount = (count: number) => {
{{ score.toFixed(1) }}
</section>
<section>
<p class="font-semibold">Note TMDB</p>
<p class="font-semibold">
Note TMDB
</p>
<div class="text-sm text-gray-400">
{{ formatVoteCount(nbVote) }}
</div>
... ...
<script lang="ts" setup>
//#region --Import--.
import type { Comment } from "~/type/commentForm";
// #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
// #endregion
//#region --Props--.
defineProps({
isSubmitting: {
type: Boolean,
required: false,
nullable: false,
default: false,
},
// #region --Props--.
withDefaults(defineProps<{
isSubmitting?: boolean;
}>(), {
isSubmitting: false,
});
//#endregion
// #endregion
// #region --Emit--.
const emit = defineEmits<{
eventSubmit: [formData: any];
}>();
// #endregion
//#region --Data/ref--.
// #region --Data/ref--.
const initialState: Comment = {
username: "",
message: "",
... ... @@ -35,6 +34,7 @@ const rules = {
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",
// eslint-disable-next-line regexp/no-obscure-range
helpers.regex(/^[a-zA-ZÀ-ÿ\s]+$/),
),
},
... ... @@ -54,15 +54,15 @@ const formData = reactive({
...initialState,
});
const v$ = useVuelidate(rules, formData);
//#endregion
// #endregion
// const errormessages = computed(() => {
// return v$.value.message.$errors.map((e) => e.$message);
// });
//#region --Function--.
// #region --Function--.
async function submitComment() {
emit("event:submit", formData);
emit("eventSubmit", formData);
}
function clear() {
... ... @@ -80,7 +80,7 @@ function handleMessageEvent(event: string) {
// console.log(formData.message.replace(/(&lt;([^&gt;]+)&gt;)/ig, ''));
}
//#endregion
// #endregion
</script>
<template>
... ... @@ -105,11 +105,11 @@ function handleMessageEvent(event: string) {
@blur="v$.rating.$touch"
@input="v$.rating.$touch"
/>
<!-- <pre>{{ errormessages }}</pre>-->
<!-- <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"
@update-model-value="handleMessageEvent"
/>
<v-btn
class="mt-6 mr-4"
... ... @@ -117,19 +117,26 @@ function handleMessageEvent(event: string) {
@click="
async () => {
const validForm = await v$.$validate();
if (validForm) {
submitComment();
}
if (validForm) submitComment();
}
"
>
<span v-if="isSubmitting" class="flex items-center justify-center">
<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>
<v-btn
class="mt-6 mr-4"
color="primary"
@click="clear"
>
effacer
</v-btn>
</VForm>
</section>
</template>
... ...
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import HelloWorld from "./HelloWorld.vue";
describe("HelloWorld", () => {
describe("helloWorld", () => {
it("component renders Hello world properly", () => {
const wrapper = mount(HelloWorld);
expect(wrapper.text()).toContain("Hello world");
... ...
<script lang="ts" setup>
//#region --Props--.
defineProps({
src: {
type: String,
required: true,
nullable: false,
},
title: {
type: String,
required: true,
nullable: false,
},
});
//#endregion
// #region --Props--.
defineProps<{
src: string;
title: string;
}>();
// #endregion
//#region --Declaration--.
// #region --Declaration--.
const w: Window = window;
//#endregion
// #endregion
</script>
<template>
... ...
<script lang="ts" setup>
//#region --Props--.
defineProps({
isLoading: {
type: Boolean,
required: true,
nullable: false,
},
isInitialLoading: {
type: Boolean,
required: false,
nullable: false,
default: false,
},
// #region --Props--.
withDefaults(defineProps<{
isLoading: boolean;
isInitialLoading?: boolean;
}>(), {
isInitialLoading: false,
});
//#endregion
// #endregion
</script>
<template>
<section v-if="isLoading && !isInitialLoading" class="flex justify-center mt-8">
<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>
... ...
<script setup lang="ts">
//#region --Props--.
// #region --Props--.
import { FilmIcon } from "lucide-vue-next";
defineProps({
src: {
type: String,
required: true,
nullable: false,
},
title: {
type: String,
required: true,
nullable: false,
},
});
//#endregion
defineProps<{
src: string;
title: string;
}>();
// #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" />
<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>
... ...
<script lang="ts" setup>
//#region --import--.
import { useDebounceFn } from "@vueuse/core";
// #region --import--.
import { SearchIcon, XIcon } from "lucide-vue-next";
import { ref } from "vue";
import { useDebounceFn } from "@vueuse/core";
//#endregion
// #endregion
//#region --Emits--.
const emit = defineEmits(["event:search", "event:clear_search"]);
//#endregion
//#region --Props--.
defineProps({
placeholder: {
type: String,
required: false,
nullable: false,
default: "",
},
// #region --Props--.
withDefaults(defineProps<{
placeholder?: string;
}>(), {
placeholder: "",
});
//#endregion
// #endregion
// #region --Emits--.
const emit = defineEmits<{
eventSearch: [search: string];
eventClearSearch: [];
}>();
// #endregion
//#region --Data/refs--.
// #region --Data/refs--.
const searchQuery = ref("");
//#endregion
// #endregion
//#region --Function--.
// #region --Function--.
/**
* Debounced function
*/
const handleSearchEvent = useDebounceFn(() => {
emit("event:search", searchQuery.value);
emit("eventSearch", searchQuery.value);
}, 500);
function handleClearSearchEvent() {
searchQuery.value = "";
emit("event:clear_search");
emit("eventClearSearch");
}
//#endregion
// #endregion
</script>
<template>
... ... @@ -49,7 +49,7 @@ function handleClearSearchEvent() {
class="w-full px-4 py-3 bg-gray-800 rounded-full text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary"
type="text"
@input="handleSearchEvent"
/>
>
<button
v-if="searchQuery"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-white"
... ... @@ -57,7 +57,10 @@ function handleClearSearchEvent() {
>
<XIcon :size="20" />
</button>
<button v-else class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400">
<button
v-else
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400"
>
<SearchIcon :size="20" />
</button>
</div>
... ...
<script lang="ts" setup>
//#region --Props--.
defineProps({
isInitialLoading: {
type: Boolean,
required: true,
nullable: false,
},
skeletonNumber: {
type: Number,
required: false,
nullable: false,
default: 12,
},
// #region --Props--.
withDefaults(defineProps<{
isInitialLoading: boolean;
skeletonNumber?: number;
}>(), {
skeletonNumber: 12,
});
//#endregion
// #endregion
</script>
<template>
<!-- Skeleton loader pendant le chargement initial -->
<section v-if="isInitialLoading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
<div v-for="i in skeletonNumber" :key="i" class="bg-gray-800 rounded-lg overflow-hidden shadow-lg animate-pulse">
<section
v-if="isInitialLoading"
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6"
>
<div
v-for="i in skeletonNumber"
:key="i"
class="bg-gray-800 rounded-lg overflow-hidden shadow-lg animate-pulse"
>
<div class="h-80 bg-gray-700" />
<div class="p-4">
<div class="h-6 bg-gray-700 rounded mb-3" />
... ...
<script lang="ts" setup>
//#region --Import--.
// #region --Import--.
import Editor from "@tinymce/tinymce-vue";
import { ref, watch } from "vue";
//#endregion
// #endregion
//#region --Declaration--.
const runtimeConfig = useRuntimeConfig();
//#endregion
//#region --Emit--.
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
//#endregion
//#region --Props--.
// #region --Props--.
const props = defineProps<{
modelValue: string;
errorMessage: string;
}>();
//#endregion
// #endregion
// #region --Emit--.
const emit = defineEmits<{
(e: "updateModelValue", value: string): void;
}>();
// #endregion
// #region --Declaration--.
const runtimeConfig = useRuntimeConfig();
// #endregion
//#region --Data/ref--.
// #region --Data/ref--.
const content = ref(props.modelValue);
const init = {
height: 300,
... ... @@ -48,10 +48,10 @@ const init = {
"wordcount",
],
toolbar:
"undo redo | blocks | bold italic underline strikethrough |" +
"bold italic forecolor | alignleft aligncenter " +
"alignright alignjustify | bullist numlist outdent indent | " +
"removeformat | help",
"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",
... ... @@ -59,29 +59,34 @@ const init = {
// valid_elements: [],
// entity_encoding : "raw",
};
//#endregion
// #endregion
//#region --Watch--.
// #region --Watch--.
watch(content, (newValue) => {
emit("update:modelValue", newValue);
emit("updateModelValue", newValue);
});
watch(
() => props.modelValue,
(newValue) => {
if (newValue !== content.value) {
content.value = newValue;
}
if (newValue !== content.value) content.value = newValue;
},
);
//#endregion
// #endregion
</script>
<template>
<div>
<editor v-model="content" :api-key="runtimeConfig.public.apiTinyMceSecret" :init="init" />
<Editor
v-model="content"
:api-key="runtimeConfig.public.apiTinyMceSecret"
:init="init"
/>
</div>
<div v-if="errorMessage" class="text-red-500 text-sm mt-1">
<div
v-if="errorMessage"
class="text-red-500 text-sm mt-1"
>
{{ errorMessage }}
</div>
</template>
... ...
... ... @@ -3,7 +3,10 @@
<template>
<v-container class="bg-gray-900">
<v-row class="bg-gray-900">
<v-col cols="12" sm="4">
<v-col
cols="12"
sm="4"
>
<v-skeleton-loader
class="mx-auto border bg-gray-800"
color="#1f2937"
... ... @@ -12,7 +15,10 @@
type="paragraph, image"
/>
</v-col>
<v-col cols="12" sm="8">
<v-col
cols="12"
sm="8"
>
<v-skeleton-loader
class="mx-auto mt-10"
color="#1f2937"
... ...
import type { RuntimeConfig } from "nuxt/schema";
export const useTMDB = function () {
export function useTMDB() {
const runtimeconfig: RuntimeConfig = useRuntimeConfig();
const apiUrl = runtimeconfig.public.apiTMDBUrl;
const apiKey = runtimeconfig.public.apiTMDBSecret;
... ... @@ -12,13 +12,11 @@ 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}`);
if (!response.ok) {
console.error("An error occurred when fetching popular movies:");
} else {
return await response.json();
}
} catch (error) {
console.error("Error fetching popular movies:", error);
if (!response.ok) throw new Error("An error occurred when fetching popular movies");
return await response.json();
}
catch (error) {
throw new Error(`Error fetching popular movies: ${error}`);
}
};
... ... @@ -32,13 +30,11 @@ export const useTMDB = function () {
const response = await fetch(
`${apiUrl}/search/movie?api_key=${apiKey}&language=fr-FR&query=${encodeURIComponent(query)}&page=${page}`,
);
if (!response.ok) {
console.error("An error occurred when searching movies:");
} else {
return await response.json();
}
} catch (error) {
console.error("Error searching movies:", error);
if (!response.ok) throw new Error("An error occurred when searching movies");
return await response.json();
}
catch (error) {
throw new Error(`Error searching movies: ${error}`);
}
};
... ... @@ -49,13 +45,11 @@ export const useTMDB = function () {
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);
if (!response.ok) throw new Error("An error occurred when fetching movie details");
return await response.json();
}
catch (error) {
throw new Error(`Error fetching details: ${error}`);
}
};
... ... @@ -65,15 +59,13 @@ export const useTMDB = function () {
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);
if (!response.ok) throw new Error("An error occurred when fetching movie credits");
return await response.json();
}
catch (error) {
throw new Error(`Error fetching movie credits: ${error}`);
}
};
return { fetchPopularMovies, searchMovies, fetchMovieDetails, fetchMovieCredits };
};
}
... ...
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs";
import js from "@eslint/js";
import eslintPluginVue from "eslint-plugin-vue";
import ts from "typescript-eslint";
import antfu from "@antfu/eslint-config";
const TsConfigRecommended = ts.configs.recommended;
export default antfu({
// `.eslintignore` is no longer supported in Flat config, use `ignores` instead.
ignores: [
"**/fixtures",
"**/.cache",
"**/.data",
"**/.gitignore",
"**/.env",
"**/.env.dist",
"**/.output",
"**/.nitro",
"**/.nuxt",
"**/assets",
"**/dist",
"**/logs",
"**/node_modules",
"**/public",
"**/server",
],
export default withNuxt(
// Your custom configs here
js.configs.recommended,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
...TsConfigRecommended,
...eslintPluginVue.configs["flat/recommended"],
{
files: ["*.vue", "**/*.vue"],
languageOptions: {
parserOptions: {
parser: "@typescript-eslint/parser",
},
},
rules: {
"vue/multi-word-component-names": "off",
},
// Disable jsonc and yaml support.
jsonc: false,
markdown: false,
// personnal rules.
rules: {
"antfu/if-newline": 0,
"antfu/curly": 0,
},
// Enable stylistic formatting rules.
// stylistic: true,
// Or customize the stylistic rules.
stylistic: {
indent: 2, // 4, or 'tab'
semi: true,
stylistic: true,
quotes: "double", // 'single' or 'double'.
},
// your custom flat configs go here, for example:
// {
// files: ['**/*.ts', '**/*.tsx'],
// rules: {
// 'no-console': 'off' // allow console.log in TypeScript files
// }
// },
// {
// ...
// }
);
// Type of the project. 'lib' for libraries, the default is 'app'.
type: "app",
// TypeScript and Vue are autodetected, you can also explicitly enable them:
typescript: true,
vue: true,
yaml: false,
});
... ...
... ... @@ -7,10 +7,10 @@ export interface CreditInterface {
character?: string;
}
export type CreditsResponse = {
export interface CreditsResponse {
id: number;
cast: CreditInterface[];
crew: CreditInterface[];
movie_id: unknown;
movie: MovieInterface;
};
}
... ...
... ... @@ -20,7 +20,7 @@ export interface MovieInterface {
credit: CreditsResponse;
}
export type Genre = {
export interface Genre {
id: number;
name: string;
};
}
... ...
import process from "node:process";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2024-11-01",
... ... @@ -21,8 +23,13 @@ export default defineNuxtConfig({
// css: ['~/assets/css/main.scss'],
eslint: {
config: {
stylistic: true,
},
},
modules: [
"@nuxt/eslint",
"@nuxt/icon",
"@nuxt/image",
"@nuxt/test-utils/module",
... ...
This diff could not be displayed because it is too large.
{
"name": "nuxt-app",
"version": "0.3.1",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
... ... @@ -10,14 +10,11 @@
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint:js": "eslint --ext \".ts,.vue\" . --fix",
"lint:prettier": "prettier --write \"{components,pages,plugins,middleware,layouts,composables,assets}/**/*.{js,jsx,ts,tsx,vue,html,css,scss,json,md}\" .",
"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}\" --list-different .",
"lintfix": "npm run format && npm run lint:js",
"lint": "npm run lint:js",
"lintfix": "eslint . --fix",
"test": "vitest"
},
"dependencies": {
"@nuxt/eslint": "^1.3.0",
"@nuxt/icon": "^1.12.0",
"@nuxt/image": "^1.10.0",
"@nuxt/scripts": "^0.11.6",
... ... @@ -30,8 +27,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",
"@vueuse/core": "^13.2.0",
"@vueuse/nuxt": "^13.2.0",
"eslint": "^9.25.1",
"lucide-vue-next": "^0.503.0",
"nuxt": "^3.16.2",
... ... @@ -42,16 +39,16 @@
"vuetify-nuxt-module": "^0.18.6"
},
"devDependencies": {
"@antfu/eslint-config": "^4.13.0",
"@nuxt/test-utils": "^3.17.2",
"@nuxtjs/tailwindcss": "^6.13.2",
"@typescript-eslint/parser": "^8.32.1",
"@vue/test-utils": "^2.4.6",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"happy-dom": "^17.4.4",
"jsdom": "^26.1.0",
"playwright-core": "^1.52.0",
"prettier": "^3.5.3",
"typescript-eslint": "^8.32.1",
"vitest": "^3.1.2"
"vitest": "^3.1.2",
"vue-eslint-parser": "^10.1.3"
}
}
... ...
<script lang="ts" setup>
//#region --import--.
import type { WhereSecondaryClosure } from "pinia-orm";
import type { CreditsResponse } from "~/interfaces/credit";
import type { MovieInterface } from "~/interfaces/movie";
import type { MovieCommentInterface } from "~/interfaces/movieComment";
// #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 { useTMDB } from "~/composables/tMDB";
import { Credit } from "~/models/credit";
import type { CreditsResponse } from "~/interfaces/credit";
import type { MovieCommentInterface } from "~/interfaces/movieComment";
import { Movie } from "~/models/movie";
import { MovieComment } from "~/models/movieComment";
import type { WhereSecondaryClosure } from "pinia-orm";
//#endregion
// #endregion
//#region --Declaration--.
// #region --Declaration--.
const { fetchMovieDetails, fetchMovieCredits } = useTMDB();
//#endregion
// #endregion
//#region --Declaration--.
// #region --Declaration--.
const { currentRoute } = useRouter();
//#endregion
// #endregion
//#region --Data/ref--.
// #region --Data/ref--.
const isLoading = ref(true);
const isSubmitting = ref(false);
//#endregion
// #endregion
//#region --Computed--.
// #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;
}
if (!currentRoute.value.params.id) return null;
if (typeof currentRoute.value.params.id !== "string") return null;
if (typeof Number(+currentRoute.value.params.id) === "number") return +currentRoute.value.params.id as number;
return currentRoute.value.params.id as string;
});
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;
}
if (!unref(movieId)) return null;
return useRepo(Movie)
.query()
.where("id", movieId.value as WhereSecondaryClosure<never> | null | undefined)
.withAll()
.first() as unknown as MovieInterface;
});
/**
* 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;
}
if (!unref(movie)?.credit?.crew) return null;
return movie.value?.credit.crew.find(person => person.job === "Director");
});
/**
... ... @@ -78,31 +64,33 @@ const comments = computed(() => {
.orderBy("createdAt", "desc")
.get();
});
//#endregion
// #endregion
//#region --Function--.
// #region --Function--.
/**
* Fetch movie details
*/
const fetchDetails = async (id: number | string) => {
async function fetchDetails(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 {
}
catch (error) {
throw new Error(`Error fetching movie details: ${error}`);
}
finally {
isLoading.value = false;
}
};
}
/**
* Format runtime
* @param minutes
*/
const formatRuntime = (minutes: number) => {
function formatRuntime(minutes: number) {
if (!minutes) return "Durée inconnue";
// Find nb hours.
const hours = Math.floor(minutes / 60);
... ... @@ -110,7 +98,7 @@ const formatRuntime = (minutes: number) => {
const mins = minutes % 60;
return `${hours}h ${mins}min`;
};
}
async function fetchCredits(id: number | string) {
try {
... ... @@ -118,12 +106,13 @@ async function fetchCredits(id: number | string) {
data.movie_id = id;
// Add to store collection.
useRepo(Credit).save(data);
} catch (error) {
console.error("Error fetching movie credits:", error);
}
catch (error) {
throw new 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 !
// 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);
... ... @@ -132,9 +121,9 @@ function handleSubmitEvent(event: MovieCommentInterface) {
isSubmitting.value = false;
}
//#endregion
// #endregion
//#region --Global event--.
// #region --Global event--.
onMounted(() => {
// Fetch data on component mount.
if (unref(movieId)) {
... ... @@ -144,7 +133,7 @@ onMounted(() => {
}
// loadComments()
});
//#endregion
// #endregion
</script>
<template>
... ... @@ -153,9 +142,16 @@ onMounted(() => {
<ui-components-skeleton-movie-detail-loader v-if="isLoading" />
<!-- Contenu du film -->
<div v-else-if="movie" class="relative">
<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" />
<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">
... ... @@ -163,41 +159,64 @@ onMounted(() => {
class="flex items-center text-gray-400 hover:text-white mb-8 transition-colors"
@click="navigateTo('/')"
>
<ArrowLeftIcon :size="20" class="mr-2" />
<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" />
<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">
<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" />
<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>
<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">
<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">
... ... @@ -212,8 +231,10 @@ onMounted(() => {
</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" />
<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[]" />
... ... @@ -223,10 +244,20 @@ onMounted(() => {
</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>
<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('/')"
... ...
//#region --Import--.
import { describe, expect, it } from "vitest";
import type { Genre } from "~/interfaces/movie";
import { mount } from "@vue/test-utils";
// #region --Import--.
import { describe, expect, it } from "vitest";
import MovieGender from "../../components/details/MovieGender.vue";
import type { Genre } from "~/interfaces/movie";
//#endregion
// #endregion
describe("MovieGender", () => {
describe("movieGender", () => {
it("affiche correctement les genres", () => {
// Données de test.
const genres: Genre[] = [
... ...
//#region --Import--.
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
// #region --Import--.
import { describe, expect, it } from "vitest";
import ScoreAndVote from "../../components/details/ScoreAndVote.vue";
//#endregion
// #endregion
describe("ScoreAndVote", () => {
describe("scoreAndVote", () => {
it("affiche correctement le score", () => {
// Monter le composant avec ses props.
const wrapper = mount(ScoreAndVote, {
... ...
export type Comment = {
export interface Comment {
username: string;
message: string;
rating: number;
};
}
... ...
import { defineVitestConfig } from "@nuxt/test-utils/config";
import { fileURLToPath } from "node:url";
import { defineVitestConfig } from "@nuxt/test-utils/config";
export default defineVitestConfig({
/**
... ...