├── 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 |
20 |
21 |
22 |
23 |
Sign Up
24 |
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 |
20 |
21 |
22 |
23 |
Login
24 |
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 |
57 |
58 | User rating:
59 | ${userRating}%
60 |
61 |
62 |
63 |
64 | ${releaseYear}
65 | ${mediaType}
66 |
67 |
${title}
68 |
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 |
90 |
Trending
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
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 |
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 |
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 |
50 |
${title}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
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 |
76 |
${title}
77 |
78 |
79 |
80 |
81 |
82 |
83 |
${errorMsg}
84 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
221 |
222 |
223 |
Avatar
224 |
Image cannot be more than 250KB
225 |
226 |
227 |
228 |
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 |
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 | `;
230 | }
231 |
232 | export { initBookmarks, getBookmarksHtml };
--------------------------------------------------------------------------------