MoviesList.vue 6.48 KB
<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>