Bruno Predot

Merge branch 'release/0.2.0'

0.2.0:
- Mise en place du CHANGELOG_RELEASE.
- Ajout page index.
- Modification app.vue afin d'initialiser l'app avec vuetify et NuxtPage pour démarrer sur la page index.
- Création du composant MoviesList.
- Création du composant SearchBar.
- Création du composant SkeletonMoviesLoader.
- Installation et paramétrage de pinia-orm.
- Ajout du model Movie.
- Création de la page movies/[id] vierge pour le détail d'un film.
0.1.2:
- Ajout fichier .prettierignore.
- Installation module vuetify + modif script lint dans package.json.
... ...
------ Dispo à la prochaine release ------------
\ No newline at end of file
... ...
<script lang="ts" setup></script>
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
<v-locale-provider>
<v-app>
<v-main class="min-h-screen bg-gray-900 text-white">
<NuxtPage />
</v-main>
</v-app>
</v-locale-provider>
</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 type { MovieInterface } from "~/interfaces/movie";
import { useDateFormat } from "@vueuse/core";
//#endregion
//#region --Declaration--.
const { fetchPopularMovies, searchMovies } = useTMDB();
//#endregion
//#region --Data/refs--.
const isInitialLoading = ref(true);
const isLoadingMore = ref(false);
const currentPage = ref(1);
const totalPages = ref(0);
const searchQuery = ref("");
/** Elément observé pour le défilement infini. */
const loadMoreTrigger = ref<HTMLElement | null>(null);
/** Instance de IntersectionObserver */
const observer = ref<IntersectionObserver | null>(null);
//#endregion
//#region --Computed--.
const movies = computed(() => {
return useRepo(Movie).query().orderBy("popularity", "desc").get() as unknown as MovieInterface[];
});
//#endregion
//#region --Function--.
/**
* Fetch popular movies
* @param page
*/
const fetchMovies = async (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);
}
totalPages.value = data.total_pages;
currentPage.value = page;
} catch (error) {
console.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) => {
// If empty search, fetch popular movies.
if (!query.trim()) {
await fetchMovies(1);
return;
}
try {
isLoadingMore.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);
}
totalPages.value = data.total_pages;
currentPage.value = page;
} catch (error) {
console.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);
}
}
},
{ threshold: 1.0 },
);
}
function handleSearchEvent(event: string) {
currentPage.value = 1;
searchQuery.value = event;
search(event, 1);
}
function handleClearSearchEvent() {
searchQuery.value = '';
currentPage.value = 1;
// Fetch popular movies after clear.
fetchMovies(1);
}
//#endregion
//#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);
}
if (loadMoreTrigger.value) {
observer.value.observe(loadMoreTrigger.value);
}
});
onBeforeUnmount(() => {
// Disconnect the observer when the component is unmounted.
if (observer.value) {
observer.value.disconnect();
}
});
//#endregion
</script>
<template>
<section>
<h1 class="text-4xl font-bold mb-8 text-center">Découvrez les films populaires</h1>
<!-- Barre de recherche -->
<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" />
<!-- 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>
</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>
<!-- 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>
<!-- Élément observé pour le défilement infini -->
<div ref="loadMoreTrigger" class="h-10 mt-4" />
</section>
</template>
<style scoped></style>
... ...
<script lang="ts" setup>
//#region --import--.
import { SearchIcon, XIcon } from "lucide-vue-next";
import { ref } from "vue";
import { useDebounceFn } from "@vueuse/core";
//#endregion
//#region --Emits--.
const emit = defineEmits(['event:search', 'event:clear_search']);
//#endregion
//#region --Props--.
defineProps({
placeholder: {
type: String,
required: false,
nullable: false,
default: "",
},
});
//#endregion
//#region --Data/refs--.
const searchQuery = ref("");
//#endregion
//#region --Function--.
/**
* Debounced function
*/
const handleSearchEvent = useDebounceFn(() => {
emit('event:search', searchQuery.value);
}, 500);
function handleClearSearchEvent() {
searchQuery.value = '';
emit('event:clear_search')
}
//#endregion
</script>
<template>
<!-- Barre de recherche -->
<section class="mb-8">
<div class="relative max-w-xl mx-auto">
<input
v-model="searchQuery"
:placeholder="placeholder"
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"
@click="handleClearSearchEvent"
>
<XIcon :size="20" />
</button>
<button v-else class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400">
<SearchIcon :size="20" />
</button>
</div>
</section>
</template>
<style scoped></style>
... ...
<script lang="ts" setup>
//#region --Props--.
defineProps({
isInitialLoading: {
type: Boolean,
required: true,
nullable: false,
},
skeletonNumber: {
type: Number,
required: false,
nullable: false,
default: 12,
},
});
//#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">
<div class="h-80 bg-gray-700" />
<div class="p-4">
<div class="h-6 bg-gray-700 rounded mb-3" />
<div class="h-4 bg-gray-700 rounded w-2/3" />
</div>
</div>
</section>
</template>
<style scoped></style>
\ No newline at end of file
... ...
... ... @@ -2,9 +2,47 @@ import type { RuntimeConfig } from "nuxt/schema";
export const useTMDB = function() {
const runtimeconfig: RuntimeConfig = useRuntimeConfig();
const apiUrl = runtimeconfig.public.apiTMDBUrl;
const apiKey = runtimeconfig.public.apiTMDBSecret;
return {apiUrl, apiKey}
/**
* Fetch popular movies.
* @param page
*/
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);
}
};
/**
* Search movies
* @param query
* @param page
*/
const searchMovies = async (query: string, page: number) => {
try {
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);
}
};
return { fetchPopularMovies, searchMovies }
}
\ No newline at end of file
... ...
export interface MovieInterface {
id: number;
title: string;
poster_path: string | null;
vote_average: number;
release_date: string;
}
\ No newline at end of file
... ...
import { Model } from "pinia-orm";
export class Movie extends Model {
/**
*
* @return {string}
*/
static get entity() {
return "Movie";
}
/**
*
* @return {string}
*/
static get primaryKey() {
return "id";
}
static fields() {
return {
// Attributs.
id: this.number(null),
adult: this.boolean(false),
backdrop_pat: this.string(null),
genre_ids: 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),
release_date: this.string(null),
title: this.string(null),
video: this.boolean(false),
vote_average: this.number(null),
vote_count: this.number(null),
// Relations.
};
}
static piniaOptions = {
persist: true,
};
}
\ No newline at end of file
... ...
... ... @@ -35,6 +35,16 @@ export default defineNuxtConfig({
],
},
],
[
"@pinia-orm/nuxt",
{
autoImports: [
// automatically imports `useRepo`.
"useRepo", // import { useRepo } from 'pinia-orm'.
["useRepo", "usePinaRepo"], // import { useRepo as usePinaRepo } from 'pinia-orm'.
],
},
],
"pinia-plugin-persistedstate/nuxt",
"@nuxt/scripts",
"@nuxt/test-utils",
... ...
{
"name": "nuxt-app",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nuxt-app",
"version": "0.1.0",
"version": "0.2.0",
"hasInstallScript": true,
"dependencies": {
"@nuxt/eslint": "^1.3.0",
... ... @@ -15,14 +15,15 @@
"@nuxt/scripts": "^0.11.6",
"@nuxt/test-utils": "^3.17.2",
"@nuxt/ui": "^2.22.0",
"@pinia/nuxt": "^0.11.0",
"@pinia-orm/nuxt": "^1.10.2",
"@pinia/nuxt": "^0.9.0",
"@unhead/vue": "^2.0.8",
"@vueuse/core": "^13.1.0",
"@vueuse/nuxt": "^13.1.0",
"eslint": "^9.25.1",
"lucide-vue-next": "^0.503.0",
"nuxt": "^3.16.2",
"pinia": "^3.0.2",
"pinia": "^2.3.1",
"pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
... ... @@ -3820,10 +3821,35 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@pinia-orm/normalizr": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@pinia-orm/normalizr/-/normalizr-1.10.2.tgz",
"integrity": "sha512-lgcCb7ST/leYXJwUT/y7RvTn+5U6OOmvSUuNGs/Mpqrx99IG3R9DSWA3w7n/wl7yDt5+35J0ERK3bebQW1STsQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/codedredd"
}
},
"node_modules/@pinia-orm/nuxt": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@pinia-orm/nuxt/-/nuxt-1.10.2.tgz",
"integrity": "sha512-I/dNHuFR2V8K9X6oi5P+EKCbiSRSenbMfMXySmxQOqaMU81f2XVaPVTaq9dgomo9i5mE7x/QqRK49ne8av51ag==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.12.3",
"pinia-orm": "1.10.2"
},
"funding": {
"url": "https://github.com/sponsors/codedredd"
},
"peerDependencies": {
"@pinia/nuxt": "<=0.9.0"
}
},
"node_modules/@pinia/nuxt": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.0.tgz",
"integrity": "sha512-QGFlUAkeVAhPCTXacrtNP4ti24sGEleVzmxcTALY9IkS6U5OUox7vmNL1pkqBeW39oSNq/UC5m40ofDEPHB1fg==",
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.9.0.tgz",
"integrity": "sha512-2yeRo7LeyCF68AbNeL3xu2h6uw0617RkcsYxmA8DJM0R0PMdz5wQHnc44KeENQxR/Mrq8T910XVT6buosqsjBQ==",
"license": "MIT",
"dependencies": {
"@nuxt/kit": "^3.9.0"
... ... @@ -3832,7 +3858,7 @@
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"pinia": "^3.0.2"
"pinia": "^2.3.0"
}
},
"node_modules/@pkgjs/parseargs": {
... ... @@ -13014,12 +13040,13 @@
}
},
"node_modules/pinia": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.2.tgz",
"integrity": "sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.2"
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
... ... @@ -13034,6 +13061,22 @@
}
}
},
"node_modules/pinia-orm": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/pinia-orm/-/pinia-orm-1.10.2.tgz",
"integrity": "sha512-Q8QwFFdAmhc347QY6ndXtLZX4kE+46dUQbyy0ha6URmdIaz1jf8FbZEJ8BhHMLGPx+PeO/QJraxvvETx62lMQA==",
"license": "MIT",
"dependencies": {
"@pinia-orm/normalizr": "1.10.2",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/codedredd"
},
"peerDependencies": {
"pinia": "^2.1.7"
}
},
"node_modules/pinia-plugin-persistedstate": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.2.0.tgz",
... ... @@ -13058,15 +13101,6 @@
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.5.tgz",
"integrity": "sha512-HYV3tJGARROq5nlVMJh5KKHk7GU8Au3IrrmNNqr978m0edxgpHgYPDoNUGrvEgIbObz09SQezFR3A1EVmB5WZg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.5"
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
... ... @@ -17155,6 +17189,32 @@
"ufo": "^1.5.4"
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-devtools-stub": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz",
... ...
{
"name": "nuxt-app",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
... ... @@ -21,14 +21,15 @@
"@nuxt/scripts": "^0.11.6",
"@nuxt/test-utils": "^3.17.2",
"@nuxt/ui": "^2.22.0",
"@pinia/nuxt": "^0.11.0",
"@pinia-orm/nuxt": "^1.10.2",
"@pinia/nuxt": "^0.9.0",
"@unhead/vue": "^2.0.8",
"@vueuse/core": "^13.1.0",
"@vueuse/nuxt": "^13.1.0",
"eslint": "^9.25.1",
"lucide-vue-next": "^0.503.0",
"nuxt": "^3.16.2",
"pinia": "^3.0.2",
"pinia": "^2.3.1",
"pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
... ...
<script lang="ts" setup></script>
<template>
<v-container class="mx-auto px-4 py-8">
<movies-list />
</v-container>
</template>
<style scoped></style>
... ...
<script setup lang="ts">
</script>
<template>
<section>
composant détail d'un film.
</section>
</template>
<style scoped>
</style>
\ No newline at end of file
... ...