diff --git a/movie-group-8/src/assets/default_avatar.png b/movie-group-8/src/assets/default_avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..778e051529c32dc9e845d961a9229d0addaf09ea Binary files /dev/null and b/movie-group-8/src/assets/default_avatar.png differ diff --git a/movie-group-8/src/components/Navbar.vue b/movie-group-8/src/components/Navbar.vue index 7abd7a88907732e7e5cb61faa5c7cbbd8d27570d..64d7e5700bc6baec7c66c76e9357bbc787de6f9e 100644 --- a/movie-group-8/src/components/Navbar.vue +++ b/movie-group-8/src/components/Navbar.vue @@ -74,6 +74,7 @@ import { ref, computed, onMounted } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { getAuth, onAuthStateChanged, signOut } from 'firebase/auth'; import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'; +import defaultAvatar from '@/assets/default_avatar.png' // Firebase Authentication instance const auth = getAuth(); @@ -87,6 +88,7 @@ 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 }, ]); @@ -109,7 +111,7 @@ onMounted(() => { if(user && user.photoURL) { userPhotoURL.value = user.photoURL; } else { - userPhotoURL.value = 'https://static.vecteezy.com/system/resources/previews/009/292/244/non_2x/default-avatar-icon-of-social-media-user-vector.jpg'; + userPhotoURL.value = defaultAvatar; } }); }); diff --git a/movie-group-8/src/components/TopRatedMovies.vue b/movie-group-8/src/components/TopRatedMovies.vue index 5df1ea21187c2f57bfa5dd31e65a7fe185cdc3a0..090e315b6344a73ba91f116b5f0b5aad23b124a1 100644 --- a/movie-group-8/src/components/TopRatedMovies.vue +++ b/movie-group-8/src/components/TopRatedMovies.vue @@ -1,6 +1,5 @@ <script setup> import { ref, onMounted, watch } from 'vue' -import { addToWatchlist } from '@/composables/useWatchlist.js' const year = ref(2000) const genre = ref('') @@ -28,41 +27,45 @@ watch([year, genre], async () => { </script> <template> - <div class="nyt-header"> - <h1>The Movies We've Loved Since 2000</h1> - <p> - Explore top-rated films using the filters below — by genre and year — and rediscover hidden gems. - </p> - </div> - - <div class="filters"> - <select v-model="genre"> - <option value="">Pick a genre ...</option> - <option v-for="g in genres" :key="g.id" :value="g.id">{{ g.name }}</option> - </select> - - <select v-model="year"> - <option v-for="y in Array.from({length: 25}, (_, i) => 2000 + i)" :key="y" :value="y"> - {{ y }} - </option> - </select> - </div> - - <h2 class="section-title">Our favorite movies from {{ year }}</h2> - - <div class="movie-grid"> - <div v-for="movie in movies" :key="movie.id" class="movie-card"> - <img - v-if="movie.poster_path" - :src="'https://image.tmdb.org/t/p/w342' + movie.poster_path" - alt="poster" - /> - <p class="title">{{ movie.title }}</p> - <p class="rating">â {{ movie.vote_average }}</p> - <button @click="addToWatchlist(movie)">Add to Watchlist</button> - </div> - </div> - </template> + <div class="nyt-header"> + <h1>The Movies We've Loved Since 2000</h1> + <p> + Explore top-rated films using the filters below — by genre and year — and rediscover hidden gems. + </p> + </div> + + <div class="filters"> + <select v-model="genre"> + <option value="">Pick a genre ...</option> + <option v-for="g in genres" :key="g.id" :value="g.id">{{ g.name }}</option> + </select> + + <select v-model="year"> + <option v-for="y in Array.from({length: 25}, (_, i) => 2000 + i)" :key="y" :value="y"> + {{ y }} + </option> + </select> + </div> + + <h2 class="section-title">Our favorite movies from {{ year }}</h2> + + <div class="movie-grid"> + <router-link + v-for="movie in movies" + :key="movie.id" + :to="`/films/${movie.id}`" + class="movie-card block hover:opacity-90 transition" + > + <img + v-if="movie.poster_path" + :src="'https://image.tmdb.org/t/p/w342' + movie.poster_path" + alt="poster" + /> + <p class="title">{{ movie.title }}</p> + <p class="rating">â {{ movie.vote_average }}</p> + </router-link> + </div> +</template> <style scoped> .nyt-header { diff --git a/movie-group-8/src/components/Watchlist.vue b/movie-group-8/src/components/Watchlist.vue index 968bb92b4ce92aa74b7431459b883eb435ee6006..724082258be8667655500d595f6361458acab0c9 100644 --- a/movie-group-8/src/components/Watchlist.vue +++ b/movie-group-8/src/components/Watchlist.vue @@ -20,6 +20,12 @@ <p class="title">{{ movie.title }}</p> <p class="rating">â {{ movie.vote_average }}</p> <p class="status">📌 {{ movie.status }}</p> + <button + @click="removeFromWatchlist(movie)" + class="mt-2 px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition" + > + Remove + </button> </div> </div> </div> @@ -27,7 +33,7 @@ <script setup> import { ref, computed, onMounted } from 'vue' - import { getFirestore, collection, getDocs } from 'firebase/firestore' + import { getFirestore, collection, getDocs, doc, deleteDoc } from 'firebase/firestore' import { getAuth } from 'firebase/auth' const db = getFirestore() @@ -43,6 +49,16 @@ const snapshot = await getDocs(collection(db, 'users', user.uid, 'watchlist')) watchlist.value = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) } + + const removeFromWatchlist = async (movie) => { + const user = auth.currentUser + const movieRef = doc(db, 'users', user.uid, 'watchlist', String(movie.id)) + await deleteDoc(movieRef) + + // remove locally + watchlist.value = watchlist.value.filter(m => m.id !== movie.id) + alert(`Removed "${movie.title}" from your watchlist.`) + } const filteredWatchlist = computed(() => { if (!filter.value) return watchlist.value diff --git a/movie-group-8/src/components/WatchlistButton.vue b/movie-group-8/src/components/WatchlistButton.vue new file mode 100644 index 0000000000000000000000000000000000000000..91458232887237079c738f99224113fc42242682 --- /dev/null +++ b/movie-group-8/src/components/WatchlistButton.vue @@ -0,0 +1,37 @@ +<template> + <button + type="button" + @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'" + > + {{ isInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist' }} + </button> +</template> + +<script setup> +import { ref, watch } from 'vue' +import { useWatchlist } from '@/composables/useWatchlist.js' + +const props = defineProps({ + movie: { + type: Object, + required: true + } +}) + +// We need a ref for the ID and a ref for the data +const movieId = ref(props.movie.id) +const movieData = ref(props.movie) + +// Pull in the composable +const { isInWatchlist, toggleWatchlist } = useWatchlist(movieId, movieData) + +// If parent updates the prop, keep our ref in sync +watch(() => props.movie, m => { + movieId.value = m.id + movieData.value = m +}) +</script> \ No newline at end of file diff --git a/movie-group-8/src/composables/useWatchlist.js b/movie-group-8/src/composables/useWatchlist.js index c63ca445acc2ddda4820469fdaeb8cee8ff8b5d3..7dc09f0315ba6d966362d22a40f381bbf7b30da6 100644 --- a/movie-group-8/src/composables/useWatchlist.js +++ b/movie-group-8/src/composables/useWatchlist.js @@ -1,17 +1,47 @@ -import { doc, setDoc } from 'firebase/firestore' -import { auth, db } from '@/firebase.js' +import { ref, watchEffect } from 'vue' +import { getAuth } from 'firebase/auth' +import { getFirestore, doc, getDoc, setDoc, deleteDoc } from 'firebase/firestore' -export const addToWatchlist = async (movie) => { - const user = auth.currentUser - if (!user) return alert('You need to log in.') +export function useWatchlist(movieId, movieData) { + const auth = getAuth() + const db = getFirestore() + const isInWatchlist = ref(false) - const movieRef = doc(db, 'users', user.uid, 'watchlist', String(movie.id)) - await setDoc(movieRef, { - title: movie.title, - poster_path: movie.poster_path, - vote_average: movie.vote_average, - status: 'planned' + // Check on load & whenever movieId or user changes + watchEffect(async (onInvalidate) => { + const user = auth.currentUser + if (!user.value ?? user) { + isInWatchlist.value = false + return + } + const ref = doc(db, 'users', user.uid, 'watchlist', String(movieId.value ?? movieId)) + const snap = await getDoc(ref) + isInWatchlist.value = snap.exists() }) - alert('Movie added to watchlist!') + // Toggle function + const toggleWatchlist = async () => { + const user = auth.currentUser + if (!user) { + return alert('You need to log in.') + } + const ref = doc(db, 'users', user.uid, 'watchlist', String(movieId.value ?? movieId)) + + if (isInWatchlist.value) { + await deleteDoc(ref) + isInWatchlist.value = false + alert('Removed from watchlist') + } else { + await setDoc(ref, { + title: movieData.value.title, + poster_path: movieData.value.poster_path, + vote_average: movieData.value.vote_average, + status: 'planned' + }) + isInWatchlist.value = true + alert('Added to watchlist') + } + } + + return { isInWatchlist, toggleWatchlist } } \ No newline at end of file diff --git a/movie-group-8/src/router/index.js b/movie-group-8/src/router/index.js index f4763b4c1ce260ab69a24d77e4ab2956d5b63c49..fcbec63700fcb695109d7a419820f479345ec6d5 100644 --- a/movie-group-8/src/router/index.js +++ b/movie-group-8/src/router/index.js @@ -10,7 +10,7 @@ const router = createRouter({ { path: '/', component: () => import('../views/Home.vue') }, - {path: '/top-rated', component: () => import('../views/TopRated.vue'), meta: { requiresAuth: true }}, + { path: '/top-rated', component: () => import('../views/TopRated.vue'), meta: { requiresAuth: true }}, { path: '/register', component: () => import('../views/Register.vue') }, { path: '/login', component: () => import('../views/Login.vue') }, { path: '/recover-account', component: () => import('../views/RecoverAccount.vue') }, @@ -48,7 +48,17 @@ const router = createRouter({ component: () => import('../views/Settings.vue'), meta: { requiresAuth: true }, }, - + { + path: '/social', + name: 'Social', + component: () => import('../views/Social.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/user/:id', + name: 'UserProfile', + component: () => import('../views/UserProfile.vue') + }, ], }); diff --git a/movie-group-8/src/views/FilmDetails.vue b/movie-group-8/src/views/FilmDetails.vue index d2b8e333a8be1b3bb1f4ea4dc9aad358a2f6be11..4880f46bcb33e425fd8571e0bca0d24e79c32a8b 100644 --- a/movie-group-8/src/views/FilmDetails.vue +++ b/movie-group-8/src/views/FilmDetails.vue @@ -30,12 +30,7 @@ ></iframe> </div> - <button - @click="addToWatchlist(movie)" - class="mt-4 px-4 py-2 bg-green-600 rounded text-white hover:bg-green-700" - > - Add to Watchlist - </button> + <WatchlistButton :movie="movie" class="mt-4" /> <RouterLink :to="{ name: 'ReviewFilm', params: { id: movie.id } }" @@ -64,17 +59,18 @@ <script setup> import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' -import { addToWatchlist } from '@/composables/useWatchlist.js' -import { getAllReviews } from '@/composables/useReviews.js' - +import { useWatchlist } from '@/composables/useWatchlist.js' +import WatchlistButton from '@/components/WatchlistButton.vue' +import { getAuth } from 'firebase/auth' +import { getFirestore, doc, getDoc } from 'firebase/firestore' const route = useRoute() const movie = ref(null) const trailerUrl = ref('') const loading = ref(true) const error = ref('') -const reviews = ref([]) -const reviewsLoading = ref(true) +const movieId = ref(route.params.id) +const { isInWatchlist } = useWatchlist(movieId, movie) function formatDate(dateString) { if (!dateString) return 'Unknown' @@ -117,16 +113,16 @@ onMounted(async () => { const id = route.params.id await fetchMovieDetails(id) await fetchMovieVideos(id) - try { - const res = await getAllReviews(id) - console.log('Raw reviews from Firestore:', res) - reviews.value = res - } catch (revErr) { - console.error('Failed to load reviews', revErr) - } finally { - reviewsLoading.value = false - loading.value = false + + const user = getAuth().currentUser + if (user) { + const db = getFirestore() + const watchRef = doc(db, 'users', user.uid, 'watchlist', String(id)) + const snap = await getDoc(watchRef) + isInWatchlist.value = snap.exists() } + + loading.value = false }) </script> diff --git a/movie-group-8/src/views/Login.vue b/movie-group-8/src/views/Login.vue index eecffe4823239b2c0feac6450cd4b1938ed3d129..0b539b1f8a6880c589d20a59837fc7afc0c1f5fc 100644 --- a/movie-group-8/src/views/Login.vue +++ b/movie-group-8/src/views/Login.vue @@ -61,47 +61,83 @@ </div> </div> </div> - </template> +</template> - <script setup> - import { ref } from 'vue'; - import { getAuth, signInWithEmailAndPassword, signInWithPopup, GoogleAuthProvider } from 'firebase/auth'; - import { useRouter } from 'vue-router'; - - const email = ref(''); - const password = ref(''); - const router = useRouter(); - const loading = ref(false); - - const login = async () => { - try { - loading.value = true; - const auth = getAuth(); - await signInWithEmailAndPassword(auth, email.value, password.value); - console.log('Successfully logged in!'); - router.push('/films'); - } catch (error) { - console.error(error); - alert(error.message); - } finally { - loading.value = false; - } - }; - - const signInWithGoogle = async () => { - try { - loading.value = true; - const auth = getAuth(); - const provider = new GoogleAuthProvider(); - await signInWithPopup(auth, provider); - console.log('Successfully signed in with Google!'); - router.push('/films'); - } catch (error) { - console.error(error); - alert(error.message); - } finally { - loading.value = false; - } - }; - </script> - \ No newline at end of file +<script setup> +import { ref } from 'vue'; +import { + getAuth, + signInWithEmailAndPassword, + signInWithPopup, + GoogleAuthProvider +} from 'firebase/auth'; +import { + getFirestore, + doc, + getDoc, + setDoc +} from 'firebase/firestore'; +import { useRouter } from 'vue-router'; +import defaultAvatar from '@/assets/default_avatar.png' + +const email = ref(''); +const password = ref(''); +const router = useRouter(); +const loading = ref(false); + +const db = getFirestore(); + +const createUserIfNotExists = async (user) => { + const userRef = doc(db, 'users', user.uid) + const userSnap = await getDoc(userRef) + + if (!userSnap.exists()) { + const photoURLToSave = user.photoURL || defaultAvatar + + await setDoc(userRef, { + uid: user.uid, + displayName: user.displayName || 'Anonymous', + photoURL: photoURLToSave + }) + } +} + +const login = async () => { + try { + loading.value = true; + const auth = getAuth(); + const userCredential = await signInWithEmailAndPassword(auth, email.value, password.value); + const user = userCredential.user; + + await createUserIfNotExists(user); + + console.log('Successfully logged in!'); + router.push('/films'); + } catch (error) { + console.error(error); + alert(error.message); + } finally { + loading.value = false; + } +}; + +const signInWithGoogle = async () => { + try { + loading.value = true; + const auth = getAuth(); + const provider = new GoogleAuthProvider(); + const result = await signInWithPopup(auth, provider); + const user = result.user; + + await createUserIfNotExists(user); + + console.log('Successfully signed in with Google!'); + router.push('/films'); + } catch (error) { + console.error(error); + alert(error.message); + } finally { + loading.value = false; + } +}; +</script> \ No newline at end of file diff --git a/movie-group-8/src/views/Profile.vue b/movie-group-8/src/views/Profile.vue index 733b4eefd0e59443c462995e23715c1aa7fa71d1..065de78bfe03ba517b7fdb499bcce3d8981ec898 100644 --- a/movie-group-8/src/views/Profile.vue +++ b/movie-group-8/src/views/Profile.vue @@ -9,7 +9,7 @@ /> <h1 class="text-3xl font-bold text-gray-800 dark:text-white">{{ displayName || 'Anonymous' }}</h1> <p class="text-sm text-gray-600 dark:text-neutral-400">{{ userEmail }}</p> - <p class="mt-2 text-base text-gray-600 dark:text-neutral-400">Movie fan and scene collector.</p> + <p class="mt-2 text-base text-gray-600 dark:text-neutral-400">{{ description }}</p> <button @click="showForm = !showForm" @@ -19,6 +19,7 @@ </button> </div> + <!-- Update Form --> <div v-if="showForm" class="max-w-xl mx-auto mb-12 bg-white dark:bg-neutral-800 p-6 rounded-xl border dark:border-neutral-700"> <form @submit.prevent="handleUpdateProfile" class="grid gap-6"> <div> @@ -26,6 +27,11 @@ <input type="text" v-model="displayName" class="w-full border border-gray-400 rounded-lg p-2 dark:text-white" required /> </div> + <div> + <label class="block text-sm mb-2 dark:text-white">Description</label> + <textarea v-model="description" rows="3" class="w-full border border-gray-400 rounded-lg p-2 dark:text-white" /> + </div> + <div> <label class="block text-sm mb-2 dark:text-white">New Password</label> <input type="password" v-model="newPassword" class="w-full border border-gray-400 rounded-lg p-2 dark:text-white" /> @@ -43,74 +49,114 @@ </form> </div> - <div> - <h2 class="text-2xl font-semibold text-gray-800 dark:text-white mb-6">Your Watchlist</h2> + <!-- Tabs --> + <div class="flex justify-center gap-4 mb-6"> + <button + v-for="tab in ['watchlist', 'followers', 'following']" + :key="tab" + @click="activeTab = tab" + :class="[ + 'px-4 py-2 rounded-lg text-sm font-medium', + activeTab === tab + ? 'bg-blue-600 text-white' + : 'bg-white dark:bg-neutral-700 text-gray-800 dark:text-white border border-gray-300 dark:border-neutral-600' + ]" + > + {{ tab.charAt(0).toUpperCase() + tab.slice(1) }} + </button> + </div> - <div v-if="watchlist.length === 0" class="text-gray-500 dark:text-gray-400 text-sm"> - Your watchlist is empty. + <!-- Tab Content --> + <div v-if="activeTab === 'watchlist'" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> + <router-link + v-for="movie in watchlist" + :key="movie.id" + :to="`/films/${movie.id}`" + class="block bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg p-3 hover:opacity-90 transition" + > + <img + v-if="movie.poster_path" + :src="'https://image.tmdb.org/t/p/w342' + movie.poster_path" + alt="Poster" + class="w-full h-60 object-cover rounded mb-3" + /> + <p class="font-semibold text-gray-800 dark:text-white">{{ movie.title }}</p> + <p class="text-sm text-gray-600 dark:text-neutral-400">â {{ movie.vote_average }} — {{ movie.status }}</p> + </router-link> + </div> + + <!-- Followers --> + <div v-else-if="activeTab === 'followers'" class="grid gap-4 max-w-xl mx-auto"> + <div + v-for="follower in followers" + :key="follower.uid" + class="flex items-center justify-between p-4 bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg" + > + <router-link :to="`/user/${follower.uid}`" class="flex items-center gap-4"> + <img + :src="follower.photoURL || 'https://via.placeholder.com/150'" + alt="Avatar" + class="w-12 h-12 rounded-full object-cover" + /> + <span class="text-lg font-semibold text-gray-800 dark:text-white">{{ follower.displayName }}</span> + </router-link> </div> + </div> - <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> - <div - v-for="movie in watchlist" - :key="movie.id" - class="bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg p-3 text-left" - > + <!-- Following --> + <div v-else-if="activeTab === 'following'" class="grid gap-4 max-w-xl mx-auto"> + <div + v-for="followed in following" + :key="followed.uid" + class="flex items-center justify-between p-4 bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg" + > + <router-link :to="`/user/${followed.uid}`" class="flex items-center gap-4"> <img - v-if="movie.poster_path" - :src="'https://image.tmdb.org/t/p/w342' + movie.poster_path" - alt="Poster" - class="w-full h-60 object-cover rounded mb-3" + :src="followed.photoURL || 'https://via.placeholder.com/150'" + alt="Avatar" + class="w-12 h-12 rounded-full object-cover" /> - <p class="font-semibold text-gray-800 dark:text-white">{{ movie.title }}</p> - <p class="text-sm text-gray-600 dark:text-neutral-400">â {{ movie.vote_average }} — {{ movie.status }}</p> - </div> + <span class="text-lg font-semibold text-gray-800 dark:text-white">{{ followed.displayName }}</span> + </router-link> </div> </div> </div> </template> <script setup> -import { ref, computed, onMounted } from 'vue' +import { + collection, + getDocs, + doc, + getDoc, + updateDoc, + getFirestore, +} from 'firebase/firestore' + import { getAuth, updateProfile as firebaseUpdateProfile, - updatePassword, + updatePassword } from 'firebase/auth' -import { - getFirestore, - collection, - getDocs -} from 'firebase/firestore' +import { ref, computed, onMounted } from 'vue' const auth = getAuth() const db = getFirestore() -const user = auth.currentUser - -const loading = ref(false) +const user = ref(null) const userEmail = ref('') const displayName = ref('') -const newPassword = ref('') +const description = ref('') const userPhotoURL = ref('') +const newPassword = ref('') const showForm = ref(false) +const activeTab = ref('watchlist') +const loading = ref(false) const watchlist = ref([]) +const followers = ref([]) +const following = ref([]) -// Load profile info -onMounted(async () => { - if (user) { - userEmail.value = user.email - displayName.value = user.displayName || '' - userPhotoURL.value = user.photoURL || '' - - // Fetch watchlist - const snapshot = await getDocs(collection(db, 'users', user.uid, 'watchlist')) - watchlist.value = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) - } -}) - -// Password validation const passwordError = computed(() => { if (!newPassword.value) return '' if (newPassword.value.length < 8) return 'Password must be at least 8 characters.' @@ -120,30 +166,104 @@ const passwordError = computed(() => { return '' }) -// Update profile function +const fetchFollowing = async () => { + const followingSnap = await getDocs(collection(db, 'users', user.value.uid, 'following')) + const followedIds = followingSnap.docs.map(doc => doc.id) + + const usersList = [] + + for (const id of followedIds) { + const docSnap = await getDoc(doc(db, 'users', id)) + if (docSnap.exists()) { + const data = docSnap.data() + // Only push if the user has a displayName or isn’t hidden + if (!data.hidden && data.displayName) { + usersList.push({ uid: id, ...data }) + } + } + } + + following.value = usersList +} + +const fetchFollowers = async () => { + if (!user.value) return + + const allUsersSnap = await getDocs(collection(db, 'users')) + const followersList = [] + + for (const docSnap of allUsersSnap.docs) { + const otherUid = docSnap.id + if (otherUid === user.value.uid) continue + + const followDocRef = doc(db, 'users', otherUid, 'following', user.value.uid) + const followDocSnap = await getDoc(followDocRef) + + if (followDocSnap.exists()) { + const otherUserData = docSnap.data() + if (otherUserData.displayName && !otherUserData.hidden) { + followersList.push({ uid: otherUid, ...otherUserData }) + } + } + } + + followers.value = followersList +} + const handleUpdateProfile = async () => { if (passwordError.value) return loading.value = true try { - if (user) { - await firebaseUpdateProfile(user, { - displayName: displayName.value - }) + const authUser = auth.currentUser + if (!authUser) throw new Error('No authenticated user') - if (newPassword.value) { - await updatePassword(user, newPassword.value) - } + // Update the Firebase Auth profile + await firebaseUpdateProfile(authUser, { + displayName: displayName.value + }) - alert('Profile updated successfully!') - showForm.value = false + if (newPassword.value) { + await updatePassword(authUser, newPassword.value) } + + // Update your Firestore user document + const userDocRef = doc(db, 'users', authUser.uid) + await updateDoc(userDocRef, { + displayName: displayName.value, + description: description.value + }) + + alert('Profile updated successfully!') + showForm.value = false + } catch (error) { alert(error.message) } finally { loading.value = false } } + +onMounted(async () => { + const authUser = auth.currentUser + if (!authUser) return + + user.value = authUser + userEmail.value = authUser.email + displayName.value = authUser.displayName || '' + userPhotoURL.value = authUser.photoURL || '' + + const userDoc = await getDoc(doc(db, 'users', user.value.uid)) + if (userDoc.exists()) { + description.value = userDoc.data().description || '' + } + + const snapshot = await getDocs(collection(db, 'users', user.value.uid, 'watchlist')) + watchlist.value = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) + + await fetchFollowing() + await fetchFollowers() +}) </script> <style scoped> diff --git a/movie-group-8/src/views/Register.vue b/movie-group-8/src/views/Register.vue index 2880887871e12186c4e92abcc2b2d1f83fff625f..1f4d10208d7c4e78d2394a6ef467358234f9f5f4 100644 --- a/movie-group-8/src/views/Register.vue +++ b/movie-group-8/src/views/Register.vue @@ -62,8 +62,18 @@ <script setup> import { ref, computed } from 'vue'; -import { getAuth, createUserWithEmailAndPassword, signInWithPopup, GoogleAuthProvider } from 'firebase/auth'; import { useRouter } from 'vue-router'; +import defaultAvatar from '@/assets/default_avatar.png' + +import { + getAuth, + createUserWithEmailAndPassword, + } from 'firebase/auth'; +import { + getFirestore, + doc, + setDoc +} from 'firebase/firestore'; const email = ref(''); const password = ref(''); @@ -93,9 +103,26 @@ const register = async () => { if (passwordError.value || confirmPasswordError.value) return; loading.value = true; try { - const auth = getAuth(); - await createUserWithEmailAndPassword(auth, email.value, password.value); - router.push('/films'); + const auth = getAuth() + const db = getFirestore() + const userCredential = await createUserWithEmailAndPassword(auth, email.value, password.value) + const user = userCredential.user + + // Set default profile info + await updateProfile(user, { + displayName: 'New User', + photoURL: defaultAvatar + }) + + // Create Firestore document for this user + await setDoc(doc(db, 'users', user.uid), { + displayName: 'New User', + photoURL: defaultAvatar, + description: '', + hidden: false + }) + + router.push('/films') } catch (error) { alert(error.message); } finally { diff --git a/movie-group-8/src/views/Social.vue b/movie-group-8/src/views/Social.vue new file mode 100644 index 0000000000000000000000000000000000000000..ed3b9b1e253cf69fa6e2c191f9b1ebea19fcb48f --- /dev/null +++ b/movie-group-8/src/views/Social.vue @@ -0,0 +1,99 @@ +<template> + <div class="min-h-screen bg-gray-100 dark:bg-neutral-900 p-6"> + <h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-6 text-center">Find and Follow Users</h1> + + <div class="max-w-xl mx-auto mb-6"> + <input + type="text" + v-model="searchTerm" + placeholder="Search by display name..." + class="w-full p-3 rounded-lg border border-gray-300 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white" + /> + </div> + + <div class="grid gap-4 max-w-3xl mx-auto"> + <div + v-for="user in filteredUsers" + :key="user.uid" + class="flex items-center justify-between p-4 bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg" + > + <router-link :to="`/user/${user.uid}`" class="flex items-center gap-4"> + <img + :src="user.photoURL" + class="w-12 h-12 rounded-full object-cover" + alt="Avatar" + /> + <span class="text-lg font-semibold text-gray-800 dark:text-white">{{ user.displayName }}</span> + </router-link> + <button + @click="toggleFollow(user.uid)" + class="px-4 py-1.5 text-sm rounded-lg font-medium" + :class="isFollowing(user.uid) + ? 'bg-red-500 text-white hover:bg-red-600' + : 'bg-blue-600 text-white hover:bg-blue-700'" + > + {{ isFollowing(user.uid) ? 'Unfollow' : 'Follow' }} + </button> + </div> + </div> + </div> +</template> + +<script setup> +import { ref, computed, onMounted } from 'vue' +import { getAuth } from 'firebase/auth' +import { + getFirestore, + collection, + getDocs, + setDoc, + deleteDoc, + doc +} from 'firebase/firestore' + +const auth = getAuth() +const db = getFirestore() +const currentUser = auth.currentUser + +const searchTerm = ref('') +const users = ref([]) +const following = ref([]) + +onMounted(async () => { + await fetchUsers() + await fetchFollowing() +}) + +const fetchUsers = async () => { + const snapshot = await getDocs(collection(db, 'users')) + users.value = snapshot.docs + .map(doc => doc.data()) + .filter(u => u.uid !== currentUser?.uid && !u.hidden) // exclude self and test user +} + +const fetchFollowing = async () => { + const snapshot = await getDocs(collection(db, 'users', currentUser.uid, 'following')) + following.value = snapshot.docs.map(doc => doc.id) +} + +const isFollowing = (uid) => following.value.includes(uid) + +const toggleFollow = async (uid) => { + const followRef = doc(db, 'users', currentUser.uid, 'following', uid) + + if (isFollowing(uid)) { + await deleteDoc(followRef) + following.value = following.value.filter(id => id !== uid) + } else { + await setDoc(followRef, { followedAt: Date.now() }) + following.value.push(uid) + } +} + +const filteredUsers = computed(() => { + if (!searchTerm.value.trim()) return users.value + return users.value.filter(u => + u.displayName.toLowerCase().includes(searchTerm.value.toLowerCase()) + ) +}) +</script> \ No newline at end of file diff --git a/movie-group-8/src/views/UserProfile.vue b/movie-group-8/src/views/UserProfile.vue new file mode 100644 index 0000000000000000000000000000000000000000..74c0cfc15940553b93dad02694bb0a5e0f82de7d --- /dev/null +++ b/movie-group-8/src/views/UserProfile.vue @@ -0,0 +1,117 @@ +<template> + <div class="min-h-screen bg-gray-100 dark:bg-neutral-900 p-6"> + <div class="text-center mb-10"> + <img + v-if="user.photoURL" + :src="user.photoURL" + alt="Profile" + class="w-24 h-24 mx-auto rounded-full object-cover" + /> + <h1 class="text-2xl font-bold mt-4 text-gray-800 dark:text-white"> + {{ user.displayName || 'Unknown User' }} + </h1> + + <button + v-if="!isOwnProfile" + @click="toggleFollow" + class="mt-3 px-4 py-2 rounded-lg text-white text-sm" + :class="isFollowing ? 'bg-red-500 hover:bg-red-600' : 'bg-blue-600 hover:bg-blue-700'" + > + {{ isFollowing ? 'Unfollow' : 'Follow' }} + </button> + </div> + + <h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-4 text-center"> + Watchlist + </h2> + + <div v-if="watchlist.length === 0" class="text-center text-gray-500 dark:text-gray-400"> + No public watchlist found. + </div> + + <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> + <router-link + v-for="movie in watchlist" + :key="movie.id" + :to="`/films/${movie.id}`" + class="block bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg p-4 hover:opacity-90 transition" + > + <img + v-if="movie.poster_path" + :src="'https://image.tmdb.org/t/p/w342' + movie.poster_path" + alt="Poster" + class="w-full h-60 object-cover rounded mb-2" + /> + <p class="font-semibold text-gray-800 dark:text-white">{{ movie.title }}</p> + <p class="text-sm text-gray-600 dark:text-neutral-400">â {{ movie.vote_average }}</p> + <p class="text-xs text-gray-400 dark:text-neutral-500">{{ movie.status }}</p> + </router-link> + </div> + </div> +</template> + +<script setup> +import { ref, onMounted } from 'vue' +import { useRoute } from 'vue-router' +import { getAuth } from 'firebase/auth' +import { + getFirestore, + doc, + getDoc, + collection, + getDocs, + setDoc, + deleteDoc +} from 'firebase/firestore' + +const route = useRoute() +const db = getFirestore() +const auth = getAuth() + +const profileUserId = route.params.id +const currentUser = auth.currentUser + +const user = ref({}) +const watchlist = ref([]) +const isFollowing = ref(false) +const isOwnProfile = currentUser?.uid === profileUserId + +onMounted(async () => { + await fetchUserProfile() + await fetchWatchlist() + if (!isOwnProfile) await checkIfFollowing() +}) + +const fetchUserProfile = async () => { + const docRef = doc(db, 'users', profileUserId) + const docSnap = await getDoc(docRef) + if (docSnap.exists()) { + user.value = docSnap.data() + } else { + user.value = { displayName: 'User Not Found' } + } +} + +const fetchWatchlist = async () => { + const snapshot = await getDocs(collection(db, 'users', profileUserId, 'watchlist')) + watchlist.value = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) +} + +const checkIfFollowing = async () => { + const followRef = doc(db, 'users', currentUser.uid, 'following', profileUserId) + const followSnap = await getDoc(followRef) + isFollowing.value = followSnap.exists() +} + +const toggleFollow = async () => { + const followRef = doc(db, 'users', currentUser.uid, 'following', profileUserId) + + if (isFollowing.value) { + await deleteDoc(followRef) + isFollowing.value = false + } else { + await setDoc(followRef, { followedAt: Date.now() }) + isFollowing.value = true + } +} +</script> \ No newline at end of file diff --git a/movie-group-8/src/views/WatchlistView.vue b/movie-group-8/src/views/WatchlistView.vue index 4ce8909b73b403ffdd4d855defd6b30761e23de9..071b02ebe222a37ce5d458cfbc3781c56c890380 100644 --- a/movie-group-8/src/views/WatchlistView.vue +++ b/movie-group-8/src/views/WatchlistView.vue @@ -10,12 +10,18 @@ No movies planned yet. </div> <div class="grid gap-4"> - <MovieCard - v-for="movie in plannedMovies" - :key="movie.id" - :movie="movie" - @status-changed="updateMovieStatus" - /> + <div v-for="movie in plannedMovies" :key="movie.id" class="relative bg-white dark:bg-neutral-800 p-4 rounded"> + <MovieCard + :movie="movie" + @status-changed="updateMovieStatus" + /> + <button + @click="removeFromWatchlist(movie.id)" + class="absolute top-2 right-2 px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition" + > + Remove + </button> + </div> </div> </div> @@ -26,12 +32,18 @@ No watched movies yet. </div> <div class="grid gap-4"> - <MovieCard - v-for="movie in watchedMovies" - :key="movie.id" - :movie="movie" - @status-changed="updateMovieStatus" - /> + <div v-for="movie in watchedMovies" :key="movie.id" class="relative bg-white dark:bg-neutral-800 p-4 rounded"> + <MovieCard + :movie="movie" + @status-changed="updateMovieStatus" + /> + <button + @click="removeFromWatchlist(movie.id)" + class="absolute top-2 right-2 px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition" + > + Remove + </button> + </div> </div> </div> </div> @@ -41,7 +53,7 @@ <script setup> import { ref, onMounted } from 'vue' import { getAuth } from 'firebase/auth' -import { getFirestore, collection, getDocs, doc, updateDoc } from 'firebase/firestore' +import { getFirestore, collection, getDocs, doc, updateDoc, deleteDoc } from 'firebase/firestore' import MovieCard from '@/components/MovieCard.vue' const auth = getAuth() @@ -60,6 +72,15 @@ const fetchWatchlist = async () => { watchedMovies.value = all.filter(m => m.status === 'watched') } +const removeFromWatchlist = async (movieId) => { + const user = auth.currentUser + if (!user) return + + const movieRef = doc(db, 'users', user.uid, 'watchlist', movieId) + await deleteDoc(movieRef) + await fetchWatchlist() +} + const updateMovieStatus = async (movieId, newStatus) => { const user = auth.currentUser if (!user) return