MoviesList.vue 5.22 KB
<script lang="ts" setup>
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";
// #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
 */
async function fetchMovies(page: number) {
  try {
    isLoadingMore.value = true;
    const data = await fetchPopularMovies(page);
    // 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) {
    throw new Error(`Error fetching popular movies: ${error}`);
  }
  finally {
    isInitialLoading.value = false;
    isLoadingMore.value = false;
  }
}

/**
 * Search movies
 * @param query
 * @param page
 */
async function search(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 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) {
    throw new Error(`Error searching movies: ${error}`);
  }
  finally {
    isInitialLoading.value = false;
    isLoadingMore.value = false;
  }
}

function createIntersectionObserver() {
  return new IntersectionObserver(
    (entries) => {
      const [entry] = entries;
      // 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 },
  );
}

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();
  // 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);
});

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 -->
    <ui-components-search-bar
      placeholder="Rechercher un film..."
      @event-search="handleSearchEvent"
      @event-clear-search="handleClearSearchEvent"
    />

    <!-- Loading Skeleton -->
    <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"
      >
        <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>

    <!-- Loader pour le chargement de plus de films -->
    <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"
    />
  </section>
</template>

<style scoped></style>