├── src ├── sass │ ├── base │ │ ├── _index.scss │ │ ├── _base.scss │ │ └── _resets.scss │ ├── utilities │ │ ├── _index.scss │ │ ├── _mixins.scss │ │ ├── _animations.scss │ │ ├── _variables.scss │ │ └── _helpers.scss │ ├── main.scss │ ├── components │ │ ├── _index.scss │ │ ├── _search.scss │ │ ├── _buttons.scss │ │ ├── _trending.scss │ │ ├── _trailers.scss │ │ ├── _header.scss │ │ └── _media-showcase.scss │ └── layout │ │ ├── _index.scss │ │ ├── _home-header.scss │ │ ├── _app.scss │ │ ├── _home-devices.scss │ │ ├── _footer.scss │ │ ├── _page404.scss │ │ ├── _home-hero.scss │ │ ├── _lightbox.scss │ │ ├── _access.scss │ │ ├── _profile.scss │ │ └── _media-details.scss ├── assets │ ├── image404.jpg │ ├── no-image.jpg │ ├── no-bookmark.jpg │ └── trailersBackground.jpg ├── auth │ ├── client.js │ ├── registerFormValidation.js │ ├── authentication.js │ └── loginFormValidation.js ├── pages │ ├── 404 │ │ └── index.js │ ├── title │ │ └── index.js │ ├── profile │ │ └── index.js │ ├── topRated │ │ └── index.js │ ├── bookmarks │ │ └── index.js │ └── home │ │ └── index.js ├── main.js ├── app │ ├── index.js │ └── router.js ├── getStartedEmailValidation.js ├── components │ ├── 404 │ │ └── index.js │ ├── app │ │ └── index.js │ ├── renderComponent.js │ ├── trending │ │ └── index.js │ ├── lightbox │ │ └── index.js │ ├── header │ │ └── index.js │ ├── recommended │ │ └── index.js │ ├── trailers │ │ └── index.js │ ├── search │ │ └── index.js │ ├── topRated │ │ └── index.js │ ├── profile │ │ └── index.js │ └── bookmarks │ │ └── index.js ├── slider.js ├── database │ ├── bookmarkManager.js │ └── index.js └── api │ └── fetchData.js ├── public ├── app-preview.jpg ├── landing-preview.jpg └── assets │ ├── home-hero.png │ ├── no-avatar.jpg │ ├── favicon-32x32.png │ ├── mockup-video.mp4 │ ├── home-devices-mockup.png │ ├── icon-play-simple.svg │ ├── icon-category-tv.svg │ └── icon-category-movie.svg ├── .gitignore ├── netlify.toml ├── functions ├── database │ ├── client.js │ ├── index.js │ └── functions.js ├── shared │ └── index.js └── api │ ├── index.js │ └── functions.js ├── package.json ├── vite.config.js ├── app └── index.html ├── access ├── register.html └── login.html └── index.html /src/sass/base/_index.scss: -------------------------------------------------------------------------------- 1 | @forward './resets'; 2 | @forward './base'; -------------------------------------------------------------------------------- /public/app-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/public/app-preview.jpg -------------------------------------------------------------------------------- /src/assets/image404.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/src/assets/image404.jpg -------------------------------------------------------------------------------- /src/assets/no-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/src/assets/no-image.jpg -------------------------------------------------------------------------------- /public/landing-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/public/landing-preview.jpg -------------------------------------------------------------------------------- /src/assets/no-bookmark.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/src/assets/no-bookmark.jpg -------------------------------------------------------------------------------- /public/assets/home-hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/public/assets/home-hero.png -------------------------------------------------------------------------------- /public/assets/no-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/public/assets/no-avatar.jpg -------------------------------------------------------------------------------- /public/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/public/assets/favicon-32x32.png -------------------------------------------------------------------------------- /public/assets/mockup-video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/public/assets/mockup-video.mp4 -------------------------------------------------------------------------------- /src/assets/trailersBackground.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/src/assets/trailersBackground.jpg -------------------------------------------------------------------------------- /src/sass/utilities/_index.scss: -------------------------------------------------------------------------------- 1 | @forward './variables'; 2 | @forward './mixins'; 3 | @forward './helpers'; 4 | @forward './animations'; -------------------------------------------------------------------------------- /public/assets/home-devices-mockup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tediko/movie-database-app/HEAD/public/assets/home-devices-mockup.png -------------------------------------------------------------------------------- /src/sass/main.scss: -------------------------------------------------------------------------------- 1 | /* Base */ 2 | @use './base'; 3 | 4 | /* Components */ 5 | @use './components'; 6 | 7 | /* Layout */ 8 | @use './layout'; -------------------------------------------------------------------------------- /src/sass/base/_base.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | body { 4 | min-height: 100vh; 5 | font-family: var(--ff-primary); 6 | background-color: hsl(var(--bg-primary)); 7 | } -------------------------------------------------------------------------------- /src/sass/components/_index.scss: -------------------------------------------------------------------------------- 1 | @forward './header'; 2 | @forward './buttons'; 3 | @forward './search'; 4 | @forward './trailers'; 5 | @forward './trending'; 6 | @forward './media-showcase'; -------------------------------------------------------------------------------- /src/sass/utilities/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use './variables' as *; 2 | 3 | /* Mixin to manage responsive breakpoints */ 4 | @mixin breakpoint($breakpoint) { 5 | @media (min-width: map-get($breakpoints, $breakpoint)) { 6 | @content; 7 | } 8 | } -------------------------------------------------------------------------------- /public/assets/icon-play-simple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/auth/client.js: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; 4 | const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; 5 | 6 | const supabase = createClient(supabaseUrl, supabaseAnonKey) 7 | 8 | export default supabase; -------------------------------------------------------------------------------- /public/assets/icon-category-tv.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/sass/layout/_index.scss: -------------------------------------------------------------------------------- 1 | @forward './home-header.scss'; 2 | @forward './home-hero.scss'; 3 | @forward './home-devices.scss'; 4 | @forward './app'; 5 | @forward './media-details.scss'; 6 | @forward './profile.scss'; 7 | @forward './access.scss'; 8 | @forward './lightbox.scss'; 9 | @forward './page404.scss'; 10 | @forward './footer.scss'; -------------------------------------------------------------------------------- /src/pages/title/index.js: -------------------------------------------------------------------------------- 1 | import renderComponent from "../../components/renderComponent"; 2 | 3 | /** 4 | * Renders the title page. 5 | * @param {HTMLElement} root - DOM element where the title page components will be rendered 6 | */ 7 | const renderTitle = (root) => { 8 | root.innerHTML = ''; 9 | renderComponent('mediaDetails', root); 10 | } 11 | 12 | export { renderTitle }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | # Local Netlify folder 26 | .netlify -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | functions = "functions" 3 | 4 | [functions] 5 | node_bundler = "esbuild" 6 | 7 | [[redirects]] 8 | from = "/app/*" 9 | to = "/app/index.html" 10 | status = 200 11 | force = true 12 | 13 | [[redirects]] 14 | from = "/login" 15 | to = "/access/login.html" 16 | status = 200 17 | 18 | [[redirects]] 19 | from = "/register" 20 | to = "/access/register.html" 21 | status = 200 -------------------------------------------------------------------------------- /src/pages/404/index.js: -------------------------------------------------------------------------------- 1 | import renderComponent from "../../components/renderComponent"; 2 | 3 | /** 4 | * Renders the 404 page. 5 | * @param {HTMLElement} root - DOM element where the 404 page components will be rendered 6 | */ 7 | const render404 = (root) => { 8 | root.innerHTML = ''; 9 | document.title = `MovieDB - 404: Take the cannoli!` 10 | renderComponent('404', root); 11 | } 12 | 13 | export { render404 }; -------------------------------------------------------------------------------- /src/pages/profile/index.js: -------------------------------------------------------------------------------- 1 | import renderComponent from "../../components/renderComponent"; 2 | 3 | /** 4 | * Renders the profile page. 5 | * @param {HTMLElement} root - DOM element where the profile page components will be rendered 6 | */ 7 | const renderProfile = (root) => { 8 | root.innerHTML = ''; 9 | document.title = `MovieDB - Profile` 10 | renderComponent('profile', root); 11 | } 12 | 13 | export { renderProfile }; -------------------------------------------------------------------------------- /functions/database/client.js: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | const supabaseUrl = process.env.SUPABASE_URL; 4 | const supabaseKey = process.env.SUPABASE_ANON_KEY; 5 | const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY; 6 | 7 | const supabase = createClient(supabaseUrl, supabaseKey, { 8 | global: { 9 | headers: { 10 | Authorization: `Bearer ${supabaseServiceKey}` 11 | } 12 | } 13 | }) 14 | 15 | export default supabase; -------------------------------------------------------------------------------- /src/pages/topRated/index.js: -------------------------------------------------------------------------------- 1 | import renderComponent from "../../components/renderComponent"; 2 | 3 | /** 4 | * Renders the top-rated page. 5 | * @param {HTMLElement} root - DOM element where the top-rated page components will be rendered 6 | */ 7 | const renderTopRated = (root) => { 8 | root.innerHTML = ''; 9 | document.title = `MovieDB - Top rated movies & TV series` 10 | renderComponent('search', root); 11 | renderComponent('top', root); 12 | } 13 | 14 | export { renderTopRated }; -------------------------------------------------------------------------------- /src/pages/bookmarks/index.js: -------------------------------------------------------------------------------- 1 | import renderComponent from "../../components/renderComponent"; 2 | 3 | /** 4 | * Renders the bookmarks page. 5 | * @param {HTMLElement} root - DOM element where the bookmarks page components will be rendered 6 | */ 7 | const renderBookmarks = (root) => { 8 | root.innerHTML = ''; 9 | document.title = `MovieDB - Bookmarked Movies and TV Series` 10 | renderComponent('search', root); 11 | renderComponent('bookmarks', root); 12 | } 13 | 14 | export { renderBookmarks }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "template", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "netlify-cli": "^17.33.4", 13 | "sass": "^1.77.1", 14 | "vite": "^5.2.0" 15 | }, 16 | "dependencies": { 17 | "@supabase/supabase-js": "^2.44.0", 18 | "lambda-multipart-parser": "^1.0.1", 19 | "swiper": "^11.1.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/sass/layout/_home-header.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .home-header { 4 | $root: &; 5 | position: absolute; 6 | width: 100%; 7 | top: clamp(1.5rem, 0.548rem + 4.061vw, 2.5rem); 8 | 9 | &__container { 10 | display: flex; 11 | justify-content: space-between; 12 | align-items: center; 13 | } 14 | 15 | &__logo-cta { 16 | &:focus-visible { 17 | @extend %focus-visible; 18 | } 19 | } 20 | 21 | &__logo { 22 | width: 60px; 23 | } 24 | } -------------------------------------------------------------------------------- /src/sass/utilities/_animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes error-email { 2 | 0% { 3 | visibility: hidden; 4 | transform: translateX(0); 5 | opacity: 0; 6 | } 7 | 25% { transform: translateX(5px) } 8 | 50% { transform: translateX(-5px) } 9 | 75% { transform: translateX(5px) } 10 | 100% { 11 | transform: translateX(0); 12 | opacity: 1; 13 | visibility: visible; 14 | } 15 | } 16 | 17 | @keyframes shimmer-effect { 18 | to { 19 | background-position-x: 0% 20 | } 21 | } -------------------------------------------------------------------------------- /public/assets/icon-category-movie.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import './sass/main.scss'; 2 | import initGetStartedEmailValidation from './getStartedEmailValidation'; 3 | import { initLoginFormValidation, handleLoginButtonClick } from './auth/loginFormValidation'; 4 | import { initRegisterFormValidation } from './auth/registerFormValidation'; 5 | import { initTrailers } from './components/trailers'; 6 | import { initApp } from './app'; 7 | 8 | initGetStartedEmailValidation('[data-home-signup]'); 9 | initLoginFormValidation(); 10 | initRegisterFormValidation(); 11 | handleLoginButtonClick(); 12 | initApp(); 13 | initTrailers(); -------------------------------------------------------------------------------- /src/pages/home/index.js: -------------------------------------------------------------------------------- 1 | import renderComponent from "../../components/renderComponent"; 2 | 3 | /** 4 | * Renders the home page. 5 | * @param {HTMLElement} root - DOM element where the home page components will be rendered 6 | */ 7 | const renderHome = (root) => { 8 | root.innerHTML = ''; 9 | document.title = `MovieDB - Your personal entertainment hub`; 10 | renderComponent('search', root); 11 | renderComponent('trending', root); 12 | renderComponent('trailers', root); 13 | renderComponent('recommended', root); 14 | } 15 | 16 | export { renderHome }; -------------------------------------------------------------------------------- /src/sass/layout/_app.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .app { 4 | $root: &; 5 | 6 | @include breakpoint('md') { 7 | padding-left: 2.5rem; 8 | } 9 | 10 | &__wrapper { 11 | display: grid; 12 | gap: 2.25rem; 13 | 14 | @include breakpoint('md') { 15 | grid-template-columns: max-content 1fr; 16 | min-height: 100vh; 17 | } 18 | } 19 | 20 | &__main { 21 | overflow: hidden; 22 | max-width: 120rem; 23 | padding-inline: clamp(1rem, -0.428rem + 6.091vw, 2.5rem); 24 | 25 | @include breakpoint('md') { 26 | padding-right: 0; 27 | } 28 | } 29 | 30 | &__search { 31 | margin-bottom: 1.25rem; 32 | 33 | @include breakpoint('md') { 34 | margin-top: 3.8rem; 35 | margin-right: 1.2rem; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | // vite.config.js 2 | import { resolve } from 'path' 3 | import { defineConfig } from 'vite' 4 | 5 | export default defineConfig({ 6 | build: { 7 | manifest: true, 8 | rollupOptions: { 9 | input: { 10 | main: resolve(__dirname, 'index.html'), 11 | login: resolve(__dirname, 'access/login.html'), 12 | register: resolve(__dirname, 'access/register.html'), 13 | app: resolve(__dirname, 'app/index.html'), 14 | }, 15 | }, 16 | }, 17 | plugins: [ 18 | { 19 | name: 'rewrite-middleware', 20 | configureServer(server) { 21 | server.middlewares.use((req, res, next) => { 22 | if (req.url.startsWith('/app')) { 23 | req.url = '/app/index.html'; 24 | } 25 | next(); 26 | }); 27 | }, 28 | }, 29 | ], 30 | server: { 31 | open: '/index.html', 32 | }, 33 | base: '', 34 | }) -------------------------------------------------------------------------------- /src/app/index.js: -------------------------------------------------------------------------------- 1 | import { bookmarkManager } from '../database/bookmarkManager'; 2 | import { getUser } from '../auth/authentication'; 3 | import { router } from './router'; 4 | 5 | // Elements 6 | let root; 7 | 8 | // Selectors 9 | const rootSelector = `#root`; 10 | 11 | /** 12 | * Initializes the Application 13 | */ 14 | async function initApp() { 15 | root = document.querySelector(rootSelector); 16 | 17 | // Check if root element exists. 18 | if (!root) return; 19 | const user = await getUser(); 20 | 21 | // Check if there is an existing user session, if not return and redirect to login page. 22 | if (!user) { 23 | window.location.href = '/access/login.html'; 24 | return; 25 | }; 26 | 27 | // Initializes bookmarks 28 | await bookmarkManager.init(); 29 | // Triggering the correct route based on the current URL 30 | router.loadInitialRoute(); 31 | } 32 | 33 | export { initApp }; -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | MovieDB - Your personal entertainment hub 12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/sass/layout/_home-devices.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .home-devices { 4 | $root: &; 5 | 6 | &__container { 7 | display: grid; 8 | justify-content: center; 9 | text-align: center; 10 | padding-block: 6.5625rem; 11 | } 12 | 13 | &__title { 14 | margin-bottom: 1rem; 15 | } 16 | 17 | &__desc { 18 | margin-bottom: 3.5rem; 19 | 20 | br { 21 | display: none; 22 | 23 | @include breakpoint('sm') { 24 | display: block; 25 | } 26 | } 27 | } 28 | 29 | &__mockup-wrapper { 30 | position: relative; 31 | } 32 | 33 | &__mockup-video-wrapper { 34 | position: absolute; 35 | top: 2%; 36 | left: 33.5%; 37 | width: 56.5%; 38 | height: 67%; 39 | overflow: hidden; 40 | z-index: -1; 41 | } 42 | 43 | &__mockup-video { 44 | width: 120%; 45 | } 46 | } -------------------------------------------------------------------------------- /src/sass/utilities/_variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Colors */ 3 | --c-white: 0 0% 100%; 4 | --c-red: 0 92% 47%; 5 | --c-red-dark: 357 84% 41%; 6 | --c-blue-dark: 223 30% 9%; 7 | --c-blue-grayish: 223 23% 46%; 8 | --c-blue-dark-semi: 223 36% 14%; 9 | --c-blue-light: 200 100% 40%; 10 | --c-green: 138 97% 38%; 11 | --c-gold: 51 100% 50%; 12 | 13 | /* Backgrounds */ 14 | --bg-primary: 223 30% 9%; 15 | --bg-blue-navy: 207 91% 13%; 16 | 17 | /* Typography */ 18 | --ff-primary: "Outfit", sans-serif; 19 | 20 | --fs-800: clamp(2rem, 0.216rem + 3.712vw, 3rem); 21 | --fs-700: 2.5rem; 22 | --fs-600: 2rem; 23 | --fs-500: 1.5rem; 24 | --fs-450: clamp(1.125rem, 0.793rem + 1.105vw, 1.5rem); 25 | --fs-400: 1.125rem; 26 | --fs-300: 0.9375rem; 27 | --fs-200: 0.8125rem; 28 | } 29 | 30 | /* Media query breakpoints */ 31 | $breakpoints: ( 32 | xs: 23.5rem, 33 | sm: 30.0625rem, 34 | md: 48.0625rem, 35 | lg: 64rem, 36 | xlg: 75rem 37 | ); -------------------------------------------------------------------------------- /functions/shared/index.js: -------------------------------------------------------------------------------- 1 | import { Blob } from "buffer"; 2 | const parser = require('lambda-multipart-parser'); 3 | 4 | /** 5 | * This function uses a form parser to process the event and retrieves 6 | * the avatar file name and a Blob representation of the avatar content. 7 | * @param {Object} event - The event object containing the incoming request data. 8 | * @returns {Promise<{ avatarFileName: string, avatarBlob: Blob }>} 9 | * A promise that resolves to an object containing: 10 | * - avatarFileName {string} - The name of the uploaded avatar file. 11 | * - avatarBlob {Blob} - A Blob representation of the uploaded avatar content. 12 | */ 13 | const parseAvatar = async (event) => { 14 | const formDataObject = await parser.parse(event); 15 | const avatarBuffer = Buffer.from(formDataObject.files[0].content); 16 | const avatarFileName = formDataObject.avatarFileName; 17 | const avatarBlob = new Blob([avatarBuffer], { type: formDataObject.files[0].contentType }); 18 | 19 | return { avatarFileName, avatarBlob }; 20 | }; 21 | 22 | export { parseAvatar } -------------------------------------------------------------------------------- /src/getStartedEmailValidation.js: -------------------------------------------------------------------------------- 1 | import { showFormMessage, emailValidation } from "./utilities"; 2 | 3 | /** 4 | * Handles form submission and email validation. 5 | * @param {Event} event - The submit event object 6 | */ 7 | const handleValidationOnSubmit = (event) => { 8 | event.preventDefault(); 9 | 10 | const eventTarget = event.target; 11 | const inputElement = eventTarget.querySelector('input'); 12 | const inputValue = inputElement.value.trim().toLowerCase(); 13 | 14 | if (emailValidation(inputValue)) { 15 | window.location.href = `access/register.html?email=${inputValue}`; 16 | } else { 17 | showFormMessage(eventTarget, ['Please enter a valid email address.']); 18 | } 19 | } 20 | 21 | /** 22 | * Initializes email validation for "get started" form. 23 | * @param {string} formSelector - Selector for the form element 24 | */ 25 | const initGetStartedEmailValidation = (formSelector) => { 26 | const formElement = document.querySelector(formSelector); 27 | 28 | if (!formElement) return; 29 | 30 | formElement.addEventListener('submit', handleValidationOnSubmit); 31 | } 32 | 33 | export default initGetStartedEmailValidation; -------------------------------------------------------------------------------- /src/sass/layout/_footer.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .footer { 4 | $root: &; 5 | padding-block: 3.3125rem; 6 | 7 | &__container { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | } 12 | 13 | &__logo-cta { 14 | &:focus-visible { 15 | @extend %focus-visible; 16 | } 17 | } 18 | 19 | &__logo { 20 | width: 60px; 21 | } 22 | 23 | &__socials-link { 24 | color: hsl(var(--c-red)); 25 | font-size: 1.35rem; 26 | transition: color 350ms ease-in-out; 27 | 28 | &:focus-visible { 29 | @extend %focus-visible; 30 | } 31 | 32 | @include breakpoint('md') { 33 | i { 34 | transition: transform 350ms ease-in-out; 35 | } 36 | 37 | &:hover { 38 | color: hsl(var(--c-red-dark)); 39 | 40 | i { 41 | transform: scale(1.1); 42 | } 43 | } 44 | } 45 | } 46 | 47 | &__info { 48 | display: grid; 49 | gap: 1rem; 50 | } 51 | 52 | &__list { 53 | display: flex; 54 | justify-content: center; 55 | align-items: center; 56 | gap: 0.5rem; 57 | } 58 | } -------------------------------------------------------------------------------- /src/sass/base/_resets.scss: -------------------------------------------------------------------------------- 1 | /* Box sizing rules */ 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: border-box; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | /* Prevent font size inflation */ 11 | html { 12 | -moz-text-size-adjust: none; 13 | -webkit-text-size-adjust: none; 14 | text-size-adjust: none; 15 | } 16 | 17 | /* Remove default margin in favour of better control in authored CSS */ 18 | body, h1, h2, h3, h4, p, 19 | figure, blockquote, dl, dd, ul { 20 | margin-block-end: 0; 21 | margin: 0; 22 | } 23 | 24 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 25 | ul, ol { 26 | list-style: none; 27 | } 28 | 29 | /* Set shorter line heights on headings and interactive elements */ 30 | h1, h2, h3, h4, 31 | button, input, label { 32 | line-height: 1.1; 33 | } 34 | 35 | /* Make images easier to work with */ 36 | img, 37 | picture { 38 | max-width: 100%; 39 | display: block; 40 | } 41 | 42 | /* Inherit fonts for inputs and buttons */ 43 | input, button, 44 | textarea, select { 45 | font: inherit; 46 | } 47 | 48 | /* Make sure textareas without a rows attribute are not tiny */ 49 | textarea:not([rows]) { 50 | min-height: 10em; 51 | } 52 | 53 | /* Anything that has been anchored to should have extra scroll margin */ 54 | :target { 55 | scroll-margin-block: 5ex; 56 | } -------------------------------------------------------------------------------- /src/sass/components/_search.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .search { 4 | &__form { 5 | display: flex; 6 | align-items: start; 7 | gap: 1.5rem; 8 | } 9 | 10 | &__submit { 11 | position: relative; 12 | background-color: transparent; 13 | border: none; 14 | cursor: pointer; 15 | transition: color 350ms ease-in-out; 16 | 17 | &::before { 18 | content: "\f002"; 19 | font-family: "Font Awesome 6 Free"; 20 | font-weight: 900; 21 | } 22 | 23 | &:focus-visible { 24 | @extend %focus-visible; 25 | color: hsl(var(--c-red)); 26 | } 27 | 28 | @include breakpoint('md') { 29 | &:hover { 30 | color: hsl(var(--c-red)); 31 | } 32 | } 33 | } 34 | 35 | &__input { 36 | background-color: transparent; 37 | border: none; 38 | width: 100%; 39 | padding-bottom: 1rem; 40 | caret-color: hsl(var(--c-red)); 41 | 42 | &:focus { 43 | border: none; 44 | outline: none; 45 | } 46 | 47 | &:focus-visible { 48 | border-bottom: 1px solid hsl(var(--c-blue-grayish)); 49 | margin-bottom: -1px; 50 | } 51 | } 52 | 53 | &__title { 54 | margin-top: 0.5rem; 55 | margin-bottom: 1.5rem; 56 | text-overflow: ellipsis; 57 | overflow: hidden; 58 | 59 | @include breakpoint('lg') { 60 | white-space: nowrap; 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/components/404/index.js: -------------------------------------------------------------------------------- 1 | import { router } from '../../app/router'; 2 | import image404 from '../../assets/image404.jpg'; 3 | 4 | // Elements 5 | let wrapperContainer; 6 | let navLink; 7 | 8 | // Selectors 9 | const wrapperSelector = `[data-404-wrapper]`; 10 | const navLinkSelector = `[data-404-link]`; 11 | 12 | /** 13 | * Initializes 404 section 14 | */ 15 | const init404 = () => { 16 | wrapperContainer = document.querySelector(wrapperSelector); 17 | if (!wrapperContainer) return; 18 | 19 | navLink = document.querySelector(navLinkSelector); 20 | navLink.addEventListener('click', handleNavLinkClick); 21 | } 22 | 23 | /** 24 | * Handles the click event on navigation link 25 | * @param {Event} event - The click event object. 26 | */ 27 | const handleNavLinkClick = (event) => { 28 | event.preventDefault(); 29 | router.navigateTo('/app'); 30 | } 31 | 32 | /** 33 | * Returns the HTML for the 404 component. 34 | * @returns {string} The HTML for the 404 component. 35 | */ 36 | const get404Html = () => { 37 | return ` 38 |
39 |

40 | I'm gonna make him an offer he can't refuse. 41 | - Vito Corleone 42 |

43 |
44 | 45 |
46 |

Page not found!

47 |

It seems this page is sleeping with the fishes. Don't worry, we're not going to the mattresses over this. Why don't you go back to the homepage and enjoy some cannoli?

48 |
`; 49 | } 50 | 51 | export { get404Html, init404 }; -------------------------------------------------------------------------------- /src/slider.js: -------------------------------------------------------------------------------- 1 | import Swiper from 'swiper'; 2 | import 'swiper/css'; 3 | 4 | /** 5 | * Initialize a Swiper slider instance. 6 | * @param {string} containerSelector - The CSS selector for the slider container. 7 | * @param {Object} [options={}] - Custom options to override default Swiper settings. 8 | * @returns {Swiper|null} The Swiper instance or null if initialization fails. 9 | */ 10 | const initSlider = (containerSelector, options = {}) => { 11 | const sliderContainer = document.querySelector(containerSelector); 12 | 13 | if (!sliderContainer) { 14 | console.error(`Slider container not found: ${sliderContainer}`); 15 | return null; 16 | } 17 | // default Swiper options 18 | const defaultOptions = { 19 | loop: false, 20 | slidesPerView: "auto", 21 | spaceBetween: 24, 22 | speed: 600, 23 | centeredSlides: false, 24 | on: { 25 | init: (swiper) => { 26 | // Add data-slide-index attribute to each slide 27 | swiper.slides.forEach((slide, index) => { 28 | slide.setAttribute('data-slide-index', index); 29 | }); 30 | } 31 | } 32 | } 33 | // Merge default options with custom options 34 | const sliderOptions = { ...defaultOptions, ...options }; 35 | 36 | // Initialize Swiper instance 37 | const swiper = new Swiper(sliderContainer, sliderOptions); 38 | 39 | // Add event listener for focus events 40 | // Return if focused element has data-bookmark-cta attribute 41 | // When user use keyboard to navigate through swiper slides it will 42 | // change sliderWrapper transform value to match focused slide 43 | // and prevent bug where slider is stuck. 44 | sliderContainer.addEventListener('focusin', (event) => { 45 | if (event.target.hasAttribute('data-bookmark-cta')) return; 46 | 47 | const focusedElement = event.target.parentElement; 48 | 49 | if (focusedElement) { 50 | const slideIndex = focusedElement.dataset.slideIndex; 51 | swiper.slideTo(slideIndex); 52 | } 53 | }); 54 | 55 | // Return Swiper instance 56 | return swiper; 57 | }; 58 | 59 | export default initSlider; -------------------------------------------------------------------------------- /src/sass/layout/_page404.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .page404 { 4 | &__container { 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | align-items: center; 9 | margin-top: 1.5rem; 10 | 11 | @include breakpoint('md') { 12 | min-height: calc(100vh - 4rem); // Little cheat to remove parent container padding 13 | } 14 | } 15 | 16 | &__quote { 17 | position: relative; 18 | display: grid; 19 | max-width: 15ch; 20 | text-align: center; 21 | line-height: 1.3; 22 | 23 | &::before { 24 | position: relative; 25 | content: '“'; 26 | color: hsl(var(--c-red)); 27 | font-size: 5rem; 28 | line-height: 0.2; 29 | } 30 | 31 | span { 32 | margin-top: 1rem; 33 | } 34 | } 35 | 36 | &__image-wrapper { 37 | position: relative; 38 | overflow: hidden; 39 | margin-top: 1rem; 40 | 41 | &::before { 42 | position: absolute; 43 | display: block; 44 | content: ""; 45 | top: 0; 46 | left: 0; 47 | width: 100%; 48 | height: 100%; 49 | pointer-events: none; 50 | background-image: radial-gradient(closest-side, #fff0, #fff0, hsl(var(--c-blue-dark))); 51 | z-index: 1; 52 | } 53 | } 54 | 55 | &__image { 56 | max-width: 500px; 57 | width: 100%; 58 | } 59 | 60 | &__title { 61 | margin-top: 2rem; 62 | margin-bottom: 0.5rem; 63 | } 64 | 65 | &__desc { 66 | text-align: center; 67 | max-width: 500px; 68 | } 69 | 70 | &__cta { 71 | transition: color 350ms ease-in-out; 72 | 73 | &:focus-visible { 74 | outline: none; 75 | border: none; 76 | color: hsl(var(--c-red)); 77 | text-decoration: underline; 78 | } 79 | 80 | @include breakpoint('md') { 81 | &:hover { 82 | color: hsl(var(--c-red)); 83 | text-decoration: underline; 84 | } 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /functions/api/index.js: -------------------------------------------------------------------------------- 1 | import { fetchUpcomingMovies, fetchTrailerSrcKey, fetchTrending, fetchRecommendations, fetchTopRated, fetchSearchResults, fetchMediaDetails } from './functions'; 2 | 3 | /** 4 | * Handler function for processing HTTP requests in a Netlify function. 5 | * @async 6 | * @param {Object} event - The event object containing details about the HTTP request. 7 | * @returns {Promise} A promise that resolves to an object containing the HTTP response. 8 | */ 9 | const handler = async (event) => { 10 | if (event.httpMethod === 'GET') { 11 | const { action, ...params } = event.queryStringParameters || {}; 12 | 13 | const actionsMap = { 14 | fetchUpcomingMovies: async () => { 15 | return await fetchUpcomingMovies(); 16 | }, 17 | fetchTrailerSrcKey: async () => { 18 | const movieId = parseInt(params.movieId); 19 | return await fetchTrailerSrcKey(movieId); 20 | }, 21 | fetchTrending: async () => { 22 | return await fetchTrending(); 23 | }, 24 | fetchRecommendations: async () => { 25 | return await fetchRecommendations(params.movieId, params.seriesId); 26 | }, 27 | fetchTopRated: async () => { 28 | return await fetchTopRated(params.type, params.page); 29 | }, 30 | fetchSearchResults: async () => { 31 | return await fetchSearchResults(params.searchQuery); 32 | }, 33 | fetchMediaDetails: async () => { 34 | return await fetchMediaDetails(params.type, params.mediaId); 35 | } 36 | } 37 | 38 | try { 39 | const actionHandler = actionsMap[action]; 40 | 41 | if (!actionHandler) { 42 | return { 43 | statusCode: 400, 44 | body: JSON.stringify({error: 'Invalid action'}) 45 | } 46 | } 47 | 48 | const response = await actionHandler(); 49 | 50 | return { 51 | statusCode: 200, 52 | body: JSON.stringify(response) 53 | } 54 | } catch (error) { 55 | return { 56 | statusCode: 500, 57 | body: JSON.stringify(error.message) 58 | } 59 | } 60 | } 61 | } 62 | 63 | export { handler } -------------------------------------------------------------------------------- /src/sass/layout/_home-hero.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .home-hero { 4 | $root: &; 5 | 6 | height: 46.875rem; 7 | background-image: url('/assets/home-hero.png'); 8 | background-repeat: no-repeat; 9 | background-size: cover; 10 | background-position-x: 40%; 11 | min-height: 100vh; 12 | 13 | @include breakpoint('lg') { 14 | height: 56.25rem; 15 | } 16 | 17 | &__container { 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | gap: 1.5rem; 23 | height: 100%; 24 | 25 | @media (max-width: 64rem) and (orientation: landscape) { 26 | justify-content: center; 27 | padding-bottom: 0; 28 | } 29 | 30 | @include breakpoint('lg') { 31 | padding-bottom: 8.5rem; 32 | justify-content: end; 33 | } 34 | } 35 | 36 | &__title, &__desc { 37 | text-align: center; 38 | } 39 | 40 | &__title { 41 | max-width: 30ch; 42 | } 43 | 44 | &__signup-form { 45 | display: flex; 46 | flex-direction: column; 47 | justify-content: center; 48 | gap: 0.5rem; 49 | width: 100%; 50 | max-width: 31.9375rem; 51 | } 52 | 53 | &__input-wrapper { 54 | display: flex; 55 | flex-direction: column; 56 | justify-content: space-between; 57 | width: 100%; 58 | gap: 1rem; 59 | 60 | &:has(:focus-visible) { 61 | @extend %focus-visible; 62 | } 63 | 64 | @include breakpoint('sm') { 65 | flex-direction: row; 66 | gap: unset; 67 | background: hsl(var(--c-white)); 68 | border-radius: 8px; 69 | } 70 | } 71 | 72 | &__input { 73 | flex: 1; 74 | border: none; 75 | border-radius: 8px; 76 | padding-left: 20px; 77 | padding-block: 0.85rem; 78 | 79 | &:focus { 80 | border: none; 81 | outline: none; 82 | } 83 | 84 | @include breakpoint('sm') { 85 | padding-block: unset; 86 | } 87 | } 88 | 89 | &__error { 90 | visibility: hidden; 91 | 92 | i { 93 | padding-right: 0.1rem; 94 | } 95 | 96 | &.has-error { 97 | animation: error-email 350ms ease-in-out forwards; 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /access/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | MovieDB - Register 12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 |
23 |

Sign Up

24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 |

35 | Already have an account? 36 | Login 37 |

38 |
39 |
40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /src/sass/layout/_lightbox.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .lightbox { 4 | $root: &; 5 | position: fixed; 6 | top: 50%; 7 | left: 0; 8 | width: 100%; 9 | transform: translateY(-50%) scale(0.8); 10 | opacity: 0; 11 | transition: transform 350ms ease-in-out, 12 | opacity 350ms ease-in-out; 13 | visibility: hidden; 14 | z-index: 1000; 15 | 16 | &:focus { 17 | border: none; 18 | outline: none; 19 | } 20 | 21 | &.active { 22 | opacity: 1; 23 | transform: translateY(-50%) scale(1); 24 | visibility: visible; 25 | } 26 | 27 | &__container { 28 | max-width: 75rem; 29 | } 30 | 31 | &__top { 32 | display: flex; 33 | justify-content: space-between; 34 | align-items: center; 35 | margin-bottom: 1rem; 36 | } 37 | 38 | &__close-cta { 39 | border: none; 40 | background-color: transparent; 41 | color: hsl(var(--c-red)); 42 | cursor: pointer; 43 | font-size: 1.5rem; 44 | 45 | &:focus-visible { 46 | @extend %focus-visible; 47 | } 48 | 49 | @include breakpoint('md') { 50 | transition: color 350ms ease-in-out; 51 | 52 | &:hover { 53 | color: hsl(var(--c-white)); 54 | } 55 | } 56 | } 57 | 58 | &__video { 59 | display: flex; 60 | justify-content: center; 61 | box-shadow: 0px 0px 111px -23px hsl(var(--c-blue-light)); 62 | background-color: hsl(var(--c-blue-dark)); 63 | 64 | iframe { 65 | width: 100%; 66 | aspect-ratio: 16 / 9; 67 | } 68 | } 69 | 70 | &__cta { 71 | display: inline-block; 72 | width: 100%; 73 | text-decoration: none; 74 | text-align: right; 75 | 76 | &:focus-visible { 77 | text-decoration: underline; 78 | } 79 | 80 | &:focus { 81 | border: none; 82 | outline: none; 83 | } 84 | } 85 | 86 | &__error { 87 | display: grid; 88 | place-items: center; 89 | gap: 0.5rem; 90 | box-shadow: 0px 0px 111px -23px hsl(var(--c-blue-light)); 91 | background-color: hsl(var(--c-blue-dark)); 92 | padding-block: 5rem; 93 | padding-inline: 1.5rem; 94 | 95 | i { 96 | color: hsl(var(--c-red)); 97 | font-size: 3rem; 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /access/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | MovieDB - Login 12 | 13 | 14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 |
23 |

Login

24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |

33 | Don't have an account? 34 | Sign Up 35 |

36 |

37 | Want to explore without registering? 38 |
39 | Use test account 40 |

41 |
42 |
43 |
44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /src/app/router.js: -------------------------------------------------------------------------------- 1 | import { bookmarkManager } from '../database/bookmarkManager'; 2 | import { renderApp } from '../components/app'; 3 | 4 | /** 5 | * Gets the current URL path. 6 | * @returns {string} The current URL path. 7 | */ 8 | const getCurrentURL = () => { 9 | const path = window.location.pathname; 10 | return path; 11 | } 12 | 13 | /** 14 | * Finds and returns a route that matches the given path. 15 | * @param {string} path - The URL path to match. 16 | * @returns {(object|null)} The matched route object if found, null otherwise. 17 | */ 18 | const matchUrlToRoutes = (path) => { 19 | const matchedRoute = routes.find((route) => route.path === path); 20 | const route404 = routes.find((route) => route.path === '/404'); 21 | return matchedRoute || route404; 22 | } 23 | 24 | /** 25 | * Loads the initial route based on the current URL path. 26 | */ 27 | const loadInitialRoute = () => { 28 | const path = getCurrentURL(); 29 | loadRoute(path); 30 | } 31 | 32 | /** 33 | * Loads a route based on the given URL path. 34 | * @param {string} path - The URL path name 35 | */ 36 | const loadRoute = (path) => { 37 | const matchedRoute = matchUrlToRoutes(path); 38 | 39 | if (!matchedRoute) { 40 | throw new Error('Route not found'); 41 | } 42 | 43 | matchedRoute.callback(); 44 | } 45 | 46 | /** 47 | * Navigates to the specified path and loads the corresponding route. 48 | * @param {string} path - The path to navigate to. 49 | * @param {URL} [url] - An optional URL object representing the new URL to navigate to with query params. 50 | */ 51 | const navigateTo = (path, url) => { 52 | // Push the new state to the browser's history stack without causing a full page reload. 53 | window.history.pushState(null, null, url ? url : path); 54 | bookmarkManager.unsubscribe(); 55 | loadRoute(path); 56 | } 57 | 58 | // Listen when the user navigate through the session history using the browser's back or forward buttons. 59 | window.addEventListener('popstate', () => { 60 | loadInitialRoute(); 61 | }); 62 | 63 | // Defining Routes 64 | const routes = [ 65 | { path: '/app', callback: () => { 66 | renderApp('home'); 67 | }}, 68 | { path: '/app/top-rated', callback: () => { 69 | renderApp('top-rated'); 70 | }}, 71 | { path: '/app/bookmarks', callback: () => { 72 | renderApp('bookmarks'); 73 | }}, 74 | { path: '/app/title', callback: () => { 75 | renderApp('title'); 76 | }}, 77 | { path: '/app/profile', callback: () => { 78 | renderApp('profile'); 79 | }}, 80 | { path: '/404', callback: () => { 81 | renderApp('404'); 82 | }} 83 | ] 84 | 85 | export const router = { 86 | getCurrentURL, 87 | loadInitialRoute, 88 | navigateTo 89 | }; -------------------------------------------------------------------------------- /src/components/app/index.js: -------------------------------------------------------------------------------- 1 | import { renderHome } from "../../pages/home"; 2 | import { renderTopRated } from "../../pages/topRated"; 3 | import { renderBookmarks } from "../../pages/bookmarks"; 4 | import { renderTitle } from "../../pages/title"; 5 | import { renderProfile } from "../../pages/profile"; 6 | import { render404 } from "../../pages/404"; 7 | import renderComponent from "../renderComponent"; 8 | import { updateActiveNavElement } from "../header"; 9 | 10 | // Elements 11 | let root; 12 | let appRoot; 13 | let appWrapper; 14 | 15 | // Selectors 16 | const rootSelector = `#root`; 17 | const appRootSelector = `[data-app-root]`; 18 | const appWrapperSelector = `[data-app-wrapper]`; 19 | 20 | // Flags 21 | let isInitialized = false; 22 | 23 | /** 24 | * Renders the app component and switches page to render the appropriate content. 25 | * @param {string} pageName - The name of the page to switch to. 26 | */ 27 | const renderApp = (pageName) => { 28 | root = document.querySelector(rootSelector); 29 | if (!root) return; 30 | 31 | initApp(); 32 | pageSwitch(pageName); 33 | } 34 | 35 | /** 36 | * Initializes the app component 37 | */ 38 | const initApp = () => { 39 | if (isInitialized) return; 40 | 41 | // Parse the app component html string into a DocumentFragment 42 | const fragment = document.createRange().createContextualFragment(getAppHtml()); 43 | root.appendChild(fragment); 44 | 45 | appRoot = document.querySelector(appRootSelector); 46 | appWrapper = document.querySelector(appWrapperSelector); 47 | 48 | renderComponent('header', appWrapper, true); 49 | isInitialized = true; 50 | } 51 | 52 | /** 53 | * Switches between different pages and renders the appropriate content. 54 | * @param {string} pageName - The name of the page to switch to. 55 | */ 56 | const pageSwitch = (pageName) => { 57 | const pages = { 58 | 'home': () => renderHome(appRoot), 59 | 'top-rated': () => renderTopRated(appRoot), 60 | 'bookmarks': () => renderBookmarks(appRoot), 61 | 'title': () => renderTitle(appRoot), 62 | 'profile': () => renderProfile(appRoot), 63 | '404': () => render404(appRoot) 64 | } 65 | 66 | const renderPage = pages[pageName] || pages['404']; 67 | renderPage(); 68 | updateActiveNavElement(); 69 | } 70 | 71 | /** 72 | * Returns the HTML for the app component. 73 | * @returns {string} The HTML for the app component. 74 | */ 75 | const getAppHtml = () => { 76 | return ` 77 |
78 |
79 |

MovieDB - Your personal entertainment hub

80 |
81 |
82 |
83 | ` 84 | } 85 | 86 | export { renderApp, getAppHtml }; -------------------------------------------------------------------------------- /functions/database/index.js: -------------------------------------------------------------------------------- 1 | import { getUserBookmarks, getGenres, getRandomMedia, downloadAvatar, updateUserBookmarks, createRecord, uploadAvatar } from './functions'; 2 | import { parseAvatar } from '../shared'; 3 | 4 | /** 5 | * Handler function for processing HTTP requests in a Netlify function. 6 | * Supports both GET and POST methods to perform various actions 7 | * based on the specified action parameter in the request. 8 | * @async 9 | * @param {Object} event - The event object containing details about the HTTP request. 10 | * @returns {Promise} A promise that resolves to an object containing the HTTP response. 11 | */ 12 | const handler = async (event) => { 13 | // Determine the HTTP method of the request 14 | const isHttpMethodGet = event.httpMethod === 'GET'; 15 | const isHttpMethodPost = event.httpMethod === 'POST'; 16 | let action, params, requestBody; 17 | 18 | // Process query parameters for GET and body for POST requests 19 | if (isHttpMethodGet || isHttpMethodPost) { 20 | ({ action, ...params } = event.queryStringParameters || {}); 21 | 22 | if (isHttpMethodPost && action !== 'uploadAvatar') { 23 | requestBody = JSON.parse(event.body); 24 | } 25 | } 26 | 27 | // Map of actions to corresponding handler functions 28 | const actionsMap = { 29 | getUserBookmarks: async () => await getUserBookmarks(params.userUid), 30 | getGenres: async () => await getGenres(), 31 | getRandomMedia: async () => await getRandomMedia(), 32 | downloadAvatar: async () => await downloadAvatar(params.userUid), 33 | updateUserBookmarks: async () => await updateUserBookmarks(requestBody.updatedBookmarks, requestBody.userUid), 34 | createRecord: async () => await createRecord(requestBody.userUid), 35 | uploadAvatar: async (avatarFileName, avatarBlob) => await uploadAvatar(avatarFileName, avatarBlob) 36 | } 37 | 38 | try { 39 | const actionHandler = actionsMap[action]; 40 | 41 | if (!actionHandler) { 42 | return { 43 | statusCode: 400, 44 | body: JSON.stringify({error: 'Invalid action'}) 45 | } 46 | } 47 | 48 | if (action === 'uploadAvatar') { 49 | const { avatarFileName, avatarBlob } = await parseAvatar(event); 50 | const response = await actionHandler(avatarFileName, avatarBlob); 51 | 52 | return { 53 | statusCode: 200, 54 | body: JSON.stringify(response) 55 | } 56 | } 57 | 58 | const response = await actionHandler(); 59 | 60 | return { 61 | statusCode: 200, 62 | body: JSON.stringify(response) 63 | } 64 | } catch (error) { 65 | return { 66 | statusCode: 500, 67 | body: JSON.stringify(error.message) 68 | } 69 | } 70 | } 71 | 72 | export { handler } -------------------------------------------------------------------------------- /src/sass/components/_buttons.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .cta-signin-sm { 4 | background-color: hsl(var(--c-red)); 5 | border-radius: 6px; 6 | padding-block: 0.5rem; 7 | padding-inline: 2rem; 8 | 9 | &:focus-visible { 10 | @extend %focus-visible; 11 | } 12 | 13 | @include breakpoint('md') { 14 | transition: background-color 350ms ease-in-out; 15 | 16 | &:hover { 17 | background-color: hsl(var(--c-red-dark)); 18 | } 19 | } 20 | } 21 | 22 | .cta-start { 23 | background-color: hsl(var(--c-red)); 24 | border-radius: 8px; 25 | padding-block: 0.85rem; 26 | padding-inline: 2rem; 27 | border: none; 28 | cursor: pointer; 29 | white-space: nowrap; 30 | 31 | &:focus { 32 | border: none; 33 | outline: none; 34 | } 35 | 36 | &:focus-visible { 37 | background-color: hsl(var(--c-red-dark)); 38 | font-weight: 600; 39 | } 40 | 41 | @include breakpoint('sm') { 42 | border-radius: 0 8px 8px 0; 43 | } 44 | 45 | @include breakpoint('md') { 46 | transition: background-color 350ms ease-in-out, 47 | font-weight 350ms ease-in-out; 48 | 49 | &:hover { 50 | background-color: hsl(var(--c-red-dark)); 51 | } 52 | } 53 | } 54 | 55 | .cta-access { 56 | padding: 1rem; 57 | background-color: hsl(var(--c-red)); 58 | color: hsl(var(--c-white)); 59 | border: none; 60 | border-radius: 6px; 61 | cursor: pointer; 62 | 63 | &:focus-visible { 64 | @extend %focus-visible; 65 | background-color: hsl(var(--c-white)); 66 | color: hsl(var(--c-blue-dark)); 67 | } 68 | 69 | @include breakpoint('md') { 70 | transition: background-color 350ms ease-in-out, 71 | color 350ms ease-in-out; 72 | 73 | &:hover { 74 | background-color: hsl(var(--c-white)); 75 | color: hsl(var(--c-blue-dark)); 76 | } 77 | } 78 | } 79 | 80 | .bookmark-cta { 81 | position: absolute; 82 | display: grid; 83 | place-items: center; 84 | top: 1rem; 85 | right: 1rem; 86 | width: 2rem; 87 | height: 2rem; 88 | background-color: hsl(var(--c-blue-dark) / 0.5); 89 | border: none; 90 | border-radius: 50%; 91 | z-index: 1; 92 | cursor: pointer; 93 | transition: color 350ms ease-in-out, 94 | background-color 350ms ease-in-out; 95 | 96 | &:hover { 97 | color: hsl(var(--c-blue-dark)); 98 | background-color: hsl(var(--c-white)); 99 | } 100 | 101 | &:focus-visible { 102 | outline: none; 103 | color: hsl(var(--c-blue-dark)); 104 | background-color: hsl(var(--c-white)); 105 | } 106 | 107 | &::before { 108 | content: '\f02e'; 109 | font-family: 'Font Awesome 6 Free'; 110 | font-weight: 400; 111 | } 112 | 113 | &[data-bookmarked] { 114 | &::before { 115 | font-weight: 900; 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /src/sass/components/_trending.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .trending { 4 | $root: &; 5 | 6 | &__container { 7 | position: relative; 8 | 9 | &::after { 10 | position: absolute; 11 | display: block; 12 | content: ""; 13 | top: 0; 14 | right: 0; 15 | width: 60px; 16 | height: 100%; 17 | background-image: linear-gradient(to right,rgba(255, 0, 0, 0)0,hsl(var(--c-blue-dark)) 100%); 18 | pointer-events: none; 19 | z-index: 1; 20 | } 21 | } 22 | 23 | &__title { 24 | margin-bottom: 1.5rem; 25 | } 26 | 27 | &__item { 28 | position: relative; 29 | width: 100%; 30 | max-width: 12rem; 31 | } 32 | 33 | &__shimmer { 34 | @extend %shimmer-effect; 35 | min-width: 192px; 36 | max-height: 353px; 37 | aspect-ratio: 1 / 2; 38 | margin-right: 2rem; 39 | } 40 | 41 | &__item-cta { 42 | text-decoration: none; 43 | 44 | @include breakpoint('md') { 45 | &:hover { 46 | #{$root}__item-bg { 47 | background-size: 110%; 48 | } 49 | } 50 | } 51 | 52 | &:focus-visible { 53 | outline: none; 54 | 55 | #{$root}__item-bg { 56 | background-size: 110%; 57 | } 58 | 59 | #{$root}__details-title { 60 | color: hsl(var(--c-red)); 61 | } 62 | } 63 | } 64 | 65 | &__item-bg { 66 | position: relative; 67 | background-image: url("https://image.tmdb.org/t/p/w342/vpnVM9B6NMmQpWeZvzLvDESb2QY.jpg"); 68 | background-size: 100%; 69 | background-position: center; 70 | aspect-ratio: 4/6; 71 | border-radius: 8px; 72 | margin-bottom: 0.5rem; 73 | transition: background-size 350ms ease-in-out; 74 | pointer-events: none; 75 | } 76 | 77 | &__user-score { 78 | position: absolute; 79 | bottom: -0.8rem; 80 | right: -0.5rem; 81 | z-index: 1; 82 | } 83 | 84 | &__details { 85 | display: grid; 86 | gap: 0.1rem; 87 | pointer-events: none; 88 | } 89 | 90 | &__details-desc { 91 | display: flex; 92 | 93 | span { 94 | display: inline-flex; 95 | align-items: center; 96 | 97 | &:not(:last-of-type) { 98 | &::after { 99 | position: relative; 100 | display: inline-block; 101 | content: ''; 102 | width: 3px; 103 | height: 3px; 104 | background-color: hsl(var(--c-white) / 0.75); 105 | margin-inline: 0.5rem; 106 | border-radius: 50%; 107 | } 108 | } 109 | 110 | img { 111 | padding-right: 0.375rem; 112 | } 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /src/database/bookmarkManager.js: -------------------------------------------------------------------------------- 1 | import { getUserBookmarks, updateUserBookmarks } from "."; 2 | 3 | let bookmarks = []; 4 | let subscribers = []; 5 | 6 | /** 7 | * Initializes the bookmarks by fetching them from the database. 8 | * Notify subscribers after initialization. 9 | * @async 10 | */ 11 | async function init() { 12 | bookmarks = await getUserBookmarks(); 13 | } 14 | 15 | /** 16 | * Checks if a bookmark with the given id and type exists in the bookmarks array. 17 | * @param {number} id - The id of the bookmark to check. 18 | * @param {string} type - The type of the bookmark to check. 19 | */ 20 | const isBookmarked = (id, type) => { 21 | return bookmarks.find(item => item.id === id && item.type === type) !== undefined; 22 | } 23 | 24 | /** 25 | * Returns a deep copy of the bookmarks array. 26 | * @returns {Array} - A new array containing the same elements as the bookmarks array. 27 | */ 28 | const getBookmarks = () => { 29 | return [...bookmarks]; 30 | } 31 | 32 | /** 33 | * Toggles the bookmark state for the given bookmark and notify subscribers about bookmarks update. 34 | * If the bookmark exists, it removes it from the current bookmarks array. 35 | * If the bookmark doesn't exists, it adds it to the current bookmarks array. 36 | * @async 37 | * @param {Object} newBookmark - The bookmark object to toggle 38 | */ 39 | async function toggleBookmark(newBookmark) { 40 | // Find the index of the bookmark in the bookmarks array, if it exists 41 | const index = bookmarks.findIndex(item => item.id === newBookmark.id && item.type === newBookmark.type); 42 | 43 | // If the bookmark exists (index !== -1), remove it from the array 44 | // Otherwise, add the new bookmark to the array 45 | if (index !== -1) { 46 | bookmarks.splice(index, 1); 47 | } else { 48 | bookmarks.push(newBookmark); 49 | } 50 | await updateUserBookmarks(bookmarks); 51 | } 52 | 53 | /** 54 | * Subscribes a component to bookmark updates 55 | * @param {Function} callback - Function to call when bookmarks are updated. 56 | */ 57 | const subscribe = (callback, componentName) => { 58 | subscribers.push({name: componentName, callback }); 59 | } 60 | 61 | /** 62 | * Notifies all subscribers about updates, except the calling component. 63 | * @param {string} callingComponentName - The name of the component that triggered the update. 64 | */ 65 | const notifySubscribers = (callingComponentName) => { 66 | const subscribersToNotify = subscribers.filter(subscriber => subscriber.name !== callingComponentName); 67 | subscribersToNotify.forEach(subscriber => subscriber.callback()); 68 | }; 69 | 70 | /** 71 | * Unsubscribes a component from bookmark updates. 72 | * If the callingComponentName is 'all', it unsubscribes all components. 73 | * @param {string} [callingComponentName=all] - The name of the component to unsubscribe. If not provided, it unsubscribes all components. 74 | */ 75 | const unsubscribe = (callingComponentName = 'all') => { 76 | if (callingComponentName === 'all') { 77 | subscribers = []; 78 | } else { 79 | subscribers = subscribers.filter(sub => sub.name !== callingComponentName); 80 | } 81 | } 82 | 83 | export const bookmarkManager = { 84 | init, 85 | toggleBookmark, 86 | isBookmarked, 87 | getBookmarks, 88 | subscribe, 89 | unsubscribe, 90 | notifySubscribers 91 | } -------------------------------------------------------------------------------- /src/auth/registerFormValidation.js: -------------------------------------------------------------------------------- 1 | import { signUp } from "./authentication"; 2 | import { emailValidation, passwordValidation, showFormMessage, showRedirectSuccessMessage, redirectToNewLocation } from "../utilities"; 3 | import { createRecord } from "../database"; 4 | import { getUrlQueryParameters } from "../utilities"; 5 | 6 | // Flags 7 | const loginPageUrl = '/access/login.html'; 8 | const registerFormSelector = '[data-access-signup]'; 9 | const registerContainerSelector = '[data-access-container]'; 10 | const emailQueryParameter = 'email'; 11 | 12 | /** 13 | * Handles form submission, performs validation and initiates sign-up process. 14 | * @param {Event} event - The submit event object. 15 | * @param {HTMLElement} registerFormContainer - The register form container element. 16 | */ 17 | const handleFormSubmit = (event, registerFormContainer) => { 18 | event.preventDefault(); 19 | 20 | const emailInputElement = registerFormContainer.querySelector('input[type="email"]'); 21 | const passwordInputElement = registerFormContainer.querySelectorAll('input[type="password"]'); 22 | const emailValue = emailInputElement.value; 23 | const passwordValue = passwordInputElement[0].value; 24 | const secondPasswordValue = passwordInputElement[1].value; 25 | 26 | let errors = []; 27 | 28 | if (!emailValidation(emailValue)) { 29 | errors.push('Invalid email address'); 30 | } 31 | 32 | if (!passwordValidation(passwordValue) || !passwordValidation(secondPasswordValue)) { 33 | errors.push('Incorrect password (min. 6 characters)'); 34 | } 35 | 36 | if (passwordValue !== secondPasswordValue) { 37 | errors.push('Passwords must be the same'); 38 | } 39 | 40 | if (errors.length > 0) { 41 | showFormMessage(registerFormContainer, errors); 42 | return; 43 | } 44 | 45 | register(emailValue, passwordValue, registerFormContainer); 46 | } 47 | 48 | /** 49 | * Creates a new user using email and password, create database record for that user, manages the sign-up process, and handles the outcome. 50 | * @async 51 | * @param {string} email - User's email address. 52 | * @param {string} password - User's password. 53 | * @param {HTMLElement} registerFormContainer - Container element for the login form. 54 | * @throws {Error} If the sign-up attempt fails, the error is caught and displayed in the form container. 55 | */ 56 | async function register(email, password, registerFormContainer) { 57 | try { 58 | const { user } = await signUp(email, password); 59 | await createRecord(user.id); 60 | 61 | showRedirectSuccessMessage(registerContainerSelector, `Congratulations! You've successfully signed up!`) 62 | redirectToNewLocation(loginPageUrl); 63 | } catch(error) { 64 | // A little workaround since there are no error codes with sign-up supabase auth. 65 | // Returns string after colon in error message (e.g. "AuthApiError: error message"); 66 | const errorMsg = error.message.split(':')[1]; 67 | showFormMessage(registerFormContainer, [`${errorMsg}`]); 68 | } 69 | } 70 | 71 | /** 72 | * Initializes the registration form validation and pre-fills the email field if provided in URL. 73 | */ 74 | const initRegisterFormValidation = () => { 75 | const registerFormElement = document.querySelector(registerFormSelector); 76 | if (!registerFormElement) return; 77 | 78 | const searchParam = getUrlQueryParameters(emailQueryParameter); 79 | if (searchParam) { 80 | const emailInputElement = registerFormElement.querySelector('input[type="email"]'); 81 | emailInputElement.value = searchParam; 82 | } 83 | 84 | registerFormElement.addEventListener('submit', (event) => handleFormSubmit(event, registerFormElement)); 85 | } 86 | 87 | export { initRegisterFormValidation }; -------------------------------------------------------------------------------- /src/sass/components/_trailers.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .trailers { 4 | $root: &; 5 | margin-top: 6px; 6 | 7 | &--small { 8 | @extend .trailers; 9 | margin-top: 3rem; 10 | } 11 | 12 | &__wrapper { 13 | background-repeat: no-repeat; 14 | background-size: cover; 15 | background-position: center; 16 | } 17 | 18 | &__wrapper-gradient { 19 | background: linear-gradient(to right,hsl(var(--bg-blue-navy) / 0.75) 0%,hsl(var(--bg-blue-navy) / 0.75) 100%); 20 | } 21 | 22 | &__container { 23 | padding-block: 9rem; 24 | 25 | &--small { 26 | @extend .trailers__container; 27 | padding-block: 3rem; 28 | padding-inline: 1rem; 29 | } 30 | } 31 | 32 | &__title, &__desc { 33 | text-align: center; 34 | } 35 | 36 | &__title { 37 | margin-bottom: 1rem; 38 | } 39 | 40 | &__desc { 41 | margin-bottom: 3.5rem; 42 | } 43 | 44 | &__list { 45 | padding-top: 0.625rem; 46 | } 47 | 48 | &__item { 49 | max-width: 19.6875rem; 50 | 51 | &:has(:focus-visible) { 52 | #{$root}__play { 53 | width: 100px; 54 | 55 | img { 56 | filter: brightness(0) saturate(100%) invert(10%) sepia(96%) saturate(7262%) hue-rotate(6deg) brightness(97%) contrast(92%); 57 | } 58 | } 59 | 60 | #{$root}__trailer-preview { 61 | transform: scale(1.05); 62 | } 63 | 64 | #{$root}__trailer-title { 65 | color: hsl(var(--c-red)) 66 | } 67 | #{$root}__trailer-title { 68 | color: hsl(var(--c-red)) 69 | } 70 | } 71 | } 72 | 73 | &__shimmer { 74 | @extend %shimmer-effect; 75 | min-width: 19.4rem; 76 | max-height: 14.3125rem; 77 | width: 100%; 78 | margin-right: 1.5rem; 79 | aspect-ratio: 1; 80 | 81 | @include breakpoint('sm') { 82 | min-width: 19.6875rem; 83 | } 84 | } 85 | 86 | &__error { 87 | display: grid; 88 | gap: 0.5rem; 89 | margin: 0 auto; 90 | text-align: center; 91 | 92 | i { 93 | font-size: 3rem; 94 | color: hsl(var(--c-red)); 95 | } 96 | 97 | br { 98 | display: none; 99 | 100 | @include breakpoint('sm') { 101 | display: block; 102 | } 103 | } 104 | } 105 | 106 | &__cta { 107 | text-decoration: none; 108 | 109 | &:focus { 110 | border: none; 111 | outline: none; 112 | } 113 | 114 | &:hover { 115 | #{$root}__play { 116 | width: 100px; 117 | } 118 | 119 | #{$root}__trailer-preview { 120 | transform: scale(1.05); 121 | } 122 | } 123 | } 124 | 125 | &__trailer-preview { 126 | position: relative; 127 | margin-bottom: 1rem; 128 | border-radius: 8px; 129 | overflow: hidden; 130 | margin-bottom: 1rem; 131 | transition: transform 350ms ease-in-out; 132 | 133 | > img { 134 | max-width: 19.6875rem; 135 | aspect-ratio: 16 / 9; 136 | } 137 | } 138 | 139 | &__play { 140 | position: absolute; 141 | top: 50%; 142 | left: 50%; 143 | width: 80px; 144 | transform: translate(-50%, -50%); 145 | transition: width 350ms ease-in-out; 146 | 147 | img { 148 | filter: invert(1); 149 | } 150 | } 151 | 152 | &__trailer-title { 153 | transition: color 350ms ease-in-out; 154 | } 155 | 156 | &__trailer-title, &__trailer-desc { 157 | text-align: center; 158 | } 159 | } -------------------------------------------------------------------------------- /src/components/renderComponent.js: -------------------------------------------------------------------------------- 1 | import { createHtmlElement } from "../utilities"; 2 | import { initBookmarks, getBookmarksHtml } from './bookmarks'; 3 | import { initTrending, getTrendingHtml } from "./trending"; 4 | import { initTrailers, getTrailersHtml } from "./trailers"; 5 | import { initRecommended, getRecommendedHtml } from "./recommended"; 6 | import { initTopRated, getTopRatedHtml } from "./topRated"; 7 | import { getSearchHtml, initSearch } from "./search"; 8 | import { getMediaDetailsHtml, initMediaDetails } from "./mediaDetails"; 9 | import { get404Html, init404 } from "./404"; 10 | import { getProfileHtml, initProfile } from "./profile"; 11 | import { getHeaderHtml, initHeader } from "./header"; 12 | 13 | /** 14 | * Renders a component in the root element. 15 | * @param {string} componentName - The name of the component to render. 16 | * @param {HTMLElement} root - The DOM element where the component will be rendered. 17 | * @param {boolean} [prepend=false] - If false or omitted, component will be appended to the root, else it will be prepended. 18 | */ 19 | const renderComponent = (componentName, root, prepend) => { 20 | // Check if root element exists. 21 | if (!root) return; 22 | 23 | // Checks if the componentName key exists in the componentTemplates object. 24 | if (!componentTemplates[componentName]) { 25 | console.error(`Template for component "${componentName}" not found.`); 26 | return; 27 | }; 28 | 29 | // Gets component HTML string 30 | const componentTemplate = componentTemplates[componentName].html; 31 | // Create a new section element with the specified tag and classes 32 | const componentWrapper = createHtmlElement(componentTemplates[componentName].tag, componentTemplates[componentName].classes, ''); 33 | // Parse the HTML template string into a DocumentFragment 34 | const fragment = document.createRange().createContextualFragment(componentTemplate); 35 | 36 | // Append DocumentFragment to the section element and insert the new view section into the root element. 37 | componentWrapper.appendChild(fragment); 38 | prepend ? root.prepend(componentWrapper) : root.appendChild(componentWrapper); 39 | 40 | // Initializes component 41 | componentTemplates[componentName].init(); 42 | } 43 | 44 | const componentTemplates = { 45 | header: { 46 | html: getHeaderHtml(), 47 | tag: 'header', 48 | classes: ['app__header', 'header'], 49 | init: initHeader 50 | }, 51 | search: { 52 | html: getSearchHtml(), 53 | tag: 'section', 54 | classes: ['app__search', 'search'], 55 | init: initSearch 56 | }, 57 | bookmarks: { 58 | html: getBookmarksHtml(), 59 | tag: 'section', 60 | classes: ['app__media-showcase', 'media-showcase'], 61 | init: initBookmarks 62 | }, 63 | top: { 64 | html: getTopRatedHtml(), 65 | tag: 'section', 66 | classes: ['app__media-showcase', 'media-showcase'], 67 | init: initTopRated 68 | }, 69 | trending: { 70 | html: getTrendingHtml(), 71 | tag: 'section', 72 | classes: ['app__trending', 'trending'], 73 | init: initTrending 74 | }, 75 | trailers: { 76 | html: getTrailersHtml(), 77 | tag: 'section', 78 | classes: ['app__trailers', 'trailers--small'], 79 | init: initTrailers 80 | }, 81 | recommended: { 82 | html: getRecommendedHtml(), 83 | tag: 'section', 84 | classes: ['app__media-showcase', 'media-showcase'], 85 | init: initRecommended 86 | }, 87 | mediaDetails: { 88 | html: getMediaDetailsHtml(), 89 | tag: 'section', 90 | classes: ['media-details'], 91 | init: initMediaDetails 92 | }, 93 | profile: { 94 | html: getProfileHtml(), 95 | tag: 'section', 96 | classes: ['profile'], 97 | init: initProfile 98 | }, 99 | 404: { 100 | html: get404Html(), 101 | tag: 'section', 102 | classes: ['page404'], 103 | init: init404 104 | } 105 | } 106 | 107 | export default renderComponent; -------------------------------------------------------------------------------- /src/auth/authentication.js: -------------------------------------------------------------------------------- 1 | import supabase from "./client"; 2 | import { generateRandomName } from "../utilities"; 3 | 4 | /** 5 | * Asynchronously signs in a user with their email and password using Supabase authentication. 6 | * @async 7 | * @param {string} email - User's email address. 8 | * @param {string} password - User's password. 9 | * @throws {Error} Throws an error with a specific message if the sign-in process fails. 10 | * @returns {Promise} A promise that resolves if the sign-in is successful. 11 | */ 12 | async function signIn(email, password) { 13 | try { 14 | const { error } = await supabase.auth.signInWithPassword({ 15 | email: email, 16 | password: password, 17 | }) 18 | 19 | if (error) { 20 | const errorMessages = { 21 | 400: 'Invalid login credentials.', 22 | 401: 'Unauthorized access. Please check your credentials.', 23 | 429: 'Too many login attempts. Please try again later.', 24 | }; 25 | 26 | const errorMessage = errorMessages[error.status] || `An unexpected error occurred: ${error.message}`; 27 | throw new Error(errorMessage); 28 | } 29 | 30 | } catch(error) { 31 | throw error; 32 | } 33 | } 34 | 35 | /** 36 | * Asynchronously signs up new user with their email and password using Supabase authentication. 37 | * (there are no documented error codes like in sign-in) 38 | * @async 39 | * @param {string} email - User's email address. 40 | * @param {string} password - User's password. 41 | * @throws {Error} Throws an error with a specific message if the sign-up process fails. 42 | * @returns {Promise} A promise that resolves if the sign-up is successful. 43 | */ 44 | async function signUp(email, password) { 45 | try { 46 | const { data, error } = await supabase.auth.signUp({ 47 | email: email, 48 | password: password, 49 | options: { 50 | data: { 51 | name: generateRandomName() 52 | } 53 | } 54 | }) 55 | 56 | if (error) { 57 | throw new Error(error); 58 | } 59 | 60 | return data; 61 | } catch(error) { 62 | throw error; 63 | } 64 | } 65 | 66 | /** 67 | * Removes the logged in user from the browser session and log them out - removing all items from localstorage. 68 | */ 69 | async function signOut() { 70 | await supabase.auth.signOut(); 71 | } 72 | 73 | /** 74 | * Gets the current user details if there is an existing session. This method 75 | * performs a network request to the Supabase Auth server, so the returned 76 | * value is authentic and can be used to base authorization rules on. 77 | * @returns {Object} - Object with current user details 78 | */ 79 | async function getUser() { 80 | const { data: { user } } = await supabase.auth.getUser(); 81 | return user; 82 | } 83 | 84 | /** 85 | * Updates user information in the Supabase authentication system. 86 | * @async 87 | * @param {string} email - The email address of the user to update. 88 | * @param {string} name - The new name of the user. 89 | * @param {string} password - The new password of the user. If an empty string, the password will not be updated. 90 | * @returns {Promise} A promise that resolves to the updated user data if the update is successful. 91 | * @throws {Error} Throws an error if there is an issue updating the user, including any errors returned from Supabase. 92 | */ 93 | async function updateUser(email, name, password) { 94 | const updateData = { 95 | email, 96 | data: { 97 | name 98 | } 99 | } 100 | 101 | // Check if password isn't an empty string 102 | if (password !== '') { 103 | updateData.password = password; 104 | } 105 | 106 | try { 107 | const { data, error } = await supabase.auth.updateUser(updateData); 108 | 109 | if (error) { 110 | throw new Error(`Error updating user: ${error.message}`); 111 | } 112 | 113 | return data; 114 | } catch (error) { 115 | throw error; 116 | } 117 | } 118 | 119 | export { signIn, getUser, signUp, signOut, updateUser }; -------------------------------------------------------------------------------- /src/auth/loginFormValidation.js: -------------------------------------------------------------------------------- 1 | import { signIn, getUser } from "./authentication"; 2 | import { emailValidation, passwordValidation, showFormMessage, showRedirectSuccessMessage, redirectToNewLocation } from "../utilities"; 3 | 4 | // Flags 5 | const appPageUrl = '/app'; 6 | const loginPageUrl = '/access/login.html'; 7 | const loginFormSelector = '[data-access-signin]'; 8 | const loginCtaSelector = '[data-login-cta]'; 9 | const loginContainerSelector = '[data-access-container]'; 10 | const loginTestAccountCtaSelector = '[data-access-test]'; 11 | 12 | /** 13 | * Handles form submission, performs validation and initiates login process. 14 | * @param {Event} event - The submit event object. 15 | * @param {HTMLElement} loginFormContainer - The form container element. 16 | */ 17 | const handleFormSubmit = (event, loginFormContrainer) => { 18 | event.preventDefault(); 19 | 20 | const emailInputElement = loginFormContrainer.querySelector('input[type="email"]'); 21 | const passwordInputElement = loginFormContrainer.querySelector('input[type="password"]'); 22 | const emailValue = emailInputElement.value; 23 | const passwordValue = passwordInputElement.value; 24 | 25 | let errors = []; 26 | 27 | if (!emailValidation(emailValue)) { 28 | errors.push('Invalid email address'); 29 | } 30 | 31 | if (!passwordValidation(passwordValue)) { 32 | errors.push('Incorrect password (min. 6 characters)'); 33 | } 34 | 35 | if (errors.length > 0) { 36 | showFormMessage(loginFormContrainer, errors); 37 | return; 38 | } 39 | 40 | login(emailValue, passwordValue, loginFormContrainer); 41 | } 42 | 43 | /** 44 | * Authenticates a user using email and password, manages the login process, and handles the outcome. 45 | * @async 46 | * @param {string} email - User's email address. 47 | * @param {string} password - User's password. 48 | * @param {HTMLElement} loginFormContainer - Container element for the login form. 49 | * @throws {Error} If the login attempt fails, the error is caught and displayed in the form container. 50 | * @returns {Promise} 51 | */ 52 | async function login(email, password, loginFormContrainer) { 53 | try { 54 | await signIn(email, password); 55 | showRedirectSuccessMessage(loginContainerSelector, 'You are successfully logged in!') 56 | redirectToNewLocation(appPageUrl); 57 | } catch(error) { 58 | showFormMessage(loginFormContrainer, [`${error.message}`]); 59 | } 60 | } 61 | 62 | /** 63 | * Middleware function to redirect user to either app page or login page based if there is an existing session or not. 64 | */ 65 | async function loginMiddleware() { 66 | const user = await getUser(); 67 | if (user) { 68 | window.location.href = appPageUrl; 69 | } else { 70 | window.location.href = loginPageUrl; 71 | } 72 | } 73 | 74 | /** 75 | * Sets up the login button cta event listener to handle login middleware. 76 | */ 77 | const handleLoginButtonClick = () => { 78 | const loginButton = document.querySelector(loginCtaSelector); 79 | 80 | if (!loginButton) return; 81 | 82 | loginButton.addEventListener('click', (event) => { 83 | event.preventDefault(); 84 | loginMiddleware(); 85 | }); 86 | } 87 | 88 | /** 89 | * Sets up the test account button cta event listener to handle login to test account 90 | */ 91 | const handleTestAccountLogin = (event, loginFormElement) => { 92 | event.preventDefault(); 93 | 94 | // Test account credentials. 95 | // No need to hide or secure these credentials as they are meant for public use 96 | const testAccount = { 97 | email: import.meta.env.VITE_TEST_ACCOUNT_LOGIN, 98 | password: import.meta.env.VITE_TEST_ACCOUNT_PASSWORD 99 | } 100 | 101 | login(testAccount.email, testAccount.password, loginFormElement); 102 | } 103 | 104 | /** 105 | * Initializes the login form validation by setting up the submit event listeners. 106 | */ 107 | const initLoginFormValidation = () => { 108 | const loginFormElement = document.querySelector(loginFormSelector); 109 | if (!loginFormElement) return; 110 | 111 | const testAccountCta = document.querySelector(loginTestAccountCtaSelector); 112 | testAccountCta.addEventListener('click', () => handleTestAccountLogin(event, loginFormElement)); 113 | 114 | loginFormElement.addEventListener('submit', (event) => handleFormSubmit(event, loginFormElement)); 115 | } 116 | 117 | export { initLoginFormValidation, handleLoginButtonClick }; -------------------------------------------------------------------------------- /src/sass/utilities/_helpers.scss: -------------------------------------------------------------------------------- 1 | @use './variables' as *; 2 | @use './mixins' as *; 3 | 4 | /* Container */ 5 | .container { 6 | max-width: 95rem; 7 | padding-inline: clamp(1.5rem, 0.548rem + 4.061vw, 2.5rem); 8 | margin-inline: auto; 9 | } 10 | 11 | /* Separator */ 12 | .separator { 13 | width: 100%; 14 | height: 6px; 15 | background-color: hsl(var(--c-blue-grayish) / 0.2); 16 | } 17 | 18 | /* Hide element */ 19 | .hide { 20 | display: none; 21 | visibility: hidden; 22 | } 23 | 24 | /* data-error */ 25 | .data-error { 26 | display: grid; 27 | gap: 0.5rem; 28 | margin: 0 auto; 29 | text-align: center; 30 | 31 | i { 32 | font-size: 3rem; 33 | color: hsl(var(--c-red)); 34 | } 35 | 36 | br { 37 | display: none; 38 | 39 | @include breakpoint('sm') { 40 | display: block; 41 | } 42 | } 43 | } 44 | 45 | /* user-score progress circle */ 46 | .user-score { 47 | --progress: 70; 48 | --progress-percentage: calc(var(--progress) * 1%); // convert to percentage 49 | 50 | display: flex; 51 | justify-content: center; 52 | align-items: center; 53 | width: 45px; 54 | height: 45px; 55 | background: 56 | radial-gradient(closest-side, hsl(var(--c-blue-dark-semi)) 79%, transparent 80% 100%), 57 | conic-gradient(hsl(var(--progress), 100%, 50%) var(--progress-percentage), hsl(var(--c-blue-dark-semi)) 0); 58 | border-radius: 50%; 59 | outline: 4px solid hsl(var(--c-blue-dark)); 60 | outline-offset: -1px; 61 | counter-reset: progress var(--progress); // Workaround to create the text variable directly from the number. 62 | 63 | &::before { 64 | /* Empty string after slash means alternative text for screen readers. 65 | We want it empty since in HTML we're providing that information using tag */ 66 | content: counter(progress) "%"; // fallback 67 | content: counter(progress) "%" / ""; 68 | } 69 | } 70 | 71 | /* Overlay */ 72 | .overlay { 73 | position: fixed; 74 | inset: 0; 75 | background-color: hsl(var(--c-blue-dark) / 0.7); 76 | z-index: 999; 77 | -webkit-backdrop-filter: blur(2px); 78 | backdrop-filter: blur(2px); 79 | visibility: hidden; 80 | opacity: 0; 81 | transition: opacity 350ms ease-in-out; 82 | 83 | &.active { 84 | opacity: 1; 85 | visibility: visible; 86 | } 87 | } 88 | 89 | /* Focus-visible styles */ 90 | %focus-visible { 91 | outline: 2px dashed hsl(var(--c-red)); 92 | outline-offset: 2px; 93 | } 94 | 95 | /* Shimmer effect styles */ 96 | %shimmer-effect { 97 | background: linear-gradient(-45deg, hsl(var(--c-blue-dark-semi)) 40%, hsl(224.6, 28.1%, 27.3%) 50%, hsl(var(--c-blue-dark-semi)) 60%); 98 | background-size: 300%; 99 | background-position-x: 100%; 100 | border-radius: 8px; 101 | animation: shimmer-effect 1.5s infinite linear; 102 | } 103 | 104 | /* Typography */ 105 | /* font-family */ 106 | .ff-primary {font-family: var(--ff-primary);} 107 | 108 | /* font-sizes */ 109 | .fs-800 {font-size: var(--fs-800);} // 32->48px 110 | .fs-700 {font-size: var(--fs-700);} // 40px 111 | .fs-600 {font-size: var(--fs-600);} // 32px 112 | .fs-500 {font-size: var(--fs-500);} // 24px 113 | .fs-450 {font-size: var(--fs-450);} // 18->24px 114 | .fs-400 {font-size: var(--fs-400);} // 18px 115 | .fs-300 {font-size: var(--fs-300);} // 15px 116 | .fs-200 {font-size: var(--fs-200);} // 13px 117 | 118 | /* line-height */ 119 | .lh-600 {line-height: 1.2} 120 | .lh-500 {line-height: normal} 121 | 122 | /* letter-spacing */ 123 | .ls-500 {letter-spacing: -0.0313rem;} 124 | 125 | /* font-weight */ 126 | .fw-700 {font-weight: 700;} 127 | .fw-500 {font-weight: 500;} 128 | .fw-400 {font-weight: 400;} 129 | .fw-300 {font-weight: 300;} 130 | 131 | /* text uppercase */ 132 | .text-uc {text-transform: uppercase;} 133 | 134 | /* text decoration */ 135 | 136 | .text-dc-none {text-decoration: none;} 137 | 138 | /* text colors */ 139 | .text-white {color: hsl(var(--c-white));} 140 | .text-white50 {color: hsl(var(--c-white) / 0.5);} 141 | .text-white75 {color: hsl(var(--c-white) / 0.75);} 142 | .text-red {color: hsl(var(--c-red));} 143 | .text-blue-light {color: hsl(var(--c-blue-light));} 144 | .text-blue-grayish {color: hsl(var(--c-blue-grayish));} 145 | 146 | /* Screen readers only! */ 147 | .sr-only { 148 | border: 0 !important; 149 | clip: rect(1px, 1px, 1px, 1px) !important; 150 | -webkit-clip-path: inset(50%) !important; 151 | clip-path: inset(50%) !important; 152 | height: 1px !important; 153 | margin: -1px !important; 154 | overflow: hidden !important; 155 | padding: 0 !important; 156 | position: absolute !important; 157 | width: 1px !important; 158 | white-space: nowrap !important; 159 | } -------------------------------------------------------------------------------- /src/sass/layout/_access.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .access { 4 | $root: &; 5 | 6 | &__wrapper { 7 | display: grid; 8 | grid-template-rows: min-content; 9 | min-height: 100vh; 10 | 11 | &::before { 12 | position: absolute; 13 | content: ''; 14 | inset: 0; 15 | background-image: url("/assets/home-hero.png"); 16 | background-repeat: no-repeat; 17 | background-size: cover; 18 | background-position-x: 40%; 19 | opacity: 0.3; 20 | } 21 | } 22 | 23 | &__header { 24 | display: flex; 25 | justify-content: center; 26 | margin-block: clamp(3rem, 0.145rem + 12.183vw, 6rem); 27 | width: 100%; 28 | z-index: 1; 29 | } 30 | 31 | &__logo-cta { 32 | &:focus-visible { 33 | @extend %focus-visible; 34 | } 35 | } 36 | 37 | &__logo { 38 | width: 100px; 39 | } 40 | 41 | &__register { 42 | display: grid; 43 | place-items: center; 44 | padding-inline: 1rem; 45 | padding-bottom: 1rem; 46 | 47 | @include breakpoint('md') { 48 | padding-inline: 1.5rem; 49 | padding-bottom: 1.5rem; 50 | } 51 | } 52 | 53 | &__container { 54 | background-color: hsl(var(--c-blue-dark-semi)); 55 | max-width: 30rem; 56 | width: 100%; 57 | border: 1px solid hsl(var(--c-blue-grayish)); 58 | border-radius: 20px; 59 | padding: 2rem; 60 | box-shadow: 0px 0px 111px -23px hsl(var(--c-blue-light) / 0.6); 61 | z-index: 1; 62 | 63 | &.active { 64 | border: 1px solid hsl(var(--c-green)); 65 | } 66 | } 67 | 68 | &__title { 69 | margin-bottom: 2.5rem; 70 | text-align: center; 71 | } 72 | 73 | &__form { 74 | display: grid; 75 | margin-bottom: 1.5rem; 76 | } 77 | 78 | &__input { 79 | background-color: transparent; 80 | border: none; 81 | padding: 1rem; 82 | caret-color: hsl(var(--c-red)); 83 | border-bottom: 1px solid hsl(var(--c-blue-grayish) / 0.5); 84 | 85 | @include breakpoint('md') { 86 | padding: 1.25rem; 87 | } 88 | 89 | &:focus { 90 | outline: none; 91 | } 92 | 93 | &:focus-visible { 94 | border-bottom-color: hsl(var(--c-white)); 95 | 96 | &::placeholder { 97 | opacity: 1; 98 | } 99 | } 100 | 101 | // Autofill styles 102 | &:-webkit-autofill, 103 | &:-webkit-autofill:hover, 104 | &:-webkit-autofill:focus { 105 | -webkit-text-fill-color: hsl(var(--c-white)); 106 | -webkit-box-shadow: 0 0 0px 1000px hsl(var(--c-blue-dark-semi)) inset; 107 | transition: background-color 600000s 0s, color 600000s 0s; 108 | border-bottom: 1px solid hsl(222, 26.3%, 29.8%) !important; 109 | } 110 | 111 | &:-webkit-autofill:focus { 112 | border-bottom: 1px solid hsl(var(--c-white)) !important; 113 | } 114 | } 115 | 116 | &__info { 117 | text-align: center; 118 | 119 | br { 120 | @include breakpoint('sm') { 121 | display: none; 122 | } 123 | } 124 | } 125 | 126 | &__info-link { 127 | text-decoration: none; 128 | 129 | &:focus { 130 | border: none; 131 | outline: none; 132 | } 133 | 134 | &:focus-visible { 135 | text-decoration: underline; 136 | } 137 | 138 | @include breakpoint('md') { 139 | &:hover { 140 | text-decoration: underline; 141 | } 142 | } 143 | } 144 | 145 | &__error { 146 | display: grid; 147 | visibility: hidden; 148 | padding-block: 1.25rem; 149 | gap: 0.25rem; 150 | 151 | i { 152 | padding-right: 0.1rem; 153 | } 154 | 155 | &.has-error { 156 | visibility: visible; 157 | } 158 | } 159 | 160 | &__success-msg { 161 | display: flex; 162 | justify-content: center; 163 | align-items: center; 164 | flex-direction: column; 165 | padding-block: 2rem; 166 | text-align: center; 167 | 168 | .fa-check { 169 | color: hsl(var(--c-green)); 170 | font-size: 3rem; 171 | } 172 | } 173 | } -------------------------------------------------------------------------------- /src/sass/layout/_profile.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .profile { 4 | $root: &; 5 | display: grid; 6 | place-items: start; 7 | min-height: 100vh; 8 | 9 | @include breakpoint('md') { 10 | margin-right: 4rem; 11 | place-items: center; 12 | } 13 | 14 | @include breakpoint('xlg') { 15 | margin-right: 0; 16 | } 17 | 18 | &__container { 19 | display: grid; 20 | gap: 2rem; 21 | width: 100%; 22 | max-width: 50rem; 23 | padding: clamp(1.5rem, 1.024rem + 2.03vw, 2rem); 24 | border: 1px solid hsl(var(--c-blue-grayish)); 25 | border-radius: 20px; 26 | background-color: hsl(var(--c-blue-dark-semi)); 27 | box-shadow: 0px 0px 111px -23px hsl(var(--c-blue-light)/0.6); 28 | z-index: 1; 29 | } 30 | 31 | &__title { 32 | display: flex; 33 | flex-direction: column; 34 | z-index: 1; 35 | 36 | span { 37 | position: relative; 38 | 39 | &.shimmer { 40 | &::before { 41 | @extend %shimmer-effect; 42 | position: absolute; 43 | content: ''; 44 | inset: 0; 45 | } 46 | } 47 | } 48 | } 49 | 50 | &__avatar-container { 51 | display: flex; 52 | flex-direction: column; 53 | align-items: center; 54 | gap: 0.5rem; 55 | 56 | &:has(:focus-within) { 57 | #{$root}__avatar-wrapper { 58 | outline: 3px dashed hsl(var(--c-red)); 59 | } 60 | } 61 | } 62 | 63 | &__avatar-wrapper { 64 | position: relative; 65 | border-radius: 50%; 66 | overflow: hidden; 67 | outline: 2px dashed hsl(var(--c-blue-grayish)); 68 | cursor: pointer; 69 | 70 | 71 | &.shimmer { 72 | &::before { 73 | @extend %shimmer-effect; 74 | position: absolute; 75 | content: ''; 76 | inset: 0; 77 | } 78 | } 79 | } 80 | 81 | &__avatar-image { 82 | width: clamp(80px, 0.836rem + 17.766vw, 150px); 83 | height: clamp(80px, 0.836rem + 17.766vw, 150px); 84 | object-fit: cover; 85 | } 86 | 87 | &__avatar-label-wrapper { 88 | display: flex; 89 | flex-direction: column; 90 | align-items: center; 91 | gap: 0.2rem; 92 | } 93 | 94 | &__avatar-label-desc { 95 | font-style: italic; 96 | 97 | &.has-error { 98 | color: hsl(var(--c-red)); 99 | } 100 | } 101 | 102 | &__form { 103 | display: grid; 104 | gap: 1.5rem; 105 | } 106 | 107 | &__input-wrapper { 108 | position: relative; 109 | display: grid; 110 | position: relative; 111 | gap: 0.2rem; 112 | border: 1px solid hsl(var(--c-blue-grayish)/0.5); 113 | border-radius: 10px; 114 | 115 | &:has(:focus-within) { 116 | border: 1px solid hsl(var(--c-white)); 117 | } 118 | 119 | &.shimmer { 120 | &::after { 121 | @extend %shimmer-effect; 122 | position: absolute; 123 | content: ''; 124 | inset: -10px -1px -1px -1px; 125 | } 126 | } 127 | } 128 | 129 | &__label { 130 | display: flex; 131 | align-items: center; 132 | position: absolute; 133 | top: -0.5rem; 134 | left: 0.46rem; 135 | background-color: hsl(var(--c-blue-dark-semi)); 136 | padding-inline: 0.2rem; 137 | 138 | &::before { 139 | font-family: "Font Awesome 6 Free"; 140 | font-weight: 900; 141 | margin-right: 0.3rem; 142 | } 143 | 144 | &--user { 145 | &::before { 146 | content: "\f007"; 147 | } 148 | } 149 | 150 | &--email { 151 | &::before { 152 | content: "\f0e0"; 153 | } 154 | } 155 | 156 | &--password { 157 | &::before { 158 | content: "\f023"; 159 | } 160 | } 161 | } 162 | 163 | &__input { 164 | background-color: transparent; 165 | border: none; 166 | caret-color: hsl(var(--c-red)); 167 | padding: 0.66rem; 168 | padding-left: 0.6rem; 169 | 170 | &:focus { 171 | outline: none; 172 | } 173 | 174 | &::placeholder { 175 | color: hsl(var(--c-blue-grayish)); 176 | } 177 | } 178 | 179 | &__error { 180 | display: none; 181 | 182 | &.has-error { 183 | display: grid; 184 | gap: 0.25rem; 185 | } 186 | 187 | &.has-success { 188 | display: grid; 189 | gap: 0.25rem; 190 | color: hsl(var(--c-green)); 191 | } 192 | } 193 | } -------------------------------------------------------------------------------- /src/sass/components/_header.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .header { 4 | position: sticky; 5 | top: 0; 6 | $root: &; 7 | z-index: 998; 8 | 9 | @include breakpoint('md') { 10 | display: flex; 11 | position: static; 12 | top: unset; 13 | } 14 | 15 | &__container { 16 | display: flex; 17 | padding: clamp(1.125rem, 0.825rem + 1.279vw, 1.44rem); 18 | background-color: hsl(var(--c-blue-dark-semi)); 19 | 20 | @include breakpoint('md') { 21 | position: sticky; 22 | top: 2rem; 23 | max-height: 40rem; 24 | flex-direction: column; 25 | gap: 5rem; 26 | border-radius: 20px; 27 | } 28 | } 29 | 30 | &__logo-cta { 31 | &:focus-visible { 32 | @extend %focus-visible; 33 | } 34 | } 35 | 36 | &__logo { 37 | width: clamp(40px, 1.905rem + 2.538vw, 50px); 38 | pointer-events: none; 39 | } 40 | 41 | &__nav { 42 | flex: 1; 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | 47 | @include breakpoint('md') { 48 | align-items: unset; 49 | } 50 | } 51 | 52 | &__list { 53 | display: flex; 54 | gap: clamp(1.5rem, 0.548rem + 4.061vw, 2.5rem); 55 | 56 | @include breakpoint('md') { 57 | flex-direction: column; 58 | } 59 | } 60 | 61 | &__item { 62 | text-align: center; 63 | 64 | &:has(:focus-visible) { 65 | @extend %focus-visible; 66 | } 67 | } 68 | 69 | &__link { 70 | transition: color 350ms ease-in-out; 71 | 72 | &:focus-visible { 73 | border: none; 74 | outline: none; 75 | color: hsl(var(--c-red)); 76 | } 77 | 78 | @include breakpoint('md') { 79 | &:hover { 80 | color: hsl(var(--c-red)); 81 | } 82 | } 83 | 84 | &::before { 85 | content: ''; 86 | font-family: "Font Awesome 6 Free"; 87 | font-weight: 900; 88 | } 89 | 90 | &--home { 91 | &::before { 92 | content: "\f015"; 93 | } 94 | } 95 | 96 | &--top { 97 | &::before { 98 | content: "\f06d"; 99 | } 100 | } 101 | 102 | &--bookmarks { 103 | &::before { 104 | content: "\f02e"; 105 | } 106 | } 107 | 108 | &--title { 109 | &::before { 110 | content: "\f522"; 111 | } 112 | } 113 | 114 | &.active { 115 | color: hsl(var(--c-white)); 116 | } 117 | } 118 | 119 | &__profile { 120 | display: grid; 121 | grid-template-columns: 1fr 1fr; 122 | gap: clamp(0.5rem, 0.024rem + 2.03vw, 1rem); 123 | align-items: center; 124 | 125 | @include breakpoint('md') { 126 | grid-template-columns: 1fr; 127 | justify-items: center; 128 | } 129 | } 130 | 131 | &__profile-cta { 132 | position: relative; 133 | 134 | &::before { 135 | position: absolute; 136 | content: ''; 137 | inset: 0; 138 | outline: 2px solid hsl(var(--c-blue-grayish)); 139 | border-radius: 50%; 140 | transition: outline 350ms ease-in-out; 141 | } 142 | 143 | &:focus-visible { 144 | @extend %focus-visible; 145 | outline-offset: 3px; 146 | 147 | &::before { 148 | outline: 2px solid hsl(var(--c-red)); 149 | } 150 | } 151 | 152 | @include breakpoint('md') { 153 | &:hover { 154 | &::before { 155 | outline: 2px solid hsl(var(--c-red)); 156 | } 157 | } 158 | } 159 | 160 | &.active { 161 | &::before { 162 | outline: 2px solid hsl(var(--c-white)); 163 | } 164 | } 165 | } 166 | 167 | &__profile-image-wrapper { 168 | width: clamp(24px, 0.06rem + 5.076vw, 40px); 169 | height: clamp(24px, 0.06rem + 5.076vw, 40px); 170 | border-radius: 50%; 171 | overflow: hidden; 172 | 173 | &:has([src=""]) { 174 | @extend %shimmer-effect; 175 | border-radius: 50%; 176 | } 177 | } 178 | 179 | &__profile-image { 180 | pointer-events: none; 181 | object-fit: cover; 182 | width: 100%; 183 | height: 100%; 184 | 185 | &[src=""] { 186 | opacity: 0; 187 | } 188 | } 189 | 190 | &__logout-cta { 191 | border: none; 192 | background-color: transparent; 193 | cursor: pointer; 194 | transition: color 350ms ease-in-out; 195 | 196 | &::before { 197 | content: "\f08b"; 198 | font-family: "Font Awesome 6 Free"; 199 | font-weight: 900; 200 | } 201 | 202 | &:focus-visible { 203 | @extend %focus-visible; 204 | color: hsl(var(--c-red)); 205 | } 206 | 207 | @include breakpoint('md') { 208 | &:hover { 209 | color: hsl(var(--c-red)); 210 | } 211 | } 212 | } 213 | } -------------------------------------------------------------------------------- /src/components/trending/index.js: -------------------------------------------------------------------------------- 1 | import { fetchTrending } from "../../api/fetchData"; 2 | import { bookmarkManager } from "../../database/bookmarkManager"; 3 | import initSlider from "../../slider"; 4 | import { createHtmlElement, displayDataError, createBookmarkHtmlElement, attachBookmarkEventListener, attachLinkWithParamsEventListener } from "../../utilities"; 5 | 6 | // Selectors 7 | let trendingList; 8 | 9 | // Flags 10 | const posterBackgroundUrl = 'https://image.tmdb.org/t/p/w342/'; 11 | const listSelector = '[data-trending-list]'; 12 | const swiperSelector = '[data-trending-swiper]'; 13 | const componentName = 'trending'; 14 | 15 | /** 16 | * Initializes the trending content section. 17 | */ 18 | async function initTrending() { 19 | trendingList = document.querySelector(listSelector); 20 | 21 | if (!trendingList) return; 22 | 23 | try { 24 | const data = await fetchTrending(); 25 | 26 | displayTrending(data); 27 | attachBookmarkEventListener(trendingList, componentName); 28 | bookmarkManager.subscribe(() => displayTrending(data), componentName); 29 | attachLinkWithParamsEventListener(trendingList); 30 | } catch (error) { 31 | displayDataError(trendingList, 'li'); 32 | } 33 | } 34 | 35 | /** 36 | * Displays a list of trending movies or TVseries in the DOM 37 | * @param {Array} data - An array of data object containing movie or TVseries information 38 | * @param {number} numOfMediaToDisplay - The number of media to display. Defaults to 12. 39 | */ 40 | const displayTrending = (data, numOfMediaToDisplay = 12) => { 41 | // Slices the data array to the specified number of movies to display 42 | // and creates a DocumentFragment to build the list. 43 | const slicedTrendingData = data.slice(0, numOfMediaToDisplay); 44 | const fragment = new DocumentFragment(); 45 | 46 | // Creates a list item for each movie/TVseries with details and appends it to the fragment. 47 | slicedTrendingData.forEach(({id, title, posterPath, backdropPath, type, releaseData, ratingAverage, genreIds}) => { 48 | const releaseYear = releaseData.split('-')[0]; 49 | const mediaType = type === 'movie' ? ` Movie` : ` TV Series` 50 | const userRating = +(ratingAverage * 10).toFixed(); 51 | const userRatingDecimal = userRating / 100; 52 | const stringifyUrlParams = JSON.stringify({id, type}); 53 | 54 | const listItem = createHtmlElement('li', ['trending__item', 'swiper-slide'], ` 55 | 56 | 62 | 69 | 70 | ${createBookmarkHtmlElement({id, title, backdropPath, type, releaseData, genreIds}, ['trending__bookmark-cta', 'bookmark-cta', 'text-white'])} 71 | `); 72 | 73 | fragment.appendChild(listItem); 74 | }) 75 | 76 | // Clears the existing content of trendingList and appends the new fragment. 77 | // Initializes a slider for the trending movies/TVseries and attaches event listener to list container. 78 | trendingList.innerHTML = ''; 79 | trendingList.appendChild(fragment); 80 | initSlider(swiperSelector, {spaceBetween: 32}); 81 | } 82 | 83 | /** 84 | * Returns the HTML for the trending component. 85 | * @returns {string} The HTML for the trending component. 86 | */ 87 | const getTrendingHtml = () => { 88 | return ` 89 | 104 | `; 105 | } 106 | 107 | export { initTrending, getTrendingHtml }; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | MovieDB - Discover movies, TV shows and more 12 | 13 | 14 |
15 |
16 |
17 | 18 | 19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 |

Millions of movies, binge-worthy series and TV shows to discover. Explore now.

27 |

Ready to explore? Enter your email to create your account.

28 | 36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |

Latest Trailers

44 |

Get ready for the ultimate cinematic experience,
and explore latest trailers of the most anticipated movies!

45 |
46 |
    47 |
    48 |
    49 |
    50 |
    51 |
    52 |
    53 |
    54 |
    55 |

    Watch everywhere

    56 |

    Discover unlimited movies and TV shows
    and watch it on your phone, tablet, laptop, and TV.

    57 |
    58 | 59 |
    60 | 61 |
    62 |
    63 |
    64 |
    65 |
    66 |
    67 | 68 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /functions/database/functions.js: -------------------------------------------------------------------------------- 1 | import supabase from "./client" 2 | 3 | /** 4 | * Fetches bookmarks for the current user from the database. 5 | * @async 6 | * @param {string} userUid - Unique user identifier 7 | * @throws {Error} Throws an error if there's an issue fetching the user or the bookmarks. 8 | * @returns {Promise} A promise that resolves to an array of bookmarked items. 9 | */ 10 | async function getUserBookmarks(userUid) { 11 | try { 12 | const { data, error } = await supabase 13 | .from('bookmarks') 14 | .select('bookmarked') 15 | .eq('user_uid', userUid); 16 | 17 | if (error) { 18 | throw new Error(`Database error: ${error.message}`); 19 | } 20 | 21 | return data.map(item => item.bookmarked); 22 | } catch (error) { 23 | throw error; 24 | } 25 | } 26 | 27 | /** 28 | * Fetches movie/TVseries genres list from the database. 29 | * @async 30 | * @throws {Error} Throws an error if there's an issue fetching the user or the bookmarks. 31 | * @returns {Promise} A promise that resolves to an array of genres items. 32 | */ 33 | async function getGenres() { 34 | try { 35 | const { data, error } = await supabase 36 | .from('content') 37 | .select('genres') 38 | .eq('id', 1); 39 | 40 | if (error) { 41 | throw new Error(`Database error: ${error.message}`); 42 | } 43 | 44 | return data.map(item => item.genres); 45 | } catch (error) { 46 | throw error; 47 | } 48 | } 49 | 50 | /** 51 | * Retrieves a random media item representing either movie or tv series id and type. 52 | * @returns {Promise} - A promise that resolves to an object representing the random media item. 53 | * @throws {Error} - If there is an error retrieving the data from the database. 54 | */ 55 | async function getRandomMedia() { 56 | try { 57 | const { data, error } = await supabase 58 | .from('content') 59 | .select('media_pool') 60 | .eq('id', 1); 61 | 62 | if (error) { 63 | throw new Error(`Database error: ${error.message}`); 64 | } 65 | 66 | const mediaPoolArr = data[0].media_pool; 67 | const randomNumber = Math.floor(Math.random() * mediaPoolArr.length); 68 | 69 | return mediaPoolArr[randomNumber]; 70 | } catch (error) { 71 | throw error; 72 | } 73 | } 74 | 75 | /** 76 | * Downloads an avatar URL from the 'user-avatars' storage bucket. 77 | * @async 78 | * @param {string} avatarFileName - The name of the avatar file to download (userUid). 79 | * @returns {Promise} A promise that resolves to the public URL of the avatar if successful, 80 | * or a string path to the default avatar if not found. 81 | * @throws {Error} If there is an error during the download process. 82 | */ 83 | async function downloadAvatar(avatarFileName) { 84 | const { data, error } = await supabase 85 | .storage 86 | .from('user-avatars') 87 | .createSignedUrl(avatarFileName, 60); 88 | 89 | // Check if there was an error or if no data was returned 90 | if (error || !data) { 91 | return '/assets/no-avatar.jpg'; 92 | } 93 | 94 | return data.signedUrl; 95 | } 96 | 97 | /** 98 | * Updates bookmarks for the current user to the database. 99 | * @async 100 | * @param {Array.} updatedBookmarks - Array of updated bookmark objects. 101 | * @param {string} userUid - Unique user identifier 102 | * @throws {Error} Throws an error if there's an issue fetching the user or updating the bookmarks. 103 | */ 104 | async function updateUserBookmarks(updatedBookmarks, userUid) { 105 | try { 106 | const { error } = await supabase 107 | .from('bookmarks') 108 | .update({ bookmarked: updatedBookmarks}) 109 | .eq('user_uid', userUid) 110 | .select('bookmarked'); 111 | 112 | if (error) { 113 | throw new Error(error); 114 | } 115 | 116 | } catch (error) { 117 | throw error; 118 | } 119 | } 120 | 121 | /** 122 | * Create a record within database for user. Performs an INSERT into the table. 123 | * @param {string} userUid - Unique user identifier 124 | */ 125 | async function createRecord(userUid) { 126 | try { 127 | const { error } = await supabase 128 | .from('bookmarks') 129 | .insert({ user_uid: userUid }); 130 | 131 | if (error) { 132 | throw new Error(`Database error: ${error}`); 133 | } 134 | } catch (error) { 135 | throw error; 136 | } 137 | } 138 | 139 | /** 140 | * Uploads an avatar file to the storage under the 'user-avatars' bucket. 141 | * @async 142 | * @param {string} avatarFileName - Unique identifier for the user, used as the name for the uploaded avatar file. 143 | * @param {string} avatarBlob - Blob object with the avatar file. 144 | * @returns {Promise} A promise that resolves to the response data from the upload operation. 145 | * @throws {Error} Throws an error if there is a problem during the upload process, including database errors or issues with the Blob conversion. 146 | */ 147 | async function uploadAvatar(avatarFileName, avatarBlob) { 148 | try { 149 | const { data, error } = await supabase 150 | .storage 151 | .from('user-avatars') 152 | .upload(`${avatarFileName}`, avatarBlob, { 153 | cacheControl: 'no-cache', 154 | upsert: true 155 | }); 156 | 157 | if (error) { 158 | throw new Error(`Database error uploading avatar: ${error}`); 159 | } 160 | 161 | return data; 162 | } catch (error) { 163 | throw error; 164 | } 165 | } 166 | 167 | export { getUserBookmarks, getGenres, getRandomMedia, downloadAvatar, updateUserBookmarks, createRecord, uploadAvatar }; 168 | -------------------------------------------------------------------------------- /src/components/lightbox/index.js: -------------------------------------------------------------------------------- 1 | import { fetchTrailerSrcKey } from '../../api/fetchData'; 2 | import { createHtmlElement, focusTrap } from "../../utilities"; 3 | 4 | // Selectors 5 | const lightboxElement = document.querySelector('[data-lightbox]'); 6 | const overlayElement = document.querySelector('[data-overlay]'); 7 | const bodyElement = document.querySelector('body'); 8 | 9 | // Vars 10 | let lastFocusedEl; 11 | 12 | // Flags 13 | const activeClass = 'active'; 14 | const youtubeUrl = 'https://www.youtube.com/embed/'; 15 | 16 | 17 | /** 18 | * Fetches a movie trailer source key and displays lightbox with trailer video. 19 | * If an error occurs, it displays an appropriate error message in the lightbox. 20 | * @param {number} targetMovieId - The ID of the movie to fetch the trailer for. 21 | * @param {Object} trailerData - Additional data about the trailer or movie to be used in the lightbox. 22 | * @param {HTMLElement} lastFocusedElement - Last focused HTML element node. 23 | */ 24 | async function fetchTrailerAndDisplayLightbox(targetMovieId, trailerData, lastFocusedElement) { 25 | lastFocusedEl = lastFocusedElement; 26 | try { 27 | const trailerSrcKey = await fetchTrailerSrcKey(targetMovieId); 28 | displayLightbox(trailerData, trailerSrcKey); 29 | } catch (error) { 30 | if (error.message.includes('No trailer found')) { 31 | displayLightboxError(trailerData, 'No trailer available for this movie'); 32 | } else { 33 | displayLightboxError(trailerData, 'There was a problem connecting to the server'); 34 | } 35 | } 36 | } 37 | 38 | /** 39 | * Displays lightbox with information about movie trailer 40 | * @param {Object} trailerData - Additional data about the trailer or movie. 41 | * @param {number} srcKey - Trailer video source key 42 | */ 43 | const displayLightbox = (trailerData, srcKey) => { 44 | if (!lightboxElement) return; 45 | 46 | const { title } = trailerData; 47 | 48 | const containerElement = createHtmlElement('div', ['lightbox__container', 'container'], ` 49 | 55 | 58 | Source: YouTube 59 | `); 60 | 61 | showLightbox(containerElement); 62 | } 63 | 64 | /** 65 | * Displays a lightbox with an error message when a trailer cannot be loaded. 66 | * @param {Object} trailerData - Additional data about the trailer or movie. 67 | * @param {string} errorMsg - Error message 68 | */ 69 | const displayLightboxError = (trailerData, errorMsg) => { 70 | if (!lightboxElement) return; 71 | 72 | const { title } = trailerData; 73 | 74 | const containerElement = createHtmlElement('div', ['lightbox__container', 'container'], ` 75 | 81 | 85 | `); 86 | 87 | showLightbox(containerElement); 88 | } 89 | 90 | /** 91 | * Displays the lightbox with the provided content. 92 | * @param {HTMLElement} containerElement - The container element for the lightbox content. 93 | */ 94 | const showLightbox = (containerElement) => { 95 | const fragment = new DocumentFragment(); 96 | 97 | lightboxElement.innerHTML = ''; 98 | fragment.appendChild(containerElement); 99 | lightboxElement.appendChild(fragment); 100 | 101 | lightboxElement.classList.add(activeClass); 102 | overlayElement.classList.add(activeClass); 103 | 104 | // Prevents scrolling on the page by fixing the body element in place. 105 | bodyElement.style.top = `-${window.scrollY}px`; 106 | bodyElement.style.position = 'fixed'; 107 | bodyElement.style.width = '100%'; 108 | 109 | attachListeners(); 110 | focusTrap(lightboxElement); 111 | } 112 | 113 | /** 114 | * Hides the lightbox and restores page to its normal state. 115 | */ 116 | const hideLightbox = () => { 117 | lightboxElement.classList.remove(activeClass); 118 | overlayElement.classList.remove(activeClass); 119 | lastFocusedEl.focus(); 120 | 121 | // Restores scrolling on the page by removing the fixed position from the body element. 122 | const scrollY = bodyElement.style.top; 123 | bodyElement.style.position = ''; 124 | bodyElement.style.top = ''; 125 | window.scrollTo(0, parseInt(scrollY) * -1); 126 | } 127 | /** 128 | * Attaches event listeners to handle closing the lightbox. 129 | */ 130 | const attachListeners = () => { 131 | const closeButton = lightboxElement.querySelector('[data-lightbox-close]'); 132 | 133 | closeButton.addEventListener('click', function closeLightbox() { 134 | hideLightbox(); 135 | closeButton.removeEventListener('click', closeLightbox); 136 | }); 137 | 138 | lightboxElement.addEventListener('keydown', function closeLightboxKey(event) { 139 | const pressedKey = event.key; 140 | 141 | if (pressedKey === 'Escape') { 142 | hideLightbox(); 143 | lightboxElement.removeEventListener('keydown', closeLightboxKey); 144 | } 145 | }) 146 | } 147 | 148 | export default fetchTrailerAndDisplayLightbox; -------------------------------------------------------------------------------- /src/components/header/index.js: -------------------------------------------------------------------------------- 1 | import { getUser, signOut } from '../../auth/authentication'; 2 | import { getRandomMedia, downloadAvatar } from '../../database'; 3 | import { createUrlWithIdAndTypeParams, loadImageFromBlob } from '../../utilities'; 4 | import { router } from '../../app/router'; 5 | 6 | // Elements 7 | let headerContainer; 8 | 9 | // Selectors 10 | const headerContainerSelector = `[data-header-container]`; 11 | const navLinkSelector = (attrValue) => attrValue ? `[data-link="${attrValue}"]` : `[data-link]`; 12 | const logoutCtaSelector = `[data-logout-cta]`; 13 | const avatarImageSelector = `[data-header-avatar]`; 14 | 15 | // Flags 16 | const activeClass = 'active'; 17 | const titlePageAttribute = '/app/title'; 18 | 19 | /** 20 | * Initializes the header component 21 | */ 22 | async function initHeader() { 23 | headerContainer = document.querySelector(headerContainerSelector); 24 | const { id: userId } = await getUser(); 25 | const avatar = await downloadAvatar(userId); 26 | 27 | updateHeaderAvatar(avatar); 28 | setupNavigation(); 29 | } 30 | 31 | /** 32 | * Updates the avatar image in the header based on the provided avatar blob or URL. 33 | * @param {string|Blob} avatar - The avatar to display. Can be: Blob object or string representing the image source path 34 | */ 35 | const updateHeaderAvatar = (avatar) => { 36 | const avatarElement = headerContainer.querySelector(avatarImageSelector); 37 | 38 | if (avatar && avatar instanceof Blob) { 39 | loadImageFromBlob(avatar, avatarElement); 40 | } else { 41 | avatarElement.src = avatar; 42 | } 43 | } 44 | 45 | /** 46 | * Sets up the navigation event listeners and handlers. 47 | */ 48 | const setupNavigation = () => { 49 | headerContainer.addEventListener('click', (event) => { 50 | const eventTarget = event.target; 51 | 52 | // Handle nav link click 53 | if (eventTarget.matches(navLinkSelector())) { 54 | handleNavLinkClick(event) 55 | } 56 | 57 | // Handle logout button click 58 | if (eventTarget.matches(logoutCtaSelector)) { 59 | signOut(); 60 | window.location.href = '/index.html'; 61 | } 62 | }); 63 | }; 64 | 65 | /** 66 | * Updates header active nav link element based on url path by adding active class to it 67 | * and remove it from the rest. 68 | */ 69 | const updateActiveNavElement = () => { 70 | const logoSelector = `[data-header-logo]`; 71 | const navLinks = headerContainer.querySelectorAll(navLinkSelector()); 72 | const activeNavElement = Array.from(navLinks).find(link => !link.matches(logoSelector) && link.matches(navLinkSelector(router.getCurrentURL()))); 73 | 74 | navLinks.forEach(link => link.classList.remove(activeClass)); 75 | activeNavElement ? activeNavElement.classList.add(activeClass) : null; 76 | } 77 | 78 | /** 79 | * Handles the click event on navigation link 80 | * @param {HTMLElement} container - The header container element. 81 | * @param {Event} event - The click event object. 82 | */ 83 | const handleNavLinkClick = (event) => { 84 | event.preventDefault(); 85 | const eventTarget = event.target; 86 | const path = eventTarget.getAttribute('href'); 87 | 88 | // Check if the target element matches the title navlink selector. 89 | if (eventTarget.matches(navLinkSelector(titlePageAttribute))) { 90 | // Navigate to a random media item. 91 | navigateToRandomMedia(); 92 | } else { 93 | // Route to clicked navLink path. 94 | router.navigateTo(path); 95 | } 96 | } 97 | 98 | /** 99 | * Asynchronously retrieves a random media item (movie or tv series), 100 | * creates a URL with the media ID and type, and navigates to the title page using the created URL. 101 | */ 102 | async function navigateToRandomMedia() { 103 | const data = await getRandomMedia(); 104 | router.navigateTo(titlePageAttribute, createUrlWithIdAndTypeParams(data, titlePageAttribute)); 105 | } 106 | 107 | /** 108 | * Returns the HTML for the header component. 109 | * @returns {string} The HTML for the header component. 110 | */ 111 | const getHeaderHtml = () => { 112 | return ` 113 |
    114 | 115 | 116 | 117 | 133 |
    134 | 135 |
    136 | 137 |
    138 |
    139 | 140 |
    141 |
    142 | ` 143 | } 144 | 145 | export { initHeader, getHeaderHtml, updateActiveNavElement, updateHeaderAvatar }; -------------------------------------------------------------------------------- /src/components/recommended/index.js: -------------------------------------------------------------------------------- 1 | import { fetchRecommendations } from "../../api/fetchData"; 2 | import { createHtmlElement, displayDataError, createBookmarkHtmlElement, attachBookmarkEventListener, attachLinkWithParamsEventListener } from "../../utilities"; 3 | import { bookmarkManager } from "../../database/bookmarkManager"; 4 | import noImageImg from '../../assets/no-image.jpg'; 5 | 6 | // Selectors 7 | let recommendedList; 8 | 9 | // Flags 10 | const listSelector = '[data-recommended-list]'; 11 | const smallBackgroundUrl = `https://media.themoviedb.org/t/p/w500/`; 12 | const componentName = 'recommended'; 13 | 14 | /** 15 | * Retrieves a random movie and series ID from the bookmarks. 16 | * If no bookmarks are available, returns default IDs. 17 | * 18 | * @returns {number[]} An array containing a random movie ID and a random tv series ID. 19 | */ 20 | const getRandomMovieAndSeriesId = () => { 21 | const defaultIds = { 22 | movie: 278, // Shawshank Redemption id 23 | series: 1396 // Breaking Bad id 24 | }; 25 | 26 | const bookmarks = bookmarkManager.getBookmarks(); 27 | 28 | // Retrieves a random ID from the bookmarks of the specified type. 29 | // The type of bookmark ('movie' or 'tv'). 30 | const getRandomId = (type) => { 31 | const filteredBookmarks = bookmarks.filter(item => item.type === type); 32 | return filteredBookmarks.length > 0 33 | ? filteredBookmarks[Math.floor(Math.random() * filteredBookmarks.length)]?.id 34 | : defaultIds[type]; 35 | } 36 | 37 | return [getRandomId('movie'), getRandomId('series')]; 38 | } 39 | 40 | /** 41 | * Initializes the recommended content section. 42 | */ 43 | async function initRecommended() { 44 | recommendedList = document.querySelector(listSelector); 45 | 46 | if (!recommendedList) return; 47 | 48 | try { 49 | const data = await fetchRecommendations(...getRandomMovieAndSeriesId()); 50 | displayRecommended(data); 51 | attachBookmarkEventListener(recommendedList, componentName); 52 | attachLinkWithParamsEventListener(recommendedList); 53 | bookmarkManager.subscribe(() => displayRecommended(data), componentName); 54 | } catch (error) { 55 | displayDataError(recommendedList, 'li'); 56 | } 57 | } 58 | 59 | /** 60 | * Displays a list of recommended movies or TVseries in the DOM 61 | * @param {Array} data - An array of data object containing movie or TVseries information 62 | * @param {number} numOfMediaToDisplay - The number of media to display. Defaults to 12. 63 | */ 64 | const displayRecommended = (data, numOfMediaToDisplay = 12) => { 65 | // Creates a DocumentFragment to build the list 66 | const fragment = new DocumentFragment(); 67 | const combinedRecommendations = []; 68 | 69 | // Combine movie and TV series recommendations into a single array. 70 | // This way the container will have movie item and tv series item alternately 71 | for (let i = 0; i < (numOfMediaToDisplay / 2); i++) { 72 | if (data.movies[i]) { 73 | combinedRecommendations.push(data.movies[i]); 74 | }; 75 | if (data.tv_series[i]) { 76 | combinedRecommendations.push(data.tv_series[i]) 77 | }; 78 | } 79 | 80 | // Creates a list item for each movie and TV series with details and appends it to the fragment. 81 | combinedRecommendations.forEach(({id, title, backdropPath, type, releaseData, genreIds}) => { 82 | const releaseYear = releaseData.split('-')[0]; 83 | const mediaType = type === 'movie' ? ` Movie` : ` TV Series` 84 | const stringifyUrlParams = JSON.stringify({id, type}); 85 | 86 | const listItem = createHtmlElement('li', ['media-showcase__item'], ` 87 | 88 |
    89 |

    90 | ${releaseYear} 91 | ${mediaType} 92 |

    93 |

    ${title}

    94 |
    95 |
    96 | ${createBookmarkHtmlElement({id, title, backdropPath, type, releaseData, genreIds}, ['media-showcase__bookmark-cta', 'bookmark-cta', 'text-white'])} 97 | `) 98 | 99 | fragment.appendChild(listItem); 100 | }); 101 | 102 | // Clears the existing content of trendingList and appends the new fragment. 103 | recommendedList.innerHTML = ''; 104 | recommendedList.appendChild(fragment); 105 | } 106 | 107 | /** 108 | * Returns the HTML for the recommended component. 109 | * @returns {string} The HTML for the recommended component. 110 | */ 111 | const getRecommendedHtml = () => { 112 | return ` 113 |
    114 |

    Recommended for you

    115 |
      116 |
    • 117 |
    • 118 |
    • 119 |
    • 120 |
    • 121 |
    • 122 |
    • 123 |
    • 124 |
    • 125 |
    • 126 |
    • 127 |
    • 128 |
    129 |
    130 | `; 131 | } 132 | 133 | export { initRecommended, getRecommendedHtml }; -------------------------------------------------------------------------------- /src/api/fetchData.js: -------------------------------------------------------------------------------- 1 | // Flags 2 | const fetchDataEndpoint = `/.netlify/functions/api`; 3 | const endpoints = { 4 | fetchUpcomingMovies: `${fetchDataEndpoint}?action=fetchUpcomingMovies`, 5 | fetchTrailerSrcKey: `${fetchDataEndpoint}?action=fetchTrailerSrcKey`, 6 | fetchTrending: `${fetchDataEndpoint}?action=fetchTrending`, 7 | fetchRecommendations: `${fetchDataEndpoint}?action=fetchRecommendations`, 8 | fetchTopRated: `${fetchDataEndpoint}?action=fetchTopRated`, 9 | fetchSearchResults: `${fetchDataEndpoint}?action=fetchSearchResults`, 10 | fetchMediaDetails: `${fetchDataEndpoint}?action=fetchMediaDetails` 11 | } 12 | 13 | /** 14 | * Fetches upcoming movies from a Netlify serverless function. 15 | * @async 16 | * @returns {Promise>} A promise that resolves to an array of movie objects. 17 | * @throws {Error} Throws an error if the fetch request fails or if the response is not ok. 18 | */ 19 | async function fetchUpcomingMovies() { 20 | try { 21 | const response = await fetch(endpoints.fetchUpcomingMovies); 22 | const data = await response.json(); 23 | 24 | if (!response.ok) { 25 | throw new Error(data); 26 | } 27 | 28 | return data; 29 | } catch (error) { 30 | throw error; 31 | } 32 | } 33 | 34 | /** 35 | * Fetches trailer source key for given movieId from a Netlify serverless function. 36 | * @async 37 | * @param {string|number} movieId - The movie ID for trailer as a string or number 38 | * @returns {Promise>} A promise that resolves to an array of movie objects. 39 | * @throws {Error} Throws an error if the fetch request fails or if the response is not ok. 40 | */ 41 | async function fetchTrailerSrcKey(movieId) { 42 | try { 43 | const response = await fetch(`${endpoints.fetchTrailerSrcKey}&movieId=${movieId}`); 44 | const data = await response.json(); 45 | 46 | if (!response.ok) { 47 | throw new Error(data); 48 | } 49 | 50 | return data; 51 | } catch (error) { 52 | throw error; 53 | } 54 | } 55 | 56 | /** 57 | * Fetches the latest trending movies and TV series from a Netlify serverless function. 58 | * @async 59 | * @returns {Promise>} A promise that resolves to an array of movies and TV series objects. 60 | * @throws {Error} Throws an error if the fetch request fails or if the response is not ok. 61 | */ 62 | async function fetchTrending() { 63 | try { 64 | const response = await fetch(endpoints.fetchTrending); 65 | const data = await response.json(); 66 | 67 | if (!response.ok) { 68 | throw new Error(data); 69 | } 70 | 71 | return data; 72 | } catch (error) { 73 | throw error; 74 | } 75 | } 76 | 77 | /** 78 | * Fetches movie and TV series recommendations based on the provided IDs from a Netlify serverless function. 79 | * @async 80 | * @param {string|number} movieId - The ID of the movie to fetch recommendations for. 81 | * @param {string|number} seriesId - The ID of the tv series to fetch recommendations for. 82 | * @returns {Promise<{movies: any[], tvSeries: any[]}>} A promise that resolves with an object containing movie and TVseries recommendations. 83 | * @throws {Error} Throws an error if the fetch request fails or if the response is not ok. 84 | */ 85 | async function fetchRecommendations(movieId, seriesId) { 86 | try { 87 | const response = await fetch(`${endpoints.fetchRecommendations}&movieId=${movieId}&seriesId=${seriesId}`); 88 | const data = await response.json(); 89 | 90 | if (!response.ok) { 91 | throw new Error(data); 92 | } 93 | 94 | return data; 95 | } catch (error) { 96 | throw error; 97 | } 98 | } 99 | 100 | /** 101 | * Fetches top-rated movies or TV shows from a Netlify serverless function. 102 | * @async 103 | * @param {('movie'|'tv')} [type='movie'] - The type of content to fetch. Must be either 'movie' or 'tv'. Defaults to 'movie' 104 | * @param {number} [page=1] - The page number of results to fetch. Defaults to 1. 105 | * @returns {Promise>} A promise that resolves to an array of filtered content objects. 106 | * @throws {Error} Throws an error if the fetch request fails or if the response is not ok. 107 | */ 108 | async function fetchTopRated(type = 'movie', page = 1) { 109 | try { 110 | const response = await fetch(`${endpoints.fetchTopRated}&type=${type}&page=${page}`); 111 | const data = await response.json(); 112 | 113 | if (!response.ok) { 114 | throw new Error(data); 115 | } 116 | 117 | return data; 118 | } catch (error) { 119 | throw error; 120 | } 121 | } 122 | 123 | /** 124 | * Fetches search results based on the given search query from a Netlify serverless function. 125 | * @async 126 | * @param {string} searchQuery - The search query to be used for fetching results. 127 | * @returns {Promise>} - A promise that resolves to an array of filtered and formatted search results. 128 | * @throws {Error} Throws an error if the fetch request fails or if the response is not ok. 129 | */ 130 | async function fetchSearchResults(searchQuery) { 131 | try { 132 | const response = await fetch(`${endpoints.fetchSearchResults}&searchQuery=${searchQuery}`); 133 | const data = await response.json(); 134 | 135 | if (!response.ok) { 136 | throw new Error(data); 137 | } 138 | 139 | return data; 140 | } catch (error) { 141 | throw error; 142 | } 143 | } 144 | 145 | /** 146 | * Fetches detailed information about a media item (movie or TV show). 147 | * @async 148 | * @param {string} type - The type of media ('movie' or 'tv'). 149 | * @param {string|number} mediaId - The unique identifier of the media item. 150 | * @returns {Promise} A promise that resolves to an object containing the media details. 151 | * @throws {Error} Throws an error if the API request fails or returns a non-OK status. 152 | */ 153 | async function fetchMediaDetails(type, mediaId) { 154 | try { 155 | const response = await fetch(`${endpoints.fetchMediaDetails}&type=${type}&mediaId=${mediaId}`); 156 | const data = await response.json(); 157 | 158 | if (!response.ok) { 159 | throw new Error(data); 160 | } 161 | 162 | return data; 163 | } catch (error) { 164 | throw error; 165 | } 166 | } 167 | 168 | export { fetchUpcomingMovies, fetchTrailerSrcKey, fetchTrending, fetchRecommendations, fetchTopRated, fetchSearchResults, fetchMediaDetails }; -------------------------------------------------------------------------------- /src/database/index.js: -------------------------------------------------------------------------------- 1 | import { getUser } from "../auth/authentication"; 2 | import { preloadImage } from "../utilities"; 3 | 4 | // Flags 5 | const databaseEndpoint = `/.netlify/functions/database`; 6 | const endpoints = { 7 | getUserBookmarks: `${databaseEndpoint}?action=getUserBookmarks`, 8 | getGenres: `${databaseEndpoint}?action=getGenres`, 9 | getRandomMedia: `${databaseEndpoint}?action=getRandomMedia`, 10 | downloadAvatar: `${databaseEndpoint}?action=downloadAvatar`, 11 | updateUserBookmarks: `${databaseEndpoint}?action=updateUserBookmarks`, 12 | createRecord: `${databaseEndpoint}?action=createRecord`, 13 | uploadAvatar: `${databaseEndpoint}?action=uploadAvatar` 14 | }; 15 | 16 | /** 17 | * Asynchronously retrieves the bookmarks for the current user making request to Netlify function `db-getUserBookmarks` to retrieve the user 18 | * bookmarks 19 | * @async 20 | * @returns {Promise} A promise that resolves to object. 21 | * @throws {Error} Throws an error if the user cannot be retrieved or if the fetch request fails. 22 | */ 23 | async function getUserBookmarks() { 24 | try { 25 | // First await the getUser() function to get the userUid. 26 | const { id: userUid } = await getUser(); 27 | const response = await fetch(`${endpoints.getUserBookmarks}&userUid=${userUid}`); 28 | const data = await response.json(); 29 | 30 | if (!response.ok) { 31 | throw new Error(data.error); 32 | } 33 | 34 | return data[0]; 35 | } catch (error) { 36 | throw error; 37 | } 38 | } 39 | 40 | /** 41 | * Asynchronously retrieves the movie/TVseries genres list making request to Netlify function `db-getGenres` 42 | * @async 43 | * @returns {Promise} A promise that resolves to object. 44 | * @throws {Error} Throws an error if the user cannot be retrieved or if the fetch request fails. 45 | */ 46 | async function getGenres() { 47 | try { 48 | const response = await fetch(endpoints.getGenres); 49 | const data = await response.json(); 50 | 51 | if (!response.ok) { 52 | throw new Error(data.error); 53 | } 54 | 55 | return data[0]; 56 | } catch (error) { 57 | throw error; 58 | } 59 | } 60 | 61 | /** 62 | * Asynchronously retrieves random media object representing either movie or tv series id and type making request to Netlify function `db-getRandomMedia` 63 | * @async 64 | * @returns {Promise} A promise that resolves to object. 65 | * @throws {Error} Throws an error if the user cannot be retrieved or if the fetch request fails. 66 | */ 67 | async function getRandomMedia() { 68 | try { 69 | const response = await fetch(endpoints.getRandomMedia); 70 | const data = await response.json(); 71 | 72 | if (!response.ok) { 73 | throw new Error(data.error); 74 | } 75 | 76 | return data; 77 | } catch (error) { 78 | throw error; 79 | } 80 | } 81 | 82 | /** 83 | * Asynchronously retrieves the user's avatar URL by making a request to the Netlify function `db-getUserAvatar`. 84 | * @async 85 | * @returns {Promise} A Promise that resolves with the avatar URL if successful, 86 | */ 87 | async function downloadAvatar() { 88 | // First await the getUser() function to get the userUid. 89 | const { id: userUid } = await getUser(); 90 | const response = await fetch(`${endpoints.downloadAvatar}&userUid=${userUid}`); 91 | const data = await response.json(); 92 | 93 | if (!response.ok) { 94 | return '/assets/no-avatar.jpg'; 95 | } 96 | // Preload the image 97 | await preloadImage(data); 98 | return data; 99 | } 100 | 101 | /** 102 | * Updates the user bookmarks by sending a POST request to a `db-postUpdateUserBookmarks` Netlify function. 103 | * @async 104 | * @param {Array} updatedBookmarks - Array of updated bookmark objects. 105 | * @returns {Promise} Promise that resolves when the update operation is complete. 106 | * @throws {Error} Throws an error if the request to the server fails or if the user cannot be retrieved. 107 | */ 108 | async function updateUserBookmarks(updatedBookmarks) { 109 | try { 110 | // First await the getUser() function to get the userUid. 111 | const { id: userUid } = await getUser(); 112 | const response = await fetch(endpoints.updateUserBookmarks, { 113 | method: 'POST', 114 | headers: { 115 | 'Content-Type': 'application/json', 116 | }, 117 | body: JSON.stringify({updatedBookmarks, userUid}), 118 | }); 119 | 120 | if (!response.ok) { 121 | throw new Error('Failed to send data'); 122 | } 123 | } catch (error) { 124 | throw error; 125 | } 126 | } 127 | 128 | /** 129 | * Creates a new record in the database by fetching the user ID and sending a POST request 130 | * to the `db-postCreateRecord` Netlify function 131 | * @async 132 | * @param {string} userUid - Unique user identifier 133 | * @returns {Promise} A promise that resolves when the record is created successfully. 134 | * @throws {Error} Throws an error if the fetch request fails or if the response is not ok. 135 | */ 136 | async function createRecord(userUid) { 137 | try { 138 | const response = await fetch(endpoints.createRecord, { 139 | method: 'POST', 140 | headers: { 141 | 'Content-Type': 'application/json', 142 | }, 143 | body: JSON.stringify({userUid}), 144 | }); 145 | 146 | if (!response.ok) { 147 | throw new Error('Failed to send data'); 148 | } 149 | 150 | } catch (error) { 151 | throw error; 152 | } 153 | } 154 | 155 | /** 156 | * Uploads an avatar file by converting it to a Base64 string and sending a POST request to the `db-postUploadAvatar` Netlify function. 157 | * @async 158 | * @param {string} avatarFileName - Unique identifier for the user, used as the name for the uploaded avatar file. 159 | * @param {File} avatarFile - The avatar file object to be uploaded. 160 | * @throws {Error} Throws an error if the fetch request fails or if the response is not ok. 161 | */ 162 | async function uploadAvatar(avatarFileName, avatarFile) { 163 | try { 164 | const formData = new FormData(); 165 | formData.append('avatarFileName', avatarFileName); 166 | formData.append('avatarFile', avatarFile); 167 | 168 | const response = await fetch(endpoints.uploadAvatar, { 169 | method: 'POST', 170 | body: formData, 171 | }); 172 | 173 | if (!response.ok) { 174 | throw new Error('Failed to send data'); 175 | } 176 | } catch (error) { 177 | throw error; 178 | } 179 | } 180 | 181 | export { getUserBookmarks, getGenres, getRandomMedia, downloadAvatar, updateUserBookmarks, createRecord, uploadAvatar }; -------------------------------------------------------------------------------- /src/sass/layout/_media-details.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .media-details { 4 | &__top { 5 | position: relative; 6 | display: grid; 7 | background-color: hsl(var(--c-blue-dark)); 8 | background-repeat: no-repeat; 9 | background-size: cover; 10 | background-position: 50%; 11 | min-height: 500px; 12 | max-height: 800px; 13 | 14 | @include breakpoint('md') { 15 | height: 60vw; 16 | } 17 | 18 | .data-error { 19 | align-content: center; 20 | } 21 | 22 | &.shimmer { 23 | &::before { 24 | @extend %shimmer-effect; 25 | position: absolute; 26 | content: ''; 27 | inset: 0; 28 | } 29 | } 30 | } 31 | 32 | &__top-container { 33 | position: relative; 34 | display: grid; 35 | align-content: center; 36 | justify-self: start; 37 | z-index: 0; 38 | padding: 1rem; 39 | 40 | &::before { 41 | position: absolute; 42 | content: ''; 43 | top: 0; 44 | left: 0; 45 | width: 130%; 46 | z-index: -1; 47 | height: 100%; 48 | background: linear-gradient( 49 | 90deg, 50 | hsl(223, 30%, 9%) 10%, 51 | hsla(223, 30%, 9%, 0.98) 20%, 52 | hsla(223, 30%, 9%, 0.97) 25%, 53 | hsla(223, 30%, 9%, 0.95) 35%, 54 | hsla(223, 30%, 9%, 0.94) 40%, 55 | hsla(223, 30%, 9%, 0.92) 45%, 56 | hsla(223, 30%, 9%, 0.9) 50%, 57 | hsla(223, 30%, 9%, 0.87) 55%, 58 | hsla(223, 30%, 9%, 0.82) 60%, 59 | hsla(223, 30%, 9%, 0.75) 65%, 60 | hsla(223, 30%, 9%, 0.63) 70%, 61 | hsla(223, 30%, 9%, 0.45) 75%, 62 | hsla(223, 30%, 9%, 0.27) 80%, 63 | hsla(223, 30%, 9%, 0.15) 85%, 64 | hsla(223, 30%, 9%, 0.08) 90%, 65 | hsla(223, 30%, 9%, 0.03) 95%, 66 | hsla(223, 30%, 9%, 0) 100% 67 | ); 68 | } 69 | } 70 | 71 | &__title { 72 | display: flex; 73 | flex-direction: column-reverse; 74 | 75 | @include breakpoint('sm') { 76 | flex-direction: row; 77 | gap: 0.5rem; 78 | } 79 | } 80 | 81 | &__info { 82 | display: flex; 83 | flex-wrap: wrap; 84 | 85 | span { 86 | display: inline-flex; 87 | align-items: center; 88 | 89 | &:not(:last-of-type) { 90 | &::after { 91 | position: relative; 92 | display: inline-block; 93 | content: ''; 94 | width: 3px; 95 | height: 3px; 96 | background-color: hsl(var(--c-white) / 0.75); 97 | margin-inline: 0.5rem; 98 | border-radius: 50%; 99 | } 100 | } 101 | 102 | img { 103 | padding-right: 0.375rem; 104 | } 105 | } 106 | } 107 | 108 | &__info-genre { 109 | &::before { 110 | content: '\f630'; 111 | font-family: "Font Awesome 6 Free"; 112 | font-weight: 900; 113 | margin-right: 0.3rem; 114 | } 115 | } 116 | 117 | &__tagline { 118 | padding-top: 1rem; 119 | font-style: italic; 120 | } 121 | 122 | &__desc { 123 | max-width: 60ch; 124 | text-align: justify; 125 | 126 | @include breakpoint('md') { 127 | text-align: left; 128 | } 129 | } 130 | 131 | &__top-details { 132 | display: flex; 133 | gap: 1rem; 134 | margin-top: 2rem; 135 | } 136 | 137 | &__top-score { 138 | position: relative; 139 | display: flex; 140 | align-items: center; 141 | gap: 0.5rem; 142 | background-color: hsl(var(--c-blue-dark-semi)); 143 | border-radius: 40px; 144 | padding-inline: 3.5rem 1rem; 145 | } 146 | 147 | &__user-score { 148 | position: absolute; 149 | top: 50%; 150 | left: 0; 151 | transform: translateY(-50%); 152 | } 153 | 154 | &__cta { 155 | position: relative; 156 | display: flex; 157 | gap: 0.5rem; 158 | padding: 1rem 2.5rem; 159 | background-color: hsl(var(--c-blue-dark-semi)); 160 | border-radius: 40px; 161 | border: none; 162 | outline: none; 163 | cursor: pointer; 164 | 165 | &::before { 166 | content: "\f02e"; 167 | font-family: "Font Awesome 6 Free"; 168 | font-weight: 400; 169 | } 170 | 171 | &:focus-visible { 172 | @extend %focus-visible; 173 | background-color: hsl(var(--c-white)); 174 | color: hsl(var(--c-blue-dark)); 175 | } 176 | 177 | @include breakpoint('md') { 178 | transition: background-color 350ms ease-in-out, 179 | color 350ms ease-in-out; 180 | 181 | &:hover { 182 | background-color: hsl(var(--c-white)); 183 | color: hsl(var(--c-blue-dark)); 184 | } 185 | } 186 | 187 | &[data-bookmarked] { 188 | &::before { 189 | font-weight: 900; 190 | } 191 | } 192 | } 193 | 194 | &__cast { 195 | position: relative; 196 | margin-top: 2rem; 197 | 198 | &::after { 199 | position: absolute; 200 | display: block; 201 | content: ""; 202 | top: 0; 203 | right: 0; 204 | width: 60px; 205 | height: 100%; 206 | background-image: linear-gradient(to right,rgba(255, 0, 0, 0)0,hsl(var(--c-blue-dark)) 100%); 207 | pointer-events: none; 208 | z-index: 1; 209 | } 210 | } 211 | 212 | &__cast-title { 213 | margin-bottom: 1.5rem; 214 | } 215 | 216 | &__cast-item { 217 | position: relative; 218 | width: 100%; 219 | max-width: 12rem; 220 | } 221 | 222 | &__cast-item-bg { 223 | position: relative; 224 | background-image: url("https://media.themoviedb.org/t/p/w300_and_h450_bestv2/jPsLqiYGSofU4s6BjrxnefMfabb.jpg"); 225 | background-size: 100%; 226 | background-position: center; 227 | aspect-ratio: 4/6; 228 | border-radius: 8px; 229 | margin-bottom: 0.5rem; 230 | transition: background-size 350ms ease-in-out; 231 | } 232 | 233 | &__cast-shimmer { 234 | @extend %shimmer-effect; 235 | min-width: 192px; 236 | max-height: 335px; 237 | aspect-ratio: 1 / 2; 238 | margin-right: 2rem; 239 | } 240 | 241 | &__similar { 242 | margin-top: 4rem; 243 | } 244 | } -------------------------------------------------------------------------------- /src/components/trailers/index.js: -------------------------------------------------------------------------------- 1 | import fetchTrailerAndDisplayLightbox from '../lightbox'; 2 | import { fetchUpcomingMovies } from '../../api/fetchData'; 3 | import initSlider from '../../slider'; 4 | import { createHtmlElement, displayDataError } from '../../utilities'; 5 | import backgroundImg from '../../assets/trailersBackground.jpg'; 6 | 7 | // Selectors 8 | let trailersWrapper; 9 | let trailersList; 10 | 11 | // Flags 12 | const wrapperSelector = '[data-trailers-wrapper]'; 13 | const listSelector = '[data-trailers-list]'; 14 | const ctaSelector = '[data-trailers-cta]'; 15 | const swiperSelector = '[data-trailers-swiper]'; 16 | const smallBackgroundUrl = `https://media.themoviedb.org/t/p/w355_and_h200_multi_faces`; 17 | const bigBackgroundUrl = `https://media.themoviedb.org/t/p/original/`; 18 | 19 | /** 20 | * Initializes the trailers content section. 21 | */ 22 | async function initTrailers() { 23 | trailersWrapper = document.querySelector(wrapperSelector); 24 | trailersList = document.querySelector(listSelector); 25 | 26 | if (!trailersWrapper || !trailersList) return; 27 | 28 | try { 29 | const data = await fetchUpcomingMovies(); 30 | displayTrailers(data); 31 | } catch (error) { 32 | trailersWrapper.style.backgroundImage = `url(${backgroundImg}`; 33 | displayDataError(trailersList, 'li'); 34 | } 35 | } 36 | 37 | /** 38 | * Displays a list of movie trailers in the DOM. 39 | * @param {Array} moviesList - An array of movie objects containing trailer information. 40 | * @param {number} [numOfMoviesToDisplay=10] - The number of movies to display. Defaults to 10. 41 | */ 42 | const displayTrailers = (moviesList, numOfMoviesToDisplay = 10) => { 43 | if (!trailersWrapper || !trailersList) return; 44 | 45 | // Slices the moviesList to the specified number of movies to display 46 | // and creates a DocumentFragment to build the list. 47 | const slicedMoviesList = moviesList.slice(0, numOfMoviesToDisplay); 48 | const fragment = new DocumentFragment(); 49 | 50 | // For each movie, creates a list item with movie details and appends it to the fragment. 51 | slicedMoviesList.forEach(({id, title, background, releaseDate}) => { 52 | const listItem = createHtmlElement('li', ['trailers__item', 'swiper-slide'], 53 | ` 54 | 55 |
    56 | ${title} 57 |
    58 | 59 |
    60 |
    61 |

    ${title}

    62 |

    Release date: ${releaseDate}

    63 |
    64 | ` 65 | ); 66 | 67 | fragment.appendChild(listItem); 68 | }) 69 | 70 | // Clears the existing content of trailersList and appends the new fragment. 71 | // Sets the background image of trailersWrapper to the first movie's background. 72 | // Initializes a slider for the trailers and attaches event listeners to the trailer list items. 73 | trailersList.innerHTML = ''; 74 | trailersList.appendChild(fragment); 75 | trailersWrapper.style.backgroundImage = `url('${bigBackgroundUrl}${moviesList[0].background}')`; 76 | initSlider(swiperSelector); 77 | attachTrailersListItemListeners(moviesList, trailersList); 78 | } 79 | 80 | /** 81 | * Handles the hover event for a trailer item. Updates the background image of 82 | * the trailersWrapper based on the hovered item's data attribute. 83 | * @param {HTMLElement} element - The DOM element being hovered over. 84 | */ 85 | const handleTrailersItemHover = (element) => { 86 | let itemBgImage = element.dataset.trailersBg; 87 | trailersWrapper.style.backgroundImage = `url('${bigBackgroundUrl}${itemBgImage}')`; 88 | } 89 | 90 | /** 91 | * Handles the click event for a trailer item. 92 | * @param {Array} - An array of movie objects containing trailer information 93 | * @param {Event} event - The click event object 94 | */ 95 | const handleTrailersItemClick = (moviesList, event) => { 96 | event.preventDefault(); 97 | 98 | // Retrieves the movie ID from the clicked element's data attribute 99 | // Finds the corresponding movie data from the moviesList. 100 | let eventTarget = event.currentTarget; 101 | let targetMovieId = +eventTarget.dataset.trailersId; 102 | let trailerData = moviesList.find(movie => movie.id == targetMovieId); 103 | 104 | // Displays the lightbox with the trailer data and fetched source key. 105 | fetchTrailerAndDisplayLightbox(targetMovieId, trailerData, eventTarget); 106 | } 107 | 108 | /** 109 | * Attaches event listeners to trailer list items. 110 | * @param {Array} moviesList - An array of movie objects containing trailer information. 111 | * @param {HTMLElement} trailersListElement - The DOM element containing the list of trailers. 112 | */ 113 | const attachTrailersListItemListeners = (moviesList, trailersListElement) => { 114 | const sliderItemCtas = trailersListElement.querySelectorAll(ctaSelector); 115 | 116 | sliderItemCtas.forEach(item => { 117 | item.addEventListener('mouseenter', () => { 118 | handleTrailersItemHover(item) 119 | }); 120 | item.addEventListener('focus', () => { 121 | handleTrailersItemHover(item) 122 | }); 123 | item.addEventListener('click', (event) => { 124 | handleTrailersItemClick(moviesList, event); 125 | }); 126 | }) 127 | } 128 | 129 | /** 130 | * Returns the HTML for the trailers component. 131 | * @returns {string} The HTML for the trailers component. 132 | */ 133 | const getTrailersHtml = () => { 134 | return ` 135 |
    136 |
    137 |
    138 |
    139 |

    Latest Trailers

    140 |
    141 |
      142 |
    • 143 |
    • 144 |
    • 145 |
    • 146 |
    • 147 |
    148 |
    149 |
    150 |
    151 |
    152 |
    153 | `; 154 | } 155 | 156 | export { initTrailers, getTrailersHtml }; -------------------------------------------------------------------------------- /src/components/search/index.js: -------------------------------------------------------------------------------- 1 | import { fetchSearchResults } from "../../api/fetchData"; 2 | import { getGenres } from "../../database"; 3 | import { createHtmlElement, createBookmarkHtmlElement, displayDataError, debounce, attachBookmarkEventListener, attachLinkWithParamsEventListener } from "../../utilities"; 4 | import noImageImg from '../../assets/no-image.jpg'; 5 | 6 | // Elements 7 | let appRoot; 8 | let wrapperContainer; 9 | let formElement; 10 | let resultsWrapper; 11 | 12 | // Selectors 13 | const appRootSelector = `[data-app-root]`; 14 | const wrapperSelector = `[data-search-wrapper]`; 15 | const formSelector = `[data-search-form]`; 16 | const resultsWrapperSelector = `[data-search-results-wrapper]`; 17 | 18 | // State 19 | let listOfMediaGenres; 20 | 21 | // Flags 22 | const smallBackgroundUrl = `https://media.themoviedb.org/t/p/w500/`; 23 | const hideClass = 'hide'; 24 | const searchContainerClass = 'search'; 25 | 26 | /** 27 | * Initializes search section 28 | */ 29 | const initSearch = () => { 30 | appRoot = document.querySelector(appRootSelector) 31 | wrapperContainer = document.querySelector(wrapperSelector); 32 | formElement = document.querySelector(formSelector); 33 | resultsWrapper = document.querySelector(resultsWrapperSelector); 34 | 35 | if (!appRoot || !wrapperContainer) return; 36 | 37 | formElement.addEventListener('submit', (event) => event.preventDefault()); 38 | formElement.addEventListener('input', debounce(handleSearchForm, 800)); 39 | attachBookmarkEventListener(resultsWrapper); 40 | attachLinkWithParamsEventListener(resultsWrapper); 41 | } 42 | 43 | /** 44 | * Handles the search form input event. 45 | * @param {Event} event - The input event object. 46 | * @returns {void} 47 | */ 48 | const handleSearchForm = (event) => { 49 | event.preventDefault(); 50 | const searchQuery = event.target.value; 51 | 52 | if (searchQuery === '' || searchQuery.length < 3) { 53 | resultsWrapper.innerHTML = ''; 54 | toggleRootElementsVisibility(false); 55 | return; 56 | }; 57 | 58 | getSearchResults(searchQuery); 59 | } 60 | 61 | /** 62 | * Fetches and displays search results for a given query. 63 | * Displays error message when there is fetch problem. 64 | * @async 65 | * @param {string} searchQuery - The search query string. 66 | */ 67 | async function getSearchResults(searchQuery) { 68 | try { 69 | const data = await fetchSearchResults(searchQuery); 70 | listOfMediaGenres = await getGenres(); 71 | 72 | displaySearchResults(data, searchQuery); 73 | toggleRootElementsVisibility(true); 74 | } catch (error) { 75 | displayDataError(resultsWrapper, 'div'); 76 | } 77 | } 78 | 79 | /** 80 | * Displays search results 81 | * @param {Object[]} data - The array of items to display. 82 | * @param {string} searchQuery - The search query that produced these results. 83 | * @param {number} [numOfMediaToDisplay=12] - The number of items to display. Default is 12. 84 | */ 85 | const displaySearchResults = (data, searchQuery, numOfMediaToDisplay = 12) => { 86 | const slicedData = data.slice(0, numOfMediaToDisplay); 87 | const fragment = new DocumentFragment(); 88 | 89 | // Create the main elements for the search results 90 | const resultsList = createHtmlElement('ul', ['search__list', 'media-showcase__list']); 91 | const resultsTitle = createHtmlElement('h2', ['search__title', 'fs-600', 'fw-300', 'text-white']); 92 | 93 | // Creates a list item for each search result with details and appends it to the fragment. 94 | slicedData.forEach(({ id, title, backdropPath, type, releaseData, ratingAverage, genreIds }) => { 95 | const releaseYear = releaseData ? releaseData.split('-')[0] : 'N/A'; 96 | const userRating = +(ratingAverage).toFixed(2); 97 | const mediaType = type === 'movie' ? ` Movie` : ` TV Series`; 98 | // Create a formatted string of genre names, and filter/remove any undefined values from the resulting array. 99 | const genres = genreIds 100 | .slice(0, 2) 101 | .map(id => listOfMediaGenres.find(genre => genre.id === id)?.name) 102 | .filter(Boolean) 103 | .join(', '); 104 | const stringifyUrlParams = JSON.stringify({id, type}); 105 | 106 | const listItem = createHtmlElement('li', ['media-showcase__item'], ` 107 | 108 |
    109 |

    110 | ${releaseYear} 111 | ${mediaType} 112 | ${genres === '' || undefined ? 'N/A' : genres} 113 |

    114 |

    ${title}

    115 |
    116 |

    117 | ${userRating} 118 |

    119 |
    120 | ${createBookmarkHtmlElement({id, title, backdropPath, type, releaseData, genreIds}, ['media-showcase__bookmark-cta', 'bookmark-cta', 'text-white'])} 121 | `) 122 | fragment.appendChild(listItem); 123 | }) 124 | 125 | // Changes results title content 126 | resultsTitle.innerHTML = `Found ${slicedData.length} ${slicedData.length > 1 ? 'results' : 'result'} for '${searchQuery}'`; 127 | // Clears the existing content of resultsList and resultsWrapper and appends the new elements to them. 128 | resultsList.innerHTML = ''; 129 | resultsWrapper.innerHTML = ''; 130 | resultsList.appendChild(fragment); 131 | resultsWrapper.appendChild(resultsTitle); 132 | if (slicedData.length !== 0) resultsWrapper.appendChild(resultsList); 133 | } 134 | 135 | /** 136 | * Toggles the visibility of direct children of root element, except for those with 'search' class. 137 | * @param {boolean} shouldHide - If true, hides elements. Shows them otherwise. 138 | */ 139 | const toggleRootElementsVisibility = (shouldHide) => { 140 | const rootElements = appRoot.querySelectorAll(`:scope > *:not(.${searchContainerClass})`); 141 | 142 | rootElements.forEach(element => { 143 | if (shouldHide) { 144 | element.classList.add(hideClass); 145 | } else { 146 | element.classList.remove(hideClass); 147 | } 148 | }) 149 | } 150 | 151 | /** 152 | * Returns the HTML for the search component. 153 | * @returns {HTMLElement} The HTML for the search component. 154 | */ 155 | const getSearchHtml = () => { 156 | return ` 157 |
    158 |
    159 | 160 | 161 | 162 |
    163 |
    164 |
    165 | `; 166 | } 167 | 168 | export { initSearch, getSearchHtml }; -------------------------------------------------------------------------------- /src/components/topRated/index.js: -------------------------------------------------------------------------------- 1 | import { fetchTopRated } from '../../api/fetchData'; 2 | import { getGenres } from '../../database'; 3 | import { createHtmlElement, createBookmarkHtmlElement, displayDataError, attachBookmarkEventListener, attachLinkWithParamsEventListener } from '../../utilities'; 4 | import { bookmarkManager } from '../../database/bookmarkManager'; 5 | import noImageImg from '../../assets/no-image.jpg'; 6 | 7 | // Elements 8 | let topRatedContainer; 9 | let topRatedPagination; 10 | let topRatedList; 11 | let listOfMediaGenres; 12 | 13 | // Selectors 14 | const paginationSelector = `[data-top-pagination-wrapper]`; 15 | const containerSelector = `[data-top-wrapper]`; 16 | const listSelector = `[data-top-list]`; 17 | const paginationCtaSelector = `[data-top-page]`; 18 | const selectCtaSelector = `[data-top-select]`; 19 | 20 | // Flags 21 | const smallBackgroundUrl = `https://media.themoviedb.org/t/p/w500/`; 22 | const activeClass = 'active'; 23 | const componentName = 'top'; 24 | 25 | // State 26 | let dataTypeToDisplay = 'movie'; 27 | let activePage = 1; 28 | 29 | /** 30 | * Initializes top rated content section. 31 | */ 32 | async function initTopRated() { 33 | topRatedContainer = document.querySelector(containerSelector) 34 | topRatedList = document.querySelector(listSelector); 35 | topRatedPagination = document.querySelector(paginationSelector); 36 | 37 | if (!topRatedContainer || !topRatedList) return; 38 | 39 | try { 40 | const data = await fetchTopRated(); 41 | listOfMediaGenres = await getGenres(); 42 | 43 | displayTopRated(data); 44 | attachBookmarkEventListener(topRatedList, componentName); 45 | attachEventListeners(topRatedContainer); 46 | attachLinkWithParamsEventListener(topRatedContainer); 47 | bookmarkManager.subscribe(() => displayTopRated(data), componentName); 48 | } catch (error) { 49 | displayDataError(topRatedList, 'li'); 50 | } 51 | } 52 | 53 | /** 54 | * Updates top rated content section. 55 | */ 56 | async function updateTopRated(type, page) { 57 | try { 58 | const data = await fetchTopRated(type, page); 59 | 60 | displayTopRated(data); 61 | } catch (error) { 62 | displayDataError(topRatedList); 63 | } 64 | } 65 | 66 | /** 67 | * Displays a list of top rated movies or TVseries in the DOM. 68 | * @param {Array} data - An array of data object containing movie or TVseries information 69 | */ 70 | const displayTopRated = (data) => { 71 | // Creates a document Fragment to build the list 72 | const fragment = new DocumentFragment(); 73 | 74 | data.forEach(({ id, title, backdropPath, type, releaseData, ratingAverage, genreIds }) => { 75 | const releaseYear = releaseData.split('-')[0]; 76 | const userRating = +(ratingAverage).toFixed(2); 77 | // Create a formatted string of genre names, and filter/remove any undefined values from the resulting array. 78 | const genres = genreIds 79 | .slice(0, 2) 80 | .map(id => listOfMediaGenres.find(genre => genre.id === id)?.name) 81 | .filter(Boolean) 82 | .join(', '); 83 | const stringifyUrlParams = JSON.stringify({id, type}); 84 | 85 | const listItem = createHtmlElement('li', ['media-showcase__item'], ` 86 | 87 |
    88 |

    89 | ${releaseYear} 90 | ${genres} 91 |

    92 |

    ${title}

    93 |
    94 |

    95 | ${userRating} 96 |

    97 |
    98 | ${createBookmarkHtmlElement({id, title, backdropPath, type, releaseData, genreIds}, ['media-showcase__bookmark-cta', 'bookmark-cta', 'text-white'])} 99 | `) 100 | 101 | fragment.appendChild(listItem); 102 | }) 103 | 104 | // Clears the existing content of topRatedList and appends the new fragment. 105 | // Adds paginationButtons within topRatedPagination container. 106 | topRatedList.innerHTML = ''; 107 | topRatedList.appendChild(fragment); 108 | displayPaginationButtons(); 109 | } 110 | 111 | /** 112 | * Displays pagination buttons. 113 | */ 114 | function displayPaginationButtons() { 115 | // Create five pagination buttons since we want to display 5x20 movies/tv series. 116 | const paginationButtons = Array.from(Array(5).keys()).map((_, index) => { 117 | const page = index + 1; 118 | const isActive = page == activePage; 119 | return isActive ? 120 | `` : 121 | ``; 122 | }) 123 | 124 | // Display pagination 125 | topRatedPagination.innerHTML = paginationButtons.join(''); 126 | } 127 | 128 | /** 129 | * Attaches event listeners to a container element for pagination and content type selection. 130 | * @param {HTMLElement} container - The container element to which the event listeners will be attached. 131 | */ 132 | const attachEventListeners = (container) => { 133 | // Add a click event listener to the container element 134 | container.addEventListener('click', (event) => { 135 | const eventTarget = event.target; 136 | 137 | // Pagination: When a pagination cta is clicked, it updates the active page and fetches new data. 138 | if (eventTarget.hasAttribute(paginationCtaSelector) || eventTarget.closest(paginationCtaSelector)) { 139 | const targetData = eventTarget.dataset.topPage; 140 | activePage = targetData; 141 | updateTopRated(dataTypeToDisplay, targetData); 142 | } 143 | 144 | // Content select: When a content select cta is clicked, it updates the content type, 145 | // resets the active page to 1, updates the UI, and fetches new data. 146 | if (eventTarget.hasAttribute(selectCtaSelector) || eventTarget.closest(selectCtaSelector)) { 147 | const targetData = eventTarget.dataset.topSelect; 148 | const siblingElement = eventTarget.previousElementSibling || eventTarget.nextElementSibling; 149 | dataTypeToDisplay = targetData; 150 | activePage = 1; 151 | 152 | siblingElement.classList.remove(activeClass); 153 | eventTarget.classList.add(activeClass); 154 | updateTopRated(targetData, 1); 155 | } 156 | }) 157 | } 158 | 159 | /** 160 | * Returns the HTML for the top rated component. 161 | * @returns {string} The HTML for the top rated component. 162 | */ 163 | const getTopRatedHtml = () => { 164 | return ` 165 |
    166 |
    167 |

    Top rated

    168 |
    169 | 170 | 171 |
    172 |
    173 |
      174 |
    • 175 |
    • 176 |
    • 177 |
    • 178 |
    • 179 |
    • 180 |
    • 181 |
    • 182 |
    • 183 |
    • 184 |
    • 185 |
    • 186 |
    187 | 188 |
    189 | `; 190 | } 191 | 192 | export { initTopRated, getTopRatedHtml }; -------------------------------------------------------------------------------- /src/sass/components/_media-showcase.scss: -------------------------------------------------------------------------------- 1 | @use '../utilities' as *; 2 | 3 | .media-showcase { 4 | $root: &; 5 | margin-top: 3rem; 6 | margin-bottom: 2rem; 7 | 8 | &__container { 9 | &--grid { 10 | @extend .media-showcase__container; 11 | display: grid; 12 | } 13 | } 14 | 15 | &__title-wrapper { 16 | display: flex; 17 | align-items: center; 18 | flex-direction: column; 19 | gap: 1rem; 20 | margin-bottom: 2rem; 21 | 22 | @include breakpoint('sm') { 23 | flex-direction: row; 24 | gap: 0.5rem; 25 | } 26 | } 27 | 28 | &__select { 29 | display: flex; 30 | padding-inline: 0.5rem; 31 | background-color: hsl(var(--c-blue-dark-semi)); 32 | border-radius: 40px; 33 | box-shadow: 0px 0px 18px 0px hsl(var(--c-blue-light)); 34 | margin-left: 1rem; 35 | 36 | &:has(:focus-visible) { 37 | @extend %focus-visible; 38 | } 39 | } 40 | 41 | &__select-cta { 42 | border: none; 43 | background-color: transparent; 44 | padding: 0.5rem 0.5rem; 45 | cursor: pointer; 46 | transition: color 350ms ease-in-out; 47 | 48 | &.active { 49 | color: hsl(var(--c-blue-light)); 50 | } 51 | 52 | &:focus-visible { 53 | color: hsl(var(--c-red)); 54 | } 55 | 56 | &:focus { 57 | border: none; 58 | outline: none; 59 | } 60 | 61 | @include breakpoint('md') { 62 | &:hover { 63 | color: hsl(var(--c-blue-light)); 64 | } 65 | } 66 | } 67 | 68 | &__title { 69 | display: flex; 70 | align-items: center; 71 | gap: 0.5rem; 72 | margin-bottom: 1.5rem; 73 | 74 | &--mg0 { 75 | position: relative; 76 | margin-bottom: 0; 77 | 78 | &::before { 79 | content: "\f06d"; 80 | font-family: "Font Awesome 6 Free"; 81 | font-weight: 900; 82 | color: hsl(var(--c-red)); 83 | } 84 | } 85 | 86 | &--bookmarked { 87 | position: relative; 88 | margin-bottom: 0; 89 | 90 | &::before { 91 | content: "\f02e"; 92 | font-family: "Font Awesome 6 Free"; 93 | font-weight: 900; 94 | color: hsl(var(--c-blue-light)); 95 | } 96 | } 97 | } 98 | 99 | &__list { 100 | display: grid; 101 | grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); 102 | gap: 2rem; 103 | padding-bottom: 1rem; 104 | 105 | @include breakpoint('md') { 106 | padding-right: 1.2rem; 107 | } 108 | 109 | > .data-error { 110 | grid-column: 1 / -1; 111 | padding-top: 2rem; 112 | } 113 | } 114 | 115 | &__item { 116 | position: relative; 117 | width: 100%; 118 | aspect-ratio: 16 / 9; 119 | } 120 | 121 | &__shimmer { 122 | @extend %shimmer-effect; 123 | width: 100%; 124 | aspect-ratio: 16 / 9; 125 | } 126 | 127 | &__item-bookmarks-info { 128 | display: flex; 129 | flex-direction: column; 130 | justify-content: center; 131 | align-items: center; 132 | gap: 0.5rem; 133 | margin-top: 3rem; 134 | grid-column: 1 / -1; 135 | 136 | .image-wrapper { 137 | position: relative; 138 | overflow: hidden; 139 | 140 | &::before { 141 | position: absolute; 142 | content: ""; 143 | width: 100%; 144 | height: 100%; 145 | background-image: radial-gradient(closest-side, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0), hsl(var(--c-blue-dark))); 146 | } 147 | } 148 | 149 | img { 150 | max-width: 350px; 151 | } 152 | 153 | p { 154 | max-width: 42ch; 155 | text-align: center; 156 | margin: 0 auto; 157 | } 158 | } 159 | 160 | &__item-cta { 161 | position: relative; 162 | display: grid; 163 | background-size: 100%; 164 | background-position: center; 165 | padding: 1.5rem; 166 | padding-bottom: 0.5rem; 167 | height: 99%; 168 | text-decoration: none; 169 | border-radius: 8px; 170 | transition: background-size 350ms ease-in-out; 171 | 172 | @include breakpoint('md') { 173 | padding-bottom: 1rem; 174 | 175 | &:hover { 176 | background-size: 110%; 177 | 178 | #{$root}__more { 179 | opacity: 1; 180 | transform: translate(-50%, -50%) scale(1); 181 | } 182 | } 183 | } 184 | 185 | &:focus-visible { 186 | outline: none; 187 | background-size: 110%; 188 | 189 | #{$root}__details-title { 190 | color: hsl(var(--c-red)); 191 | } 192 | } 193 | 194 | &::before { 195 | position: absolute; 196 | display: block; 197 | content: ""; 198 | top: 0; 199 | left: 0; 200 | width: 100%; 201 | height: 100%; 202 | background-image: linear-gradient(180deg, hsl(var(--c-white) / 0) 53%, hsl(var(--c-blue-dark)) 100%); 203 | pointer-events: none; 204 | z-index: 1; 205 | } 206 | } 207 | 208 | &__more { 209 | position: absolute; 210 | display: flex; 211 | align-items: center; 212 | top: 2rem; 213 | left: 2rem; 214 | gap: 0.5rem; 215 | opacity: 0; 216 | transform: translate(-50%, -50%) scale(0.7); 217 | pointer-events: none; 218 | background-color: hsl(var(--c-white) / 0.2); 219 | border-radius: 40px; 220 | padding: 0.3rem; 221 | transition: opacity 350ms ease-in-out, 222 | transform 350ms ease-in-out; 223 | z-index: 1; 224 | 225 | &::before { 226 | content: "\f06e"; 227 | font-family: 'Font Awesome 6 Free'; 228 | font-weight: 400; 229 | font-size: 1.5rem; 230 | } 231 | } 232 | 233 | &__details { 234 | align-self: end; 235 | display: grid; 236 | gap: 0.1rem; 237 | z-index: 1; 238 | } 239 | 240 | &__details-desc { 241 | display: flex; 242 | 243 | span { 244 | display: inline-flex; 245 | align-items: center; 246 | 247 | &:not(:last-of-type) { 248 | &::after { 249 | position: relative; 250 | display: inline-block; 251 | content: ''; 252 | width: 3px; 253 | height: 3px; 254 | background-color: hsl(var(--c-white) / 0.75); 255 | margin-inline: 0.5rem; 256 | border-radius: 50%; 257 | } 258 | } 259 | 260 | img { 261 | padding-right: 0.375rem; 262 | } 263 | } 264 | } 265 | 266 | &__details-genre { 267 | &::before { 268 | content: '\f630'; 269 | font-family: "Font Awesome 6 Free"; 270 | font-weight: 900; 271 | margin-right: 0.3rem; 272 | } 273 | } 274 | 275 | &__details-title { 276 | transition: color 350ms ease-in-out; 277 | } 278 | 279 | &__user-rating { 280 | position: absolute; 281 | display: flex; 282 | align-items: center; 283 | gap: 0.3rem; 284 | top: 1rem; 285 | left: 1rem; 286 | background-color: hsl(var(--c-blue-dark)); 287 | padding: 0.5rem; 288 | border-radius: 50px; 289 | z-index: 1; 290 | 291 | &::before { 292 | content: "\f005"; 293 | font-family: "Font Awesome 6 Free"; 294 | font-weight: 600; 295 | color: hsl(var(--c-gold)); 296 | } 297 | } 298 | 299 | &__pagination { 300 | justify-self: center; 301 | display: flex; 302 | gap: 0.5rem; 303 | margin-block: 1rem; 304 | } 305 | 306 | &__pagination-cta { 307 | position: relative; 308 | display: flex; 309 | justify-content: center; 310 | align-items: center; 311 | width: 25px; 312 | height: 25px; 313 | border: none; 314 | background-color: transparent; 315 | padding: 1rem; 316 | font-size: 1rem; 317 | cursor: pointer; 318 | border-radius: 50%; 319 | transition: background-color 350ms ease-in-out; 320 | 321 | &:focus-visible { 322 | background-color: hsl(var(--c-blue-dark-semi)); 323 | } 324 | 325 | &:focus { 326 | border: none; 327 | outline: none; 328 | } 329 | 330 | @include breakpoint('md') { 331 | &:hover { 332 | background-color: hsl(var(--c-blue-dark-semi)); 333 | } 334 | } 335 | 336 | &.active { 337 | background-color: hsl(var(--c-blue-dark-semi)); 338 | pointer-events: none; 339 | } 340 | } 341 | } -------------------------------------------------------------------------------- /src/components/profile/index.js: -------------------------------------------------------------------------------- 1 | import { emailValidation, generateRandomName, passwordValidation, showFormMessage, loadImageFromBlob } from "../../utilities"; 2 | import { getUser, updateUser } from "../../auth/authentication"; 3 | import { uploadAvatar, downloadAvatar } from "../../database"; 4 | import { updateHeaderAvatar } from "../header"; 5 | 6 | // Elements 7 | let profileWrapper; 8 | let profileNameSpan; 9 | let formElement; 10 | let avatarWrapper; 11 | let imageElement; 12 | let avatarInput; 13 | let avatarLabelElement; 14 | let nameInput; 15 | let emailInput; 16 | let passwordInput; 17 | 18 | // Selectors 19 | const profileSelector = `[data-profile-wrapper]`; 20 | const formSelector = `[data-profile-form]` 21 | const profileNameSpanSelector = `[data-profile-name]`; 22 | const avatarWrapperSelector = `[data-profile-avatar-wrapper]`; 23 | const avatarInputSelector = `[data-profile-avatar-input]`; 24 | const avatarImgSelector = `[data-profile-avatar-image]`; 25 | const avatarLabelSelector = `[data-profile-avatar-label]`; 26 | const nameInputSelector = `input[name="name"]`; 27 | const emailInputSelector = `input[name="email"]`; 28 | const passwordInputSelector = `input[name="password"]`; 29 | 30 | // State 31 | let userUploadedAvatarFile; 32 | let userId; 33 | let userEmail; 34 | let userName; 35 | 36 | // Flags 37 | const maxFileSize = 250; // kilobytes (KB) 38 | const errorClass = 'has-error'; 39 | 40 | /** 41 | * Initializes profile component 42 | */ 43 | async function initProfile() { 44 | profileWrapper = document.querySelector(profileSelector); 45 | profileNameSpan = document.querySelector(profileNameSpanSelector); 46 | formElement = document.querySelector(formSelector); 47 | avatarWrapper = document.querySelector(avatarWrapperSelector); 48 | imageElement = document.querySelector(avatarImgSelector); 49 | avatarInput = document.querySelector(avatarInputSelector); 50 | nameInput = formElement.querySelector(nameInputSelector); 51 | emailInput = formElement.querySelector(emailInputSelector); 52 | passwordInput = formElement.querySelector(passwordInputSelector); 53 | 54 | if (!profileWrapper) return; 55 | 56 | // Get user data and avatar blob object 57 | const { id, email, user_metadata } = await getUser(); 58 | const avatarUrl = await downloadAvatar(id); 59 | 60 | // Display downloaded avatar image 61 | displayAvatar(imageElement, avatarUrl); 62 | 63 | // Update state 64 | userId = id; 65 | userEmail = email; 66 | userName = user_metadata.name; 67 | 68 | // Update appropriate elements with user data 69 | nameInput.value = userName; 70 | emailInput.value = userEmail; 71 | profileNameSpan.innerHTML = userName; 72 | removeShimmer(); 73 | 74 | // Set up event listeners 75 | avatarWrapper.addEventListener('click', () => avatarInput.click()); 76 | avatarInput.addEventListener('change', handleAvatarUpload); 77 | formElement.addEventListener('submit', handleFormSubmit); 78 | } 79 | 80 | /** 81 | * Handles the form submission event. 82 | * @param {Event} event - Event object representing the form submission. 83 | */ 84 | const handleFormSubmit = (event) => { 85 | event.preventDefault(); 86 | let nameValue = nameInput.value; 87 | const emailValue = emailInput.value; 88 | const passwordValue = passwordInput.value; 89 | 90 | // Validate form and return array with error messages if any. 91 | let validationResult = formValidation(userId, emailValue, nameValue, passwordValue); 92 | 93 | // Generate random name when nameValue string is empty 94 | if (nameValue.length === 0) { 95 | nameValue = generateRandomName(); 96 | userName = nameValue; 97 | } 98 | 99 | // Display error message when validationResult returns array of errors 100 | if (validationResult) { 101 | showFormMessage(formElement, validationResult, false); 102 | return; 103 | } 104 | 105 | // Upload avatar to storage if user uploaded file 106 | if (userUploadedAvatarFile) { 107 | uploadAvatar(userId, userUploadedAvatarFile); 108 | updateHeaderAvatar(userUploadedAvatarFile); 109 | avatarLabelElement.innerHTML = `Image cannot be more than 250KB`; 110 | } 111 | 112 | // Update user, update elements and show form message 113 | updateUser(emailValue, nameValue, passwordValue); 114 | nameInput.value = nameValue; 115 | profileNameSpan.innerHTML = nameValue; 116 | passwordInput.value = ''; 117 | showFormMessage(profileWrapper, ['Profile updated successfully!'], true); 118 | } 119 | 120 | /** 121 | * Validates profile form. 122 | * @param {string} uid - Unique identifier for the user account. 123 | * @param {string} email - Email address of the user. 124 | * @param {string} name - Name of the user. 125 | * @param {string} password - Password for the user account. 126 | * @returns {(string[]|null)} An array of error messages if validation fails, or null if validation passes. 127 | */ 128 | const formValidation = (uid, email, name, password) => { 129 | const testAccountUid = `3cc0964a-a9a8-4c6f-bb2c-cd378291a0c5`; 130 | let errors = []; 131 | 132 | // Checks if currently logged user is on test account 133 | if (uid === testAccountUid) { 134 | errors.push('Profile editing is disabled for test account. Sign up for a regular account to access all features.'); 135 | return errors; 136 | } 137 | 138 | // Checks if name is more than 15 characters 139 | if (name.length > 15) { 140 | errors.push('Name is too long. Must be less than 15 characters') 141 | } 142 | 143 | // Checks if email doesn't pass validation 144 | if (!emailValidation(email)) { 145 | errors.push('Invalid email address'); 146 | } 147 | 148 | // Checks if password doesn't pass validation 149 | if (!passwordValidation(password) && password.length > 0) { 150 | errors.push('Incorrect password (min. 6 characters)'); 151 | } 152 | 153 | return errors.length > 0 ? errors : null; 154 | } 155 | 156 | /** 157 | * Handles the avatar upload process, including file validation and image src change. 158 | * @param {Event} event - The change event triggered by the file input. 159 | */ 160 | const handleAvatarUpload = (event) => { 161 | avatarLabelElement = document.querySelector(avatarLabelSelector); 162 | const uploadedFile = event.target.files[0]; 163 | const fileSize = Math.round(uploadedFile.size / 1000); // convert bytes to kilobytes (KB) 164 | const fileName = uploadedFile.name; 165 | 166 | // Check if the selected file is an image 167 | if (!uploadedFile.type.startsWith('image/')) { 168 | avatarLabelElement.innerHTML = `Selected file is not an image`; 169 | avatarLabelElement.classList.add(errorClass); 170 | return; 171 | } 172 | 173 | // Check if the selected file size is less or equal to 250kb 174 | if (fileSize >= maxFileSize) { 175 | avatarLabelElement.innerHTML = `File is too large. Please upload an image under 250 KB.`; 176 | avatarLabelElement.classList.add(errorClass); 177 | return; 178 | } 179 | 180 | loadImageFromBlob(uploadedFile, imageElement); 181 | 182 | avatarLabelElement.innerHTML = `Selected file: ${fileName}`; 183 | avatarLabelElement.classList.remove(errorClass); 184 | userUploadedAvatarFile = uploadedFile; 185 | } 186 | 187 | /** 188 | * Displays an avatar image in an image element. 189 | * @param {HTMLImageElement} imageElement - The image element where the avatar will be displayed. 190 | * @param {Blob|string|null} avatar - The avatar to display. Can be: Blob object or string representing the image source path 191 | */ 192 | const displayAvatar = (imageElement, avatar) => { 193 | imageElement.src = avatar; 194 | } 195 | 196 | /** 197 | * Removes shimmer effect from multiple elements that are displaying some fetched data. 198 | */ 199 | const removeShimmer = () => { 200 | const shimmerClass = 'shimmer'; 201 | avatarWrapper.classList.remove(shimmerClass); 202 | profileNameSpan.classList.remove(shimmerClass); 203 | nameInput.parentElement.classList.remove(shimmerClass); 204 | emailInput.parentElement.classList.remove(shimmerClass); 205 | passwordInput.parentElement.classList.remove(shimmerClass); 206 | } 207 | 208 | /** 209 | * Returns HTML for the profile component. 210 | * @returns {string} HTML for the profile component. 211 | */ 212 | const getProfileHtml = () => { 213 | return ` 214 |
    215 |

    Profile 

    216 |
    217 |
    218 | 219 |
    220 | Preview 221 |
    222 |
    223 | 224 |

    Image cannot be more than 250KB

    225 |
    226 |
    227 |
    228 |
    229 |
    230 | 231 | 232 |
    233 |
    234 | 235 | 236 |
    237 |
    238 | 239 | 240 |
    241 | 242 | 243 |
    244 |
    `; 245 | } 246 | 247 | export { getProfileHtml, initProfile }; -------------------------------------------------------------------------------- /functions/api/functions.js: -------------------------------------------------------------------------------- 1 | // Flags 2 | const apiMovieUrl = 'https://api.themoviedb.org/3/movie/'; 3 | const apiSeriesUrl = 'https://api.themoviedb.org/3/tv/'; 4 | const apiTrendingUrl = 'https://api.themoviedb.org/3/trending/all/week'; 5 | const apiSearchUrl = 'https://api.themoviedb.org/3/search/multi?query='; 6 | 7 | // Fetch options 8 | const options = { 9 | method: 'GET', 10 | headers: { 11 | accept: 'application/json', 12 | Authorization: `Bearer ${process.env.TMBD_API_KEY}` 13 | } 14 | }; 15 | 16 | /** 17 | * Fetches upcoming movies from the API and displays them. 18 | * @async 19 | * @returns {Promise>} A promise that resolves to an array of movie objects. 20 | * @throws {Error} If there's an issue with the API request or response parsing. 21 | */ 22 | async function fetchUpcomingMovies() { 23 | try { 24 | const response = await fetch(`${apiMovieUrl}/upcoming?language=en-US&page=1`, options); 25 | 26 | if (!response.ok) { 27 | throw new Error(`HTTP error! Status: ${response.status}`); 28 | } 29 | 30 | const data = await response.json(); 31 | const moviesList = data.results.map(movie => { 32 | return { 33 | id: movie.id, 34 | title: movie.title, 35 | background: movie.backdrop_path, 36 | releaseDate: movie.release_date 37 | } 38 | }); 39 | 40 | return moviesList; 41 | } catch (error) { 42 | throw error; 43 | } 44 | } 45 | 46 | /** 47 | * Fetches the trailer source key for a given movie ID. 48 | * @async 49 | * @param {number} movieId - The unique identifier of the movie. 50 | * @throws {Error} If there's an issue with the API request, response parsing , or if no trailer is found. 51 | * @returns {Promise} The key for the movie's trailer video. 52 | */ 53 | async function fetchTrailerSrcKey(movieId) { 54 | if (!movieId || typeof movieId !== 'number') { 55 | throw new TypeError('Invalid movieId. Expected a number'); 56 | } 57 | 58 | try { 59 | const response = await fetch(`${apiMovieUrl}${movieId}/videos`, options); 60 | 61 | if (!response.ok) { 62 | throw new Error(`HTTP error! Status: ${response.status}`); 63 | } 64 | 65 | const data = await response.json(); 66 | const trailerInfo = data.results.find(trailer => trailer.type === "Trailer"); 67 | 68 | if (!trailerInfo) { 69 | throw new Error(`No trailer found for movieId: ${movieId}`); 70 | } 71 | 72 | return trailerInfo.key; 73 | } catch (error) { 74 | throw error; 75 | } 76 | } 77 | 78 | /** 79 | * Fetches the latest trending movies and TV series from the API. 80 | * @async 81 | * @throws {Error} If there's an issue with the API request or response parsing. 82 | * @returns {Object} An array of objects containing the trending movies and TV series data. 83 | */ 84 | async function fetchTrending() { 85 | try { 86 | const response = await fetch(apiTrendingUrl, options); 87 | 88 | if (!response.ok) { 89 | throw new Error(`HTTP error! Status: ${response.status}`); 90 | } 91 | 92 | const data = await response.json(); 93 | const filteredData = data.results.filter((item) => { 94 | return item.media_type == 'movie' || item.media_type == 'tv'; 95 | }).map((item) => { 96 | return { 97 | id: item.id, 98 | title: item.title || item.name, 99 | posterPath: item.poster_path, 100 | backdropPath: item.backdrop_path, 101 | type: item.media_type, 102 | releaseData: item.release_date || item.first_air_date, 103 | ratingAverage: item.vote_average, 104 | genreIds: item.genre_ids 105 | } 106 | }) 107 | 108 | return filteredData; 109 | } catch (error) { 110 | throw error; 111 | } 112 | } 113 | 114 | /** 115 | * Fetches movie and TV series recommendations based on the provided IDs. 116 | * @async 117 | * @param {number|string} movieId - The ID of the movie to fetch recommendations for. 118 | * @param {number|string} seriesId - The ID of the TV series to fetch recommendations for. 119 | * @throws {Error} If there's an issue with the API request or response parsing. 120 | * @returns {Promise<{movies: any[], tvSeries: any[]}>} A promise that resolves with an object containing movie and TVseries recommendations. 121 | */ 122 | async function fetchRecommendations(movieId, seriesId) { 123 | const urls = [ 124 | `${apiMovieUrl}${movieId}/recommendations`, 125 | `${apiSeriesUrl}${seriesId}/recommendations` 126 | ]; 127 | 128 | try { 129 | // Takes an iterable of promises and returns a single Promise. 130 | const responses = await Promise.all(urls.map(url => fetch(url, options))); 131 | 132 | responses.forEach(response => { 133 | if (!response.ok) { 134 | throw new Error(`HTTP Error! Status: ${response.status}`) 135 | } 136 | }) 137 | 138 | const data = await Promise.all(responses.map(res => res.json())); 139 | 140 | data.forEach(item => { 141 | if (Array.isArray(item.results) && !item.results.length) { 142 | throw new Error(`Data error: Data array is empty!`); 143 | } 144 | }) 145 | 146 | return { 147 | movies: data[0].results.map(item => { 148 | return { 149 | id: item.id, 150 | title: item.title, 151 | backdropPath: item.backdrop_path, 152 | type: item.media_type, 153 | releaseData: item.release_date, 154 | genreIds: item.genre_ids 155 | } 156 | }), 157 | tv_series: data[1].results.map(item => { 158 | return { 159 | id: item.id, 160 | title: item.name, 161 | backdropPath: item.backdrop_path, 162 | type: item.media_type, 163 | releaseData: item.first_air_date, 164 | genreIds: item.genre_ids 165 | } 166 | }) 167 | } 168 | } catch (error) { 169 | throw error; 170 | } 171 | } 172 | 173 | /** 174 | * Fetches top-rated movies or TV shows from the API. 175 | * @async 176 | * @param {('movie'|'tv')} type - The type of content to fetch. Must be either 'movie' or 'tv'. 177 | * @param {number} page - The page number of results to fetch. 178 | * @returns {Promise>} A promise that resolves to an array of filtered content objects. 179 | * @throws {Error} Throws an error if the HTTP request fails or if there's an issue with the fetch operation. 180 | */ 181 | async function fetchTopRated(type = 'movie', page = 1) { 182 | const url = `https://api.themoviedb.org/3/${type}/top_rated?language=en-US&page=${page}`; 183 | 184 | try { 185 | const response = await fetch(url, options); 186 | 187 | if (!response.ok) { 188 | throw new Error(`HTTP Error! Status: ${response.status}`); 189 | } 190 | 191 | const data = await response.json(); 192 | const filteredData = data.results.map((item) => { 193 | return { 194 | id: item.id, 195 | title: item.title || item.name, 196 | backdropPath: item.backdrop_path, 197 | type: type, 198 | releaseData: item.release_date || item.first_air_date, 199 | ratingAverage: item.vote_average, 200 | genreIds: item.genre_ids 201 | } 202 | }) 203 | 204 | return filteredData; 205 | } catch (error) { 206 | throw error; 207 | } 208 | } 209 | 210 | /** 211 | * Fetches search results from an API based on the given search query. 212 | * @async 213 | * @param {string} searchQuery - The search query to be used for fetching results. 214 | * @returns {Promise>} - A promise that resolves to an array of filtered and formatted search results. 215 | * @throws {Error} Throws an error if the API request fails or if there's an HTTP error. 216 | */ 217 | async function fetchSearchResults(searchQuery) { 218 | const url = `${apiSearchUrl}${searchQuery}&include_adult=false&language=en-US&page=1` 219 | 220 | try { 221 | const response = await fetch(url, options); 222 | 223 | if (!response.ok) { 224 | throw new Error(`HTTP Error! Status: ${response.status}`); 225 | } 226 | 227 | const data = await response.json(); 228 | const filteredData = data.results 229 | .filter(item => { 230 | return item.media_type !== 'person' && item.backdrop_path !== null; 231 | }) 232 | .sort((a, b) => { 233 | return b.popularity - a.popularity; 234 | }) 235 | .map((item) => { 236 | return { 237 | id: item.id, 238 | title: item.title || item.name, 239 | backdropPath: item.backdrop_path, 240 | type: item.media_type, 241 | releaseData: item.release_date || item.first_air_date, 242 | ratingAverage: item.vote_average, 243 | genreIds: item.genre_ids 244 | } 245 | }); 246 | 247 | return filteredData; 248 | } catch (error) { 249 | throw error; 250 | } 251 | } 252 | 253 | /** 254 | * Fetches detailed information about a media item (movie or TV show). 255 | * @async 256 | * @param {string} type - The type of media ('movie' or 'tv'). 257 | * @param {string|number} mediaId - The unique identifier of the media item. 258 | * @returns {Promise} A promise that resolves to an object containing the media details. 259 | * @throws {Error} Throws an error if the API request fails or returns a non-OK status. 260 | */ 261 | async function fetchMediaDetails(type, mediaId) { 262 | const url = `https://api.themoviedb.org/3/${type}/${mediaId}?append_to_response=credits,recommendations`; 263 | 264 | try { 265 | const response = await fetch(url, options); 266 | 267 | if (!response.ok) { 268 | throw new Error(`HTTP Error! Status: ${response.status}`); 269 | } 270 | 271 | const data = await response.json(); 272 | 273 | return { 274 | id: data.id, 275 | title: data.title || data.name, 276 | backdropPath: data.backdrop_path, 277 | type: type, 278 | releaseData: data.release_date || data.first_air_date, 279 | runTime: data.runtime || data.number_of_seasons, 280 | ratingAverage: data.vote_average, 281 | genreIds: data.genres, 282 | tagline: data.tagline, 283 | cast: data.credits.cast, 284 | recommendations: data.recommendations.results, 285 | overview: data.overview 286 | } 287 | } catch (error) { 288 | throw error; 289 | } 290 | } 291 | 292 | export { fetchUpcomingMovies, fetchTrailerSrcKey, fetchTrending, fetchRecommendations, fetchTopRated, fetchSearchResults, fetchMediaDetails }; -------------------------------------------------------------------------------- /src/components/bookmarks/index.js: -------------------------------------------------------------------------------- 1 | import { bookmarkManager } from "../../database/bookmarkManager"; 2 | import { getGenres } from "../../database"; 3 | import { createHtmlElement, createBookmarkHtmlElement, displayDataError, attachBookmarkEventListener, attachLinkWithParamsEventListener } from "../../utilities"; 4 | import noImageImg from '../../assets/no-image.jpg'; 5 | import noBookmarkImg from '../../assets/no-bookmark.jpg'; 6 | 7 | // Selectors 8 | const containerSelector = `[data-bookmarks-wrapper]`; 9 | const paginationSelector = `[data-bookmarks-pagination-wrapper]`; 10 | const listSelector = `[data-bookmarks-list]`; 11 | const paginationCtaSelector = `[data-bookmarks-page]`; 12 | const selectSelector = `[data-bookmarks-select]`; 13 | const bookmarkCtaAttribute = `data-bookmark-cta`; 14 | 15 | // Elements 16 | let bookmarksContainer; 17 | let bookmarksPagination; 18 | let bookmarksList; 19 | let listOfMediaGenres; 20 | 21 | // Flags 22 | const smallBackgroundUrl = `https://media.themoviedb.org/t/p/w500/`; 23 | const activeClass = 'active'; 24 | const componentName = 'bookmarks'; 25 | 26 | // State 27 | let dataTypeToDisplay = 'movie'; 28 | let activePage = 0; 29 | 30 | /** 31 | * Initializes bookmarked content section. 32 | */ 33 | async function initBookmarks() { 34 | bookmarksContainer = document.querySelector(containerSelector); 35 | bookmarksPagination = document.querySelector(paginationSelector); 36 | bookmarksList = document.querySelector(listSelector); 37 | 38 | if (!bookmarksContainer || !bookmarksList) return; 39 | 40 | try { 41 | listOfMediaGenres = await getGenres(); 42 | 43 | updateBookmarks(); 44 | attachBookmarkEventListener(bookmarksList, componentName); 45 | attachEventListeners(bookmarksContainer); 46 | attachLinkWithParamsEventListener(bookmarksList); 47 | bookmarkManager.subscribe(updateBookmarks, componentName); 48 | } catch (error) { 49 | displayDataError(bookmarksList, 'li'); 50 | } 51 | } 52 | 53 | /** 54 | * Updates and displays bookmarks based on the current page and media type. 55 | * @param {number} [numOfMediaToDisplay=12] - The number of media items to display per page. 56 | */ 57 | function updateBookmarks(numOfMediaToDisplay = 12) { 58 | const data = bookmarkManager.getBookmarks(); 59 | // Filter data based on dataTypeToDisplay. Returns either bookmarked movies or tv series. 60 | const filteredData = data.filter(item => item.type === dataTypeToDisplay); 61 | // Calculate the total number of pages 62 | const pagesToDisplay = Math.ceil((filteredData.length / numOfMediaToDisplay)); 63 | // Adjust activePage if it exceeds the available pages 64 | activePage = Math.max(0, Math.min(activePage, pagesToDisplay - 1)); 65 | 66 | // Calculate the slice of data to display on the current page 67 | const currentPageData = filteredData.slice( 68 | parseInt(activePage) * numOfMediaToDisplay, 69 | (parseInt(activePage) + 1) * numOfMediaToDisplay 70 | ); 71 | 72 | displayBookmarks(currentPageData, pagesToDisplay); 73 | } 74 | 75 | /** 76 | * Displays bookmarks and pagination based on the provided data. 77 | * @param {Array} data - An array of bookmark objects to display. 78 | * @param {number} pagesToDisplay - The total number of pages to display in pagination. 79 | */ 80 | const displayBookmarks = (data, pagesToDisplay) => { 81 | // Creates a document Fragment to build the list. 82 | const fragment = new DocumentFragment(); 83 | 84 | data.forEach(({ id, title, backdropPath, type, releaseData, genreIds }) => { 85 | const releaseYear = releaseData.split('-')[0]; 86 | // Create formated string of genre names, filter/remove any undefined values from the resulting array. 87 | const genres = genreIds 88 | .slice(0, 2) 89 | .map(id => listOfMediaGenres.find(genre => genre.id === id)?.name) 90 | .filter(Boolean) 91 | .join(', '); 92 | const stringifyUrlParams = JSON.stringify({id, type}); 93 | 94 | const listItem = createHtmlElement('li', ['media-showcase__item'], ` 95 | 96 |
    97 |

    98 | ${releaseYear} 99 | ${genres} 100 |

    101 |

    ${title}

    102 |
    103 |
    104 | ${createBookmarkHtmlElement({id, title, backdropPath, type, releaseData, genreIds}, ['media-showcase__bookmark-cta', 'bookmark-cta', 'text-white'])} 105 | `); 106 | 107 | fragment.appendChild(listItem); 108 | }) 109 | 110 | // If there is no bookmarked items in array display info message 111 | if (data.length == 0) { 112 | renderInfoMessage(fragment); 113 | } 114 | 115 | bookmarksList.innerHTML = ''; 116 | bookmarksList.appendChild(fragment); 117 | displayPaginationButtons(pagesToDisplay); 118 | } 119 | 120 | /** 121 | * Renders info message when there are no bookmarks to display. 122 | * @param {DocumentFragment} fragment - The document fragment to append the created list item to. 123 | * @returns {DocumentFragment} The document fragment with the appended list item. 124 | */ 125 | const renderInfoMessage = (fragment) => { 126 | const contentTypeText = dataTypeToDisplay === 'movie' ? 'movies' : 'TV series'; 127 | const listItem = createHtmlElement('li', ['media-showcase__item-bookmarks-info'], ` 128 |
    129 | 130 |
    131 |

    Add bookmarks!

    132 |

    Don't let the Man in Black's neuralyzer get the best of you! Bookmark your favourite ${contentTypeText} so you can recall them in an instant!

    133 | `); 134 | 135 | return fragment.appendChild(listItem); 136 | } 137 | 138 | /** 139 | * Displays or hides pagination buttons based on the number of pages. 140 | * @param {number} pagesToDisplay - The total number of pages to be displayed. 141 | */ 142 | function displayPaginationButtons(pagesToDisplay) { 143 | if (pagesToDisplay > 1) { 144 | const paginationButtons = Array.from(Array(pagesToDisplay).keys()).map((item, index) => { 145 | // Check if the current button corresponds to the active page 146 | const isActive = index == activePage; 147 | // Calculate the page number (1-based index for display) 148 | const pageIndex = index + 1; 149 | return isActive ? 150 | `` : 151 | ``; 152 | }) 153 | 154 | // Display pagination 155 | bookmarksPagination.setAttribute('aria-hidden', false); 156 | bookmarksPagination.innerHTML = paginationButtons.join(''); 157 | } else { 158 | // Hide pagination if there is only one page 159 | bookmarksPagination.innerHTML = ''; 160 | bookmarksPagination.setAttribute('aria-hidden', true); 161 | } 162 | } 163 | 164 | /** 165 | * Attaches event listeners to a container element for bookmark cta, pagination and content type selection. 166 | * @param {HTMLElement} container - The container element to which the event listeners will be attached. 167 | */ 168 | const attachEventListeners = (container) => { 169 | // Add a click event listener to the container element 170 | container.addEventListener('click', (event) => { 171 | const eventTarget = event.target; 172 | 173 | // Handle bookmark removal 174 | if (eventTarget.hasAttribute(bookmarkCtaAttribute)) { 175 | updateBookmarks(); 176 | } 177 | 178 | // Handle pagination: When a pagination cta is clicked, it updates the active page and fetches new data. 179 | if (eventTarget.hasAttribute(paginationCtaSelector) || eventTarget.closest(paginationCtaSelector)) { 180 | const targetData = eventTarget.dataset.bookmarksPage; 181 | activePage = targetData; 182 | updateBookmarks(); 183 | } 184 | 185 | // Handle select: When a content select cta is clicked, it updates the content type, 186 | // resets the active page to 0, updates the UI, and fetches new data. 187 | if (eventTarget.hasAttribute(selectSelector) || eventTarget.closest(selectSelector)) { 188 | const targetData = eventTarget.dataset.bookmarksSelect; 189 | const siblingElement = eventTarget.previousElementSibling || eventTarget.nextElementSibling; 190 | dataTypeToDisplay = targetData; 191 | activePage = 0; 192 | 193 | siblingElement.classList.remove(activeClass); 194 | eventTarget.classList.add(activeClass); 195 | updateBookmarks(); 196 | } 197 | }) 198 | } 199 | 200 | /** 201 | * Returns the HTML for the bookmarks component. 202 | * @returns {string} The HTML for the bookmarks component. 203 | */ 204 | const getBookmarksHtml = () => { 205 | return ` 206 |
    207 |
    208 |

    Bookmarks

    209 |
    210 | 211 | 212 |
    213 |
    214 |
      215 |
    • 216 |
    • 217 |
    • 218 |
    • 219 |
    • 220 |
    • 221 |
    • 222 |
    • 223 |
    • 224 |
    • 225 |
    • 226 |
    • 227 |
    228 | 229 |
    `; 230 | } 231 | 232 | export { initBookmarks, getBookmarksHtml }; --------------------------------------------------------------------------------