diff --git a/movie-group-8/src/components/Navbar.vue b/movie-group-8/src/components/Navbar.vue index 64d7e5700bc6baec7c66c76e9357bbc787de6f9e..ea83fa1aa0b41228417ff9b2171d64bbefc33573 100644 --- a/movie-group-8/src/components/Navbar.vue +++ b/movie-group-8/src/components/Navbar.vue @@ -11,6 +11,7 @@ <div class="absolute left-1/2 transform -translate-x-1/2 w-full max-w-md"> <input v-model="searchQuery" + @keyup.enter="handleSearch" type="text" placeholder="Search..." class="w-full px-4 py-2 pr-10 rounded-full bg-neutral-700 text-white placeholder-gray-400 focus:ring-2 focus:ring-white focus:outline-none" @@ -83,13 +84,12 @@ const route = useRoute(); const router = useRouter(); const isLoggedIn = ref(false); const userPhotoURL = ref(''); +const searchQuery = ref(''); const navigation = ref([ { name: 'Films', href: '/films', authRequired: true }, { name: 'Watchlist', href: '/watchlist', authRequired: true }, - { name: 'Top Rated', href: '/top-rated', authRequired: true }, { name: 'Social', href: '/social', authRequired: true }, - ]); // Compute the current route to match against the navigation links @@ -127,4 +127,10 @@ const handleSignOut = () => { console.error("Error signing out:", error); }); }; + +function handleSearch() { + if (searchQuery.value.trim()) { + router.push({ path: '/films', query: { q: searchQuery.value.trim() } }); + } +} </script> diff --git a/movie-group-8/src/components/WatchlistButton.vue b/movie-group-8/src/components/WatchlistButton.vue index 91458232887237079c738f99224113fc42242682..7e68baada3c94af92dcf9ced1496881420471486 100644 --- a/movie-group-8/src/components/WatchlistButton.vue +++ b/movie-group-8/src/components/WatchlistButton.vue @@ -4,8 +4,8 @@ @click="toggleWatchlist" class="mt-2 px-3 py-1 rounded text-white text-sm transition" :class="isInWatchlist - ? 'bg-red-600 hover:bg-red-700' - : 'bg-green-600 hover:bg-green-700'" + ? 'bg-red-600 hover:bg-red-700 border border-white' + : 'bg-neutral-700 hover:bg-neutral-800 border border-white'" > {{ isInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist' }} </button> diff --git a/movie-group-8/src/stores/WatchlistStore.js b/movie-group-8/src/stores/WatchlistStore.js new file mode 100644 index 0000000000000000000000000000000000000000..b7cc82583eaabf3af1422f5c24b982991e0844e0 --- /dev/null +++ b/movie-group-8/src/stores/WatchlistStore.js @@ -0,0 +1,25 @@ +import { reactive } from 'vue'; + +export const WatchlistStore = reactive({ + watchlist: JSON.parse(localStorage.getItem('watchlist') || '[]'), + + add(movie) { + if (!this.watchlist.find(m => m.id === movie.id)) { + this.watchlist.push(movie); + this.save(); + } + }, + + remove(movie) { + this.watchlist = this.watchlist.filter(m => m.id !== movie.id); + this.save(); + }, + + isInWatchlist(movie) { + return this.watchlist.some(m => m.id === movie.id); + }, + + save() { + localStorage.setItem('watchlist', JSON.stringify(this.watchlist)); + } +}); diff --git a/movie-group-8/src/views/Films.vue b/movie-group-8/src/views/Films.vue index 2343d26e7e79942a734e33569c7a9d2b783a2e5a..05b38809ea0761ad318f2f634ff36f8cc770a2f1 100644 --- a/movie-group-8/src/views/Films.vue +++ b/movie-group-8/src/views/Films.vue @@ -1,107 +1,148 @@ <template> - <div class="films-container"> - <!-- Centered search bar pushed down slightly --> - <div class="search-wrapper"> - <input - v-model="term" - @keyup.enter="onSearch" - placeholder="Search movies…" - class="search-input" - /> - </div> - - <div v-if="loading" class="loading">Loading…</div> - <div v-else-if="error" class="error">Error: {{ error.message }}</div> + <div class="bg-neutral-900 min-h-screen py-10 px-6 sm:px-10"> + <div class="max-w-[85rem] mx-auto mt-12"> + <!-- Title and Filters --> + <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-10"> + <h2 class="text-3xl font-semibold text-white"> + {{ computedTitle }} + </h2> + <div class="flex flex-col sm:flex-row sm:items-center gap-4"> + <select v-model="genre" @change="fetchFiltered" class="px-4 py-2 rounded-lg border border-neutral-700 bg-neutral-800 text-white"> + <option value="">All Genres</option> + <option v-for="g in genres" :key="g.id" :value="g.id">{{ g.name }}</option> + </select> + <select v-model.number="year" @change="fetchFiltered" class="px-4 py-2 rounded-lg border border-neutral-700 bg-neutral-800 text-white"> + <option value="">All Years</option> + <option v-for="y in years" :key="y" :value="y">{{ y }}</option> + </select> + </div> + </div> - <!-- Movie grid --> - <MovieList :movies="results" /> + <!-- Movie Cards --> + <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> + <div + v-for="movie in results" + :key="movie.id" + @click="goToMovie(movie.id)" + class="group flex flex-col h-full bg-neutral-800 hover:bg-neutral-700 cursor-pointer border border-gray-200 shadow-2xs rounded-xl dark:border-neutral-700 dark:shadow-neutral-700/70 text-sm transition-colors duration-150" + > + <div class="rounded-t-xl overflow-hidden"> + <img v-if="movie.poster_path" :src="imgUrl(movie.poster_path)" alt="" class="w-full object-contain rounded-t-xl"/> + </div> + <div class="p-3 flex flex-col flex-grow"> + <h3 class="text-base font-semibold text-gray-200 dark:hover:text-white"> + {{ movie.title }} + </h3> + <p class="mt-2 text-gray-400 dark:text-neutral-400 line-clamp-3"> + {{ movie.overview || 'No description available.' }} + </p> + </div> + <div class="mt-auto flex justify-between items-center border-t border-gray-200 dark:border-neutral-700 px-3 py-2"> + <span class="text-xs font-semibold text-blue-400"> + â {{ movie.vote_average.toFixed(1) }} + </span> + <WatchlistButton :movie="movie" @click.stop /> + </div> + </div> + </div> + </div> </div> </template> <script setup> -import { ref, onMounted } from 'vue' +import { ref, onMounted, computed, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' import axios from 'axios' -import MovieList from '../components/MovieList.vue' +import WatchlistButton from '@/components/WatchlistButton.vue' const API_KEY = import.meta.env.VITE_TMDB_API_KEY -const BASE = import.meta.env.VITE_TMDB_BASE +const BASE = import.meta.env.VITE_TMDB_BASE -const term = ref('') +const route = useRoute() +const router = useRouter() + +const term = ref(route.query.q || '') const results = ref([]) +const genres = ref([]) +const genre = ref('') +const year = ref('') const loading = ref(false) -const error = ref(null) - -async function search(query) { - loading.value = true - error.value = null - try { - const { data } = await axios.get(`${BASE}/search/movie`, { - params: { api_key: API_KEY, query } - }) - results.value = data.results - } catch (e) { - error.value = e - } finally { - loading.value = false - } +const error = ref(null) + +const years = Array.from({ length: 25 }, (_, i) => 2000 + i) + +function imgUrl(path) { + return `https://image.tmdb.org/t/p/w342${path}` } -async function fetchPopular() { - loading.value = true - error.value = null - try { - const { data } = await axios.get(`${BASE}/movie/popular`, { - params: { api_key: API_KEY } - }) - results.value = data.results - } catch (e) { - error.value = e - } finally { - loading.value = false - } +async function fetchGenres() { + const { data } = await axios.get(`${BASE}/genre/movie/list`, { + params: { api_key: API_KEY }, + }) + genres.value = data.genres } -function onSearch() { - if (term.value.trim()) { - search(term.value) - } else { - fetchPopular() - } +async function fetchTopRated() { + const { data } = await axios.get(`${BASE}/movie/top_rated`, { + params: { api_key: API_KEY }, + }) + results.value = data.results } -onMounted(fetchPopular) -</script> +async function fetchFiltered() { + let url = `${BASE}/discover/movie` + const params = { + api_key: API_KEY, + sort_by: 'vote_average.desc', + 'vote_count.gte': 100, + with_genres: genre.value || undefined, + primary_release_year: year.value || undefined, + } -<style scoped> -.films-container { - padding-top: 5rem; /* adjust to nav height + spacing */ - display: flex; - flex-direction: column; - align-items: center; - width: 100%; + const { data } = await axios.get(url, { params }) + results.value = data.results } -.search-wrapper { - width: 100%; - max-width: 500px; - margin-bottom: 1.5rem; - display: flex; - justify-content: center; - color: white; +async function onSearch() { + if (!term.value) return fetchTopRated() + const { data } = await axios.get(`${BASE}/search/movie`, { + params: { api_key: API_KEY, query: term.value }, + }) + results.value = data.results } -.search-input { - width: 100%; - padding: 0.75rem 1rem; - font-size: 1.1rem; - border: 1px solid #ccc; - border-radius: 4px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +watch(() => route.query.q, async (newQuery) => { + term.value = newQuery || '' + await onSearch() +}) + +function goToMovie(id) { + router.push(`/films/${id}`) } -.loading, -.error { - margin: 1rem 0; - font-weight: bold; +const computedTitle = computed(() => { + const genreName = genres.value.find(g => g.id === Number(genre.value))?.name + if (genreName && year.value) return `Top Rated ${genreName} ${year.value}` + if (genreName) return `Top Rated ${genreName}` + if (year.value) return `Top Rated ${year.value}` + return 'Top Rated' +}) + +onMounted(async () => { + await fetchGenres() + if (term.value) { + await onSearch() + } else { + await fetchTopRated() + } +}) +</script> + +<style scoped> +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; } </style> diff --git a/movie-group-8/src/views/Home.vue b/movie-group-8/src/views/Home.vue index 04089a56d0655779a1cbdd2d067b22e84be646be..1a9c377eb4f21196b953e02fe6d1182681642b63 100644 --- a/movie-group-8/src/views/Home.vue +++ b/movie-group-8/src/views/Home.vue @@ -1,5 +1,5 @@ <template> - <div class="h-full bg-neutral-900 p-6"> + <div class="min-h-screen bg-neutral-900 p-6"> <div class="max-w-[85rem] px-4 py-10 sm:px-6 lg:px-8 lg:py-14 mx-auto dark"> <div class="relative p-6 md:p-16"> <div class="relative z-10 lg:grid lg:grid-cols-12 lg:gap-16 lg:items-center">