diff --git a/movie-group-8/package-lock.json b/movie-group-8/package-lock.json index 9c88ee0b1d6c8e4d01f7eb84321d22531a6eca11..5502da0ec210ec42ef3ac199421a2d8977f55e32 100644 --- a/movie-group-8/package-lock.json +++ b/movie-group-8/package-lock.json @@ -2691,9 +2691,9 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "node_modules/vite": { - "version": "6.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz", - "integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/movie-group-8/src/components/MovieCard.vue b/movie-group-8/src/components/MovieCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..a68aef804c0099383b7f77c9b9089953f66c176e --- /dev/null +++ b/movie-group-8/src/components/MovieCard.vue @@ -0,0 +1,42 @@ +<template> + <div class="bg-white dark:bg-neutral-800 border dark:border-neutral-700 rounded-lg p-4 flex gap-4 items-start"> + <img + v-if="movie.poster_path" + :src="'https://image.tmdb.org/t/p/w154' + movie.poster_path" + alt="Poster" + class="w-24 h-36 object-cover rounded" + /> + <div class="flex-1"> + <p class="text-lg 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> + + <label class="text-xs text-gray-500 dark:text-gray-400 block mt-2"> + Status: + <select + v-model="selectedStatus" + @change="emitChange" + class="ml-2 bg-white dark:bg-neutral-700 border border-gray-300 dark:border-neutral-600 rounded px-2 py-1 text-sm" + > + <option value="planned">Planned</option> + <option value="watched">Watched</option> + </select> + </label> + </div> + </div> +</template> + +<script setup> +import { ref, watch } from 'vue' + +const props = defineProps({ + movie: Object, +}) + +const emit = defineEmits(['status-changed']) + +const selectedStatus = ref(props.movie.status) + +const emitChange = () => { + emit('status-changed', props.movie.id, selectedStatus.value) +} +</script> \ No newline at end of file diff --git a/movie-group-8/src/components/TopRatedMovies.vue b/movie-group-8/src/components/TopRatedMovies.vue index 89845c5502db60f794bd399f34cd1fa56da71a8b..5df1ea21187c2f57bfa5dd31e65a7fe185cdc3a0 100644 --- a/movie-group-8/src/components/TopRatedMovies.vue +++ b/movie-group-8/src/components/TopRatedMovies.vue @@ -1,6 +1,6 @@ <script setup> import { ref, onMounted, watch } from 'vue' -import addToWatchlist from '@/views/Watchlist.vue' +import { addToWatchlist } from '@/composables/useWatchlist.js' const year = ref(2000) const genre = ref('') diff --git a/movie-group-8/src/components/Watchlist.vue b/movie-group-8/src/components/Watchlist.vue index f4f4aadaed9e7001aa2a16e99f1cc00984980767..968bb92b4ce92aa74b7431459b883eb435ee6006 100644 --- a/movie-group-8/src/components/Watchlist.vue +++ b/movie-group-8/src/components/Watchlist.vue @@ -55,7 +55,6 @@ </script> <style scoped> - /* Reuse your styles or tweak as needed */ .watchlist { padding: 2rem; text-align: center; diff --git a/movie-group-8/src/composables/useWatchlist.js b/movie-group-8/src/composables/useWatchlist.js new file mode 100644 index 0000000000000000000000000000000000000000..7de03593946235c36f67c8966ec81774b616c01e --- /dev/null +++ b/movie-group-8/src/composables/useWatchlist.js @@ -0,0 +1,18 @@ +import { getFirestore, doc, setDoc } from 'firebase/firestore' +import { getAuth } from 'firebase/auth' + +export const addToWatchlist = async (movie) => { + const user = getAuth().currentUser + if (!user) return alert('You need to log in.') + + const db = getFirestore() + 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' + }) + + alert('Movie added to watchlist!') +} \ 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 a989d41f6c061f29bff582b1251572f898fc31fe..8cad4e40bda52c8a31bdd640c2ea7d4ba4dee9f1 100644 --- a/movie-group-8/src/router/index.js +++ b/movie-group-8/src/router/index.js @@ -27,7 +27,7 @@ const router = createRouter({ { path: '/watchlist', - component: () => import('../views/Watchlist.vue'), + component: () => import('../views/WatchlistView.vue'), meta: { requiresAuth: true }, }, { diff --git a/movie-group-8/src/views/FilmDetails.vue b/movie-group-8/src/views/FilmDetails.vue index e7718825d9737a7cfa11dad90ed75c6d43488f39..0e8eda82adaf3e2d8d7a715f4278a2db8587d4a4 100644 --- a/movie-group-8/src/views/FilmDetails.vue +++ b/movie-group-8/src/views/FilmDetails.vue @@ -29,6 +29,7 @@ allowfullscreen ></iframe> </div> + <button @click="addToWatchlist(movie)">Add to Watchlist</button> </div> </div> </div> @@ -37,6 +38,7 @@ <script setup> import { ref, onMounted } from 'vue' import { useRoute } from 'vue-router' + import { addToWatchlist } from '@/composables/useWatchlist.js' const route = useRoute() const movie = ref(null) diff --git a/movie-group-8/src/views/Profile.vue b/movie-group-8/src/views/Profile.vue index 7e01bc735dbae92af82aaa50e9e4bf31980eaab9..733b4eefd0e59443c462995e23715c1aa7fa71d1 100644 --- a/movie-group-8/src/views/Profile.vue +++ b/movie-group-8/src/views/Profile.vue @@ -1,103 +1,153 @@ <template> - <div class="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-neutral-900"> - <div class="mt-7 bg-white border border-gray-200 rounded-xl shadow-2xs dark:bg-neutral-900 dark:border-neutral-700 min-w-96"> - <div class="p-4 sm:p-7"> - <div class="text-center"> - <h1 class="block text-2xl font-bold text-gray-800 dark:text-white">Profile</h1> - <p class="mt-2 text-sm text-gray-600 dark:text-neutral-400"> - Manage your account information - </p> - </div> - - <div class="mt-5"> - <form @submit.prevent="updateProfile"> - <div class="grid gap-y-4"> - <div> - <label class="block text-sm mb-2 dark:text-white">Email</label> - <input type="email" :value="userEmail" disabled class="w-full border border-gray-400 rounded-lg p-2 bg-gray-100 dark:bg-neutral-800 dark:text-white" /> - </div> - - <div> - <label class="block text-sm mb-2 dark:text-white">Display Name</label> - <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">New Password</label> - <input type="password" v-model="newPassword" class="w-full border border-gray-400 rounded-lg p-2 dark:text-white" /> - <p v-if="newPassword && passwordError" class="text-red-500 text-sm mt-1">{{ passwordError }}</p> - </div> - - <button - type="submit" - :disabled="loading || !!passwordError" - class="w-full py-3 bg-blue-600 text-white rounded-lg flex justify-center items-center gap-2 hover:bg-blue-700 transition duration-300 disabled:opacity-50 disabled:pointer-events-none" - > - <span v-if="!loading">Update Profile</span> - <span v-else>Saving...</span> - </button> - </div> - </form> - </div> - </div> + <div class="min-h-screen bg-gray-100 dark:bg-neutral-900 text-center px-4 py-8"> + <div class="text-center mb-10"> + <img + v-if="userPhotoURL" + :src="userPhotoURL" + alt="Profile Picture" + class="w-28 h-28 mx-auto rounded-full object-cover mb-4" + /> + <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> + + <button + @click="showForm = !showForm" + class="mt-4 px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" + > + {{ showForm ? 'Close Update Form' : 'Update Profile' }} + </button> + </div> + + <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> + <label class="block text-sm mb-2 dark:text-white">Display Name</label> + <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">New Password</label> + <input type="password" v-model="newPassword" class="w-full border border-gray-400 rounded-lg p-2 dark:text-white" /> + <p v-if="newPassword && passwordError" class="text-red-500 text-sm mt-1">{{ passwordError }}</p> + </div> + + <button + type="submit" + :disabled="loading || !!passwordError" + class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50" + > + <span v-if="!loading">Save Changes</span> + <span v-else>Saving...</span> + </button> + </form> + </div> + + <div> + <h2 class="text-2xl font-semibold text-gray-800 dark:text-white mb-6">Your Watchlist</h2> + + <div v-if="watchlist.length === 0" class="text-gray-500 dark:text-gray-400 text-sm"> + Your watchlist is empty. + </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" + > + <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> </div> + </div> </div> + </div> </template> <script setup> - import { ref, computed, onMounted } from 'vue'; - import { getAuth, updateProfile, updatePassword } from 'firebase/auth'; - - const auth = getAuth(); - const user = auth.currentUser; - - const loading = ref(false); - const userEmail = ref(''); - const displayName = ref(''); - const newPassword = ref(''); - - // Load current profile info - onMounted(() => { - if (user) { - userEmail.value = user.email; - displayName.value = user.displayName || ''; - } - }); - - // Password validation - const passwordError = computed(() => { - if (!newPassword.value) return ''; - if (newPassword.value.length < 8) return "Password must be at least 8 characters."; - if (!/[A-Z]/.test(newPassword.value)) return "Must include at least 1 uppercase letter."; - if (!/[0-9]/.test(newPassword.value)) return "Must include at least 1 number."; - if (!/[@$!%*?&]/.test(newPassword.value)) return "Must include at least 1 special character (@$!%*?&)."; - return ''; - }); - - // Update function - const updateProfile = async () => { - if (passwordError.value) return; - - loading.value = true; - try { - if (user) { - // Update display name - await updateProfile(user, { - displayName: displayName.value, - }); - - // Update password if provided - if (newPassword.value) { - await updatePassword(user, newPassword.value); - } - - alert('Profile updated successfully!'); - } - } catch (error) { - alert(error.message); - } finally { - loading.value = false; - } - }; +import { ref, computed, onMounted } from 'vue' +import { + getAuth, + updateProfile as firebaseUpdateProfile, + updatePassword, +} from 'firebase/auth' +import { + getFirestore, + collection, + getDocs +} from 'firebase/firestore' + +const auth = getAuth() +const db = getFirestore() + +const user = auth.currentUser + +const loading = ref(false) +const userEmail = ref('') +const displayName = ref('') +const newPassword = ref('') +const userPhotoURL = ref('') +const showForm = ref(false) + +const watchlist = 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.' + if (!/[A-Z]/.test(newPassword.value)) return 'Must include at least 1 uppercase letter.' + if (!/[0-9]/.test(newPassword.value)) return 'Must include at least 1 number.' + if (!/[@$!%*?&]/.test(newPassword.value)) return 'Must include at least 1 special character (@$!%*?&).' + return '' +}) + +// Update profile function +const handleUpdateProfile = async () => { + if (passwordError.value) return + + loading.value = true + try { + if (user) { + await firebaseUpdateProfile(user, { + displayName: displayName.value + }) + + if (newPassword.value) { + await updatePassword(user, newPassword.value) + } + + alert('Profile updated successfully!') + showForm.value = false + } + } catch (error) { + alert(error.message) + } finally { + loading.value = false + } +} </script> - \ No newline at end of file + +<style scoped> +input:disabled { + cursor: not-allowed; +} +</style> \ No newline at end of file diff --git a/movie-group-8/src/views/Watchlist.vue b/movie-group-8/src/views/Watchlist.vue deleted file mode 100644 index 58cf86606685c77a69557491d6f210d3ba2ca52d..0000000000000000000000000000000000000000 --- a/movie-group-8/src/views/Watchlist.vue +++ /dev/null @@ -1,26 +0,0 @@ -<template> - <Watchlist /> -</template> - -<script setup> -import Watchlist from '@/components/Watchlist.vue' - -import { getFirestore, doc, setDoc } from 'firebase/firestore' -import { getAuth } from 'firebase/auth' - -const addToWatchlist = async (movie) => { - const user = getAuth().currentUser - if (!user) return alert('You need to log in.') - - const db = getFirestore() - 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' - }) - - alert('Movie added to watchlist!') -} -</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 new file mode 100644 index 0000000000000000000000000000000000000000..4ce8909b73b403ffdd4d855defd6b30761e23de9 --- /dev/null +++ b/movie-group-8/src/views/WatchlistView.vue @@ -0,0 +1,74 @@ +<template> + <div class="min-h-screen bg-gray-100 dark:bg-neutral-900 p-6"> + <h1 class="text-3xl font-bold mb-6 text-gray-900 dark:text-white text-center">Your Watchlist</h1> + + <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> + <!-- Planned Movies --> + <div> + <h2 class="text-2xl font-semibold mb-4 text-gray-800 dark:text-white">📌 Planned to Watch</h2> + <div v-if="plannedMovies.length === 0" class="text-gray-500 dark:text-gray-400 text-sm"> + 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> + </div> + + <!-- Watched Movies --> + <div> + <h2 class="text-2xl font-semibold mb-4 text-gray-800 dark:text-white">✅ Watched</h2> + <div v-if="watchedMovies.length === 0" class="text-gray-500 dark:text-gray-400 text-sm"> + 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> + </div> + </div> + </div> +</template> + +<script setup> +import { ref, onMounted } from 'vue' +import { getAuth } from 'firebase/auth' +import { getFirestore, collection, getDocs, doc, updateDoc } from 'firebase/firestore' +import MovieCard from '@/components/MovieCard.vue' + +const auth = getAuth() +const db = getFirestore() + +const plannedMovies = ref([]) +const watchedMovies = ref([]) + +const fetchWatchlist = async () => { + const user = auth.currentUser + if (!user) return + + const snapshot = await getDocs(collection(db, 'users', user.uid, 'watchlist')) + const all = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })) + plannedMovies.value = all.filter(m => m.status === 'planned') + watchedMovies.value = all.filter(m => m.status === 'watched') +} + +const updateMovieStatus = async (movieId, newStatus) => { + const user = auth.currentUser + if (!user) return + + const movieRef = doc(db, 'users', user.uid, 'watchlist', movieId) + await updateDoc(movieRef, { status: newStatus }) + + await fetchWatchlist() // Refresh lists +} + +onMounted(fetchWatchlist) +</script>