Let's dive into the final part of this tutorial series, where we'll integrate the backend with the Frontend, connecting the pieces to complete the file upload application. We will begin by ensuring that authentications in the Frontend are up and running.
Web3 Modal Authentication
Create a new folder named 'config' in the Frontend directory and add an index file, resulting in the path /frontend/config/index.tsx
. Now let’s add the following codes to it.
// config/index.tsx | |
// Step 1: Import necessary modules and configurations | |
import { defaultWagmiConfig } from '@web3modal/wagmi/react/config' | |
import { cookieStorage, createStorage } from 'wagmi' | |
import { mainnet, sepolia } from 'wagmi/chains' | |
// Step 2: Define the WalletConnect Cloud project ID from environment variables | |
export const projectId = process.env.NEXT_PUBLIC_PROJECT_ID || '' | |
// Step 3: Create a metadata object for the application | |
const metadata = { | |
name: 'VidTv', // Application name | |
description: 'Stream video directly from the Sia blockchain', // Application description | |
url: 'https://web3modal.com', // Application URL (must match domain & subdomain) | |
icons: ['https://avatars.githubusercontent.com/u/37784886'], // Application icons | |
} | |
// Step 4: Define the supported chains for the application | |
const chains = [mainnet, sepolia] as const | |
// Step 5: Create the Wagmi configuration using defaultWagmiConfig | |
export const config = defaultWagmiConfig({ | |
chains, // Supported chains | |
projectId, // WalletConnect Cloud project ID | |
metadata, // Application metadata | |
ssr: true, // Enable server-side rendering | |
storage: createStorage({ // Create storage using cookieStorage | |
storage: cookieStorage, | |
}), | |
auth: { // Authentication settings | |
email: true, // Enable email authentication (default: true) | |
socials: ['google', 'x', 'github', 'discord', 'apple'], // Enable social login options | |
showWallets: false, // Disable showing wallets (default: true) | |
walletFeatures: true, // Enable wallet features (default: true) | |
}, | |
}) |
This code sets up a Wagmi configuration for our Web3 application, defining metadata, supported chains, and authentication settings, including wallet and social login options, and stores it in the config
export. We also need to create a context API to keep track of the authentication state.
The Context API
Next, create a new folder named 'context' still in the Frontend directory and add an index file, resulting in the path /frontend/context/index.tsx
. Add the following codes to it.
// context/index.tsx | |
// Step 1: Enable client-side rendering | |
'use client' | |
// Step 2: Import necessary modules and configurations | |
import React, { ReactNode } from 'react' | |
import { config, projectId } from '@/config' | |
import { createWeb3Modal, useWeb3ModalTheme } from '@web3modal/wagmi/react' | |
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | |
import { State, WagmiProvider } from 'wagmi' | |
// Step 3: Setup query client for React Query | |
const queryClient = new QueryClient() | |
// Step 4: Validate project ID | |
if (!projectId) throw new Error('Project ID is not defined') | |
// Step 5: Create Web3 modal with Wagmi configuration and project ID | |
createWeb3Modal({ | |
wagmiConfig: config, // Wagmi configuration | |
projectId, // Project ID | |
enableAnalytics: false, // Optional - defaults to your Cloud configuration | |
enableOnramp: false, // Optional - false as default | |
}) | |
// Step 6: Define the Web3ModalProvider component | |
export default function Web3ModalProvider({ | |
children, // Application children | |
initialState, // Optional initial state | |
}: { | |
children: ReactNode | |
initialState?: State | |
}) { | |
// Step 7: Set theme variables for Web3 modal | |
const { setThemeVariables } = useWeb3ModalTheme() | |
setThemeVariables({ | |
'--w3m-accent': '#22C55E', // Accent color | |
'--w3m-color-mix-strength': 0, // Color mix strength | |
}) | |
// Step 8: Return the application wrapped in WagmiProvider and QueryClientProvider | |
return ( | |
<WagmiProvider config={config} initialState={initialState}> | |
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> | |
</WagmiProvider> | |
) | |
} |
This code sets up a Web3Modal provider using Wagmi and React Query, configuring the Web3 modal with the project ID and theme variables, and wrapping the application in a WagmiProvider and QueryClientProvider.
Updating Layout
Let’s have our application layout updated to include the above configurations. Head to /frontend/app/layout.tsx
and replace its codes with the one below.
// Step 1: Import necessary modules and components | |
import type { Metadata } from 'next' | |
import { Inter } from 'next/font/google' | |
import './globals.css' // Global styles | |
import 'react-toastify/dist/ReactToastify.css' // Toast notifications styles | |
import 'react-loading-skeleton/dist/skeleton.css' // Skeleton loader styles | |
import Header from './components/layout/Header' // Header component | |
import Footer from './components/layout/Footer' // Footer component | |
import { cookieToInitialState } from 'wagmi' // Convert cookie to initial state | |
import { config } from '@/config' // Wagmi configuration | |
import { headers } from 'next/headers' // Next.js headers | |
import Web3ModalProvider from '@/context' // Web3 modal provider | |
import { ToastContainer } from 'react-toastify' // Toast notifications container | |
// Step 2: Create Inter font instance | |
const inter = Inter({ subsets: ['latin'] }) | |
// Step 3: Define metadata for the application | |
export const metadata: Metadata = { | |
title: 'VidTv', // Application title | |
description: 'Stream video directly from the Sia blockchain', // Application description | |
} | |
// Step 4: Define the root layout component | |
export default function RootLayout({ | |
children, // Application children | |
}: Readonly<{ | |
children: React.ReactNode | |
}>) { | |
// Step 5: Convert cookie to initial state for Web3 modal | |
const initialState = cookieToInitialState(config, headers().get('cookie')) | |
// Step 6: Return the application layout | |
return ( | |
<html lang="en"> | |
<body className={inter.className}> | |
<Web3ModalProvider initialState={initialState}> | |
<div className="flex flex-col h-[100vh]"> | |
<Header /> {/* Step 7: Render Header component */} | |
<main className="flex-grow mt-16 p-4 sm:p-8 max-w-7xl sm:mx-auto w-full"> | |
{children} {/* Step 8: Render application children */} | |
</main> | |
<Footer /> {/* Step 9: Render Footer component */} | |
</div> | |
<ToastContainer | |
position="bottom-center" // Step 10: Configure toast notifications | |
autoClose={5000} // Auto-close delay | |
hideProgressBar={false} // Show progress bar | |
newestOnTop={false} // New notifications on top | |
closeOnClick // Close on click | |
rtl={false} // Right-to-left layout | |
pauseOnFocusLoss // Pause on focus loss | |
draggable // Draggable notifications | |
pauseOnHover // Pause on hover | |
theme="dark" // Dark theme | |
/> | |
</Web3ModalProvider> | |
</body> | |
</html> | |
) | |
} |
The above code sets up the root layout for a Next.js application, including metadata, fonts, styles, and providers for Web3 modal, toast notifications, and layout components like header and footer.
The Login Button
Now, we need to enable the login buttons in the /frontend/app/components/layout/Header.tsx
and /frontend/app/components/shared/Menu.tsx
components, and update their codes using the information below.
import React from 'react' | |
import { SiGoogledisplayandvideo360 } from 'react-icons/si' | |
import Menue from '../shared/Menu' | |
import Link from 'next/link' | |
const Header = () => { | |
return ( | |
<div className="fixed top-0 w-full bg-[#121926] text-slate-200 z-50"> | |
<div className="max-w-7xl mx-auto flex justify-between items-center p-4 px-8"> | |
<Link href="/" className="flex gap-2 items-center text-lg sm:text-2xl "> | |
<span> | |
<SiGoogledisplayandvideo360 className="text-green-500" /> | |
</span> | |
<h1 className=" font-semibold ">VidTV</h1> | |
</Link> | |
<div className="flex items-center gap-10"> | |
<ul className="hidden md:flex gap-4 font-medium"> | |
<Link href="/create">Create</Link> | |
<li>Movies</li> | |
<li>Series</li> | |
<li>Anime</li> | |
<Link href="/account">Account</Link> | |
</ul> | |
<Menue /> | |
<div className="hidden md:block"> | |
<w3m-button label="Login" balance="hide" /> {/* Launches the web3 modal */} | |
</div> | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default Header |
This code defines a React component for a navigation bar that includes a logo, navigation links, a custom menu, and a login button that launches a Web3 Modal, with a responsive design for different screen sizes.
The following images should pop up as proof that what we have done works when you click on the login button and proceed with your preferred provider, X, Facebook, Google, Discord, or Ethereum.
Superb, let’s go deeper and set up our database and NextJs API-based system. For any confusion on the process, please watch the video section below, just make sure you stop at the 02:57:59 mark.
Database Scripts
First, let’s update the NextJs configuration script to properly address our pages, and endpoints, and free our remote images from warnings and scrutiny.
// Step 1: Define the Next.js configuration object | |
/** @type {import('next').NextConfig} */ | |
const nextConfig = { | |
// Step 2: Define rewrites for API routes | |
rewrites: async () => { | |
return [ | |
// Step 2.1: Rewrite API routes to match the API path | |
{ | |
source: '/api/:path*', // Source path | |
destination: '/api/:path*', // Destination path | |
}, | |
// Step 2.2: Rewrite client-side routes to match the pages directory | |
{ | |
source: '/:path*', // Source path | |
destination: '/pages/:path*', // Destination path | |
}, | |
] | |
}, | |
// Step 3: Configure image optimization | |
images: { | |
// Step 3.1: Allow remote images from any HTTPS hostname | |
remotePatterns: [ | |
{ | |
protocol: 'https', // Protocol | |
hostname: '**', // Allow any hostname | |
}, | |
], | |
// Step 3.2: Allow images from localhost domain | |
domains: ['localhost'], // Domains | |
}, | |
} | |
// Step 4: Export the Next.js configuration object | |
export default nextConfig |
This code defines a Next.js configuration object that sets up API route rewrites and image optimization, allowing remote images from any HTTPS hostname and local images from the localhost domain.
Database Config Script
We will be using SQLite for this application, but you are free to use a more robust solution such as MYSQL or NOSQL providers. For the sake of simplicity, let's work with a SQLite flat file.
Create /frontend/app/api/database.ts
file path and add the codes below in it.
// Step 1: Import necessary modules | |
import path from 'path' // Import path module for file path manipulation | |
import sqlite3 from 'sqlite3' // Import sqlite3 module for database operations | |
// Step 2: Define the database file path | |
const dbPath = path.join(process.cwd(), 'movies.db') // Join current working directory with 'movies.db' filename | |
// Step 3: Create a new sqlite3 database instance | |
const db = new sqlite3.Database( | |
dbPath, // Database file path | |
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, // Open flags: read-write and create if not exists | |
(err: any) => { // Callback function for database connection | |
if (err) { // Check for errors | |
console.error(err.message) // Log error message | |
} | |
console.log('Connected to the movie database.') // Log success message | |
} | |
) | |
// Step 4: Define an API function for GET requests | |
export const apiGet = async (query: any) => { // Async function for GET requests | |
return await new Promise((resolve, reject) => { // Create a new promise | |
db.all(query, (err: Error, row: any) => { // Execute query and get all rows | |
if (err) { // Check for errors | |
console.log(err) // Log error | |
return reject(err) // Reject promise with error | |
} | |
return resolve(row) // Resolve promise with query result | |
}) | |
}) | |
} | |
// Step 5: Define an API function for POST requests | |
export const apiPost = async (query: any, values: string[]) => { // Async function for POST requests | |
return await new Promise((resolve, reject) => { // Create a new promise | |
db.run(query, values, (err: Error) => { // Execute query with values | |
if (err) { // Check for errors | |
console.log(err) // Log error | |
return reject(err) // Reject promise with error | |
} | |
return resolve(null) // Resolve promise with no value | |
}) | |
}) | |
} | |
// Step 6: Export the database instance as default | |
export default db // Export db instance for external use |
This code sets up an SQLite database connection and defines two API functions, apiGet
and apiPost
, to perform GET and POST requests on the database, with error handling and promise-based asynchronous execution. We will be using these codes whenever we wish to send or retrieve data from the database.
Database Migration Script
We need to create both a database flat file and a table to hold all our contents. Create /frontend/app/api/migrations.ts
file path and add the codes below in it.
// Step 1: Import the database instance from the database module | |
import db from './database' | |
// Step 2: Define a function to migrate the database schema | |
const migrate = () => { | |
// Step 3: Serialize database operations to ensure sequential execution | |
db.serialize(() => { | |
// Step 4: Create the 'movies' table if it doesn't exist | |
db.run( | |
` | |
CREATE TABLE IF NOT EXISTS movies ( | |
id INTEGER PRIMARY KEY AUTOINCREMENT, | |
userId TEXT NOT NULL, | |
name TEXT NOT NULL, | |
image TEXT NOT NULL, | |
videoUrl TEXT NOT NULL, | |
slug TEXT UNIQUE NOT NULL, | |
release DATE NOT NULL, | |
genre TEXT NOT NULL, | |
rating TEXT NOT NULL, | |
language TEXT NOT NULL, | |
duration TEXT NOT NULL, | |
background TEXT NOT NULL, | |
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
); | |
`, | |
// Step 5: Handle errors and log success message | |
(err: Error) => { | |
if (err) { | |
console.error(err.message) // Log error message | |
} else { | |
console.log('movies table schema created successfully.') // Log success message | |
} | |
} | |
) | |
}) | |
} | |
// Step 6: Call the migrate function to execute the schema migration | |
migrate() |
This code defines a database migration function that creates a 'movies' table with specified columns if it doesn't exist, using SQLite, and logs the result of the operation. Now run the command below in a terminal pointed at the /frontend
directory.
$ cd frontend
$ npx esrun app/api/migrations.ts
It should be noted that this process will also create a database flat file called movies.db
at the root of the frontend directory. We have also added this command to the package.json script, so running $ yarn migrate
on the frontend directory should work the same.
For visual assistance, watch the video below, just stop it at the 03:10:54 mark.
Application Endpoints
Now, let’s define some endpoints for creating, reading, updating, and deleting movies, we will be using the NextJs API provision to make these endpoints.
Create Movie Endpoint
To create a movie, the required information includes the user ID, movie name, image, video URL, release date, genre, rating, language, duration, and background description. Create /frontend/app/api/movies/create/route.ts
file path and add the codes below in it.
// Step 1: Import necessary modules | |
import crypto from 'crypto' // Import crypto module for generating random strings | |
import { apiPost } from '../../database' // Import apiPost function from database module | |
// Step 2: Define an async function to handle POST requests | |
export async function POST(req: Request, res: Response) { | |
// Step 3: Parse the request body as JSON | |
const content = await req.json() | |
// Step 4: Define required properties for the movie object | |
const requiredProperties = [ | |
'userId', | |
'name', | |
'image', | |
'videoUrl', | |
'release', | |
'genre', | |
'rating', | |
'language', | |
'duration', | |
'background', | |
] | |
// Step 5: Check for missing required properties | |
const missingProperty = requiredProperties.find( | |
(property) => !(property in content) | |
) | |
// Step 6: Return an error response if a required property is missing | |
if (missingProperty) { | |
return Response.json( | |
{ | |
error: `Missing required property: ${missingProperty}`, | |
}, | |
{ | |
status: 400, | |
headers: { 'content-type': 'application/json' }, | |
} | |
) | |
} | |
// Step 7: Extract movie properties from the request body | |
const { | |
userId, | |
name, | |
image, | |
videoUrl, | |
release, | |
genre, | |
rating, | |
language, | |
duration, | |
background, | |
} = content | |
// Step 8: Generate a random string for the movie slug | |
const randomString = crypto.randomBytes(3).toString('hex').toLowerCase() | |
// Step 9: Create a unique slug for the movie | |
const slug = `${name | |
.replace(/[^a-zA-Z0-9 ]/g, '') | |
.replace(/\s/g, '-') | |
.split('-') | |
.join('-')}-${randomString}`.toLowerCase() | |
// Step 10: Define the SQL query to insert the movie into the database | |
const query = ` | |
INSERT INTO movies(userId, name, image, videoUrl, slug, release, genre, rating, language, duration, background) | |
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
` | |
// Step 11: Define the values to be inserted into the database | |
const values = [ | |
userId, | |
name, | |
image, | |
videoUrl, | |
slug, | |
release, | |
genre, | |
rating, | |
language, | |
duration, | |
background, | |
] | |
// Step 12: Initialize variables to store the response status and body | |
let status, body | |
// Step 13: Execute the SQL query using the apiPost function | |
await apiPost(query, values) | |
.then(() => { | |
// Step 14: Set the response status and body on success | |
status = 201 | |
body = { message: 'Successfully created movie' } | |
}) | |
.catch((err) => { | |
// Step 15: Set the response status and body on error | |
status = 400 | |
body = err | |
}) | |
// Step 16: Return the response as JSON | |
return Response.json(body, { | |
status, | |
headers: { 'content-type': 'application/json' }, | |
}) | |
} |
This code defines an endpoint to handle POST requests, validate and process movie data, generate a unique slug, and insert the data into a database using an apiPost function while handling errors and returning JSON responses.
Update Movie Endpoint
To update a movie, the required information includes the user ID, slug, and other information provided when creating a movie. Create /frontend/app/api/movies/update/route.ts
file path and add the codes below in it.
// Step 1: Import the apiPost function from the database module | |
import { apiPost } from '../../database' | |
// Step 2: Define an async function to handle POST requests | |
export async function POST(req: Request, res: Response) { | |
// Step 3: Parse the request body as JSON | |
const content = await req.json() | |
// Step 4: Define the required properties for the movie update | |
const requiredProperties = [ | |
'userId', | |
'slug', | |
'name', | |
'image', | |
'videoUrl', | |
'release', | |
'genre', | |
'rating', | |
'language', | |
'duration', | |
'background', | |
] | |
// Step 5: Check for missing required properties | |
const missingProperty = requiredProperties.find( | |
(property) => !(property in content) | |
) | |
// Step 6: Return an error response if a required property is missing | |
if (missingProperty) { | |
return Response.json( | |
{ | |
error: `Missing required property: ${missingProperty}`, | |
}, | |
{ | |
status: 400, | |
headers: { 'content-type': 'application/json' }, | |
} | |
) | |
} | |
// Step 7: Extract movie properties from the request body | |
const { | |
userId, | |
slug, | |
name, | |
image, | |
videoUrl, | |
release, | |
genre, | |
rating, | |
language, | |
duration, | |
background, | |
} = content | |
// Step 8: Define the SQL query to update the movie | |
const query = ` | |
UPDATE movies | |
SET name = ?, image = ?, videoUrl = ?, release = ?, genre = ?, rating = ?, language = ?, duration = ?, background = ? | |
WHERE slug = ? AND userId = ? | |
` | |
// Step 9: Define the values to be updated in the database | |
const values = [ | |
name, | |
image, | |
videoUrl, | |
release, | |
genre, | |
rating, | |
language, | |
duration, | |
background, | |
slug, | |
userId, | |
] | |
// Step 10: Initialize variables to store the response status and body | |
let status, body | |
// Step 11: Execute the SQL query using the apiPost function | |
await apiPost(query, values) | |
.then(() => { | |
// Step 12: Set the response status and body on success | |
status = 200 | |
body = { message: 'Successfully updated movie' } | |
}) | |
.catch((err) => { | |
// Step 13: Set the response status and body on error | |
status = 400 | |
body = err | |
}) | |
// Step 14: Return the response as JSON | |
return Response.json(body, { | |
status, | |
headers: { 'content-type': 'application/json' }, | |
}) | |
} |
This code defines an endpoint to handle POST requests for updating a movie, validating required properties, and executing an SQL query to update the movie data in the database using the apiPost function.
Delete Movie Endpoint
To delete a movie, the required information includes the user ID and slug of a movie. Create /frontend/app/api/movies/delete/route.ts
file path and add the codes below in it.
// Step 1: Import the apiPost function from the database module | |
import { apiPost } from '../../database' | |
// Step 2: Define an async function to handle POST requests for deleting a movie | |
export async function POST(req: Request, res: Response) { | |
// Step 3: Parse the request body as JSON | |
const content = await req.json() | |
// Step 4: Define the required properties for the movie deletion | |
const requiredProperties = ['userId', 'slug'] | |
// Step 5: Check for missing required properties | |
const missingProperty = requiredProperties.find( | |
(property) => !(property in content) | |
) | |
// Step 6: Return an error response if a required property is missing | |
if (missingProperty) { | |
return Response.json( | |
{ | |
error: `Missing required property: ${missingProperty}`, | |
}, | |
{ | |
status: 400, | |
headers: { 'content-type': 'application/json' }, | |
} | |
) | |
} | |
// Step 7: Extract the userId and slug from the request body | |
const { userId, slug } = content | |
// Step 8: Define the SQL query to delete the movie | |
const query = `DELETE FROM movies WHERE slug = ? AND userId = ?` | |
// Step 9: Define the values to be used in the SQL query | |
const values = [slug, userId] | |
// Step 10: Initialize variables to store the response status and body | |
let status, body | |
// Step 11: Execute the SQL query using the apiPost function | |
await apiPost(query, values) | |
.then(() => { | |
// Step 12: Set the response status and body on success | |
status = 200 | |
body = { message: 'Successfully deleted movie' } | |
}) | |
.catch((err) => { | |
// Step 13: Set the response status and body on error | |
status = 400 | |
body = err | |
}) | |
// Step 14: Return the response as JSON | |
return Response.json(body, { | |
status, | |
headers: { 'content-type': 'application/json' }, | |
}) | |
} |
This code defines an endpoint to handle POST requests for deleting a movie, validating required properties (userId and slug), and executing an SQL query to delete the movie from the database using the apiPost function.
All Movies Endpoint
The optional data required to get movies are pageSize and userId, which can be passed as query parameters to filter and paginate the results. Create /frontend/app/api/movies/all/route.ts
file path and add the codes below in it.
// Step 1: Import the apiGet function from the database module | |
import { apiGet } from '../../database' | |
// Step 2: Define an async function to handle GET requests for retrieving movies | |
export async function GET(req: Request, res: Response) { | |
// Step 3: Parse the URL and extract query parameters | |
const url = new URL(req.url) | |
const pageSizeParam = url.searchParams.get('pageSize') | |
const userIdParam = url.searchParams.get('userId') | |
// Step 4: Convert query parameters to appropriate data types | |
const pageSize = pageSizeParam ? parseInt(pageSizeParam, 10) : null | |
const userId = userIdParam ? userIdParam : null | |
// Step 5: Initialize the SQL query to retrieve movies | |
let query = `SELECT * FROM movies` | |
// Step 6: Conditionally add filters to the query based on query parameters | |
if (userId !== null) query += ` WHERE userId="${userId}"` | |
if (pageSize !== null) query += ` LIMIT ${pageSize}` | |
// Step 7: Initialize variables to store the response status and body | |
let status, body | |
// Step 8: Execute the SQL query using the apiGet function | |
await apiGet(query) | |
.then((res) => { | |
// Step 9: Set the response status and body on success | |
status = 200 | |
body = res | |
}) | |
.catch((err) => { | |
// Step 10: Set the response status and body on error | |
status = 400 | |
body = err | |
}) | |
// Step 11: Return the response as JSON | |
return Response.json(body, { | |
status, | |
headers: { 'content-type': 'application/json' }, | |
}) | |
} |
The above code defines an endpoint to handle GET requests for retrieving movies, allowing optional filtering by userId and pagination by pageSize, and returns the results in JSON format.
Single Movie Endpoint
To retrieve a single movie, the required data is the slug of a movie. Create /frontend/app/api/movies/[slug]/route.ts
file path and add the codes below in it.
// Step 1: Import the apiGet function from the database module | |
import { apiGet } from '../../database' | |
// Step 2: Define an async function to handle GET requests for retrieving a movie by slug | |
export async function GET( | |
req: Request, | |
{ params }: { params: any }, | |
res: Response | |
) { | |
// Step 3: Extract the slug parameter from the request parameters | |
const slug = params.slug | |
// Step 4: Check if the slug parameter is present, return an error if not | |
if (!slug) { | |
return Response.json( | |
{ error: 'slug is required' }, | |
{ | |
status: 400, | |
headers: { 'content-type': 'application/json' }, | |
} | |
) | |
} | |
// Step 5: Define the SQL query to retrieve the movie by slug | |
const query = `SELECT * FROM movies WHERE slug = "${slug}"` | |
// Step 6: Initialize variables to store the response status and body | |
let status, body | |
// Step 7: Execute the SQL query using the apiGet function | |
await apiGet(query) | |
.then((res) => { | |
// Step 8: Set the response status and body on success | |
status = 200 | |
body = res | |
}) | |
.catch((err) => { | |
// Step 9: Set the response status and body on error | |
status = 400 | |
body = err | |
}) | |
// Step 10: Return the response as JSON | |
return Response.json(body, { | |
status, | |
headers: { 'content-type': 'application/json' }, | |
}) | |
} |
This code defines an endpoint to handle GET requests for retrieving a movie by its slug, validating the slug parameter, and executing an SQL query to retrieve the movie data from the database using the apiGet function.
That marks all the endpoints we will need for this application. If you need a visual aid to help you understand these endpoints better, please watch the video below, just ensure you stop at the 03:48:22 timestamp.
Endpoint Integration
Our task is to review and update pre-coded components and pages, explaining each one's purpose and functionality, and documenting the changes we make to the existing code. We will start by creating a service for interacting with the endpoints we previously created in the api
directory.
Create /frontend/app/services/api.service.ts
file path and add the codes below in it.
import { PosterInterface } from '@/utils/interfaces' | |
import axios, { AxiosProgressEvent, AxiosRequestConfig } from 'axios' | |
const fetchMovies = async ( | |
pageSize: number | null = null, | |
userId: string | null = null | |
) => { | |
let url = '/api/movies/all' | |
if (pageSize !== null) url += `?pageSize=${pageSize}` | |
if (userId !== null) { | |
if (url.includes('?')) { | |
url += `&userId=${userId}` | |
} else { | |
url += `?userId=${userId}` | |
} | |
} | |
const res = await fetch(url, { | |
cache: 'no-store', | |
}) | |
const moviesData: PosterInterface[] = await res.json() | |
return moviesData | |
} | |
const fetchMovie = async (slug: string) => { | |
let url = `/api/movies/${slug}` | |
const res = await fetch(url, { | |
cache: 'no-store', | |
}) | |
const moviesData: PosterInterface[] = await res.json() | |
return moviesData[0] | |
} | |
const createMovie = async (movieData: any) => { | |
try { | |
const response = await fetch('/api/movies/create', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify(movieData), | |
}) | |
if (!response.ok) { | |
throw new Error(`HTTP error! status: ${response.status}`) | |
} | |
const responseData = await response.json() | |
return responseData | |
} catch (error) { | |
console.error('There was a problem with the fetch operation:', error) | |
throw error | |
} | |
} | |
const updateMovie = async (movieData: PosterInterface) => { | |
try { | |
const response = await fetch('/api/movies/update', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify(movieData), | |
}) | |
if (!response.ok) { | |
throw new Error(`HTTP error! status: ${response.status}`) | |
} | |
const responseData = await response.json() | |
return responseData | |
} catch (error) { | |
console.error('There was a problem with the fetch operation:', error) | |
throw error | |
} | |
} | |
const deleteMovie = async (movieData: PosterInterface) => { | |
try { | |
const response = await fetch('/api/movies/delete', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify(movieData), | |
}) | |
if (!response.ok) { | |
throw new Error(`HTTP error! status: ${response.status}`) | |
} | |
const responseData = await response.json() | |
return responseData | |
} catch (error) { | |
console.error('There was a problem with the fetch operation:', error) | |
throw error | |
} | |
} | |
const uploadFile = async ( | |
file: File, | |
onProgress: (progressEvent: AxiosProgressEvent) => void | |
) => { | |
const url = process.env.NEXT_PUBLIC_FILE_SERVICE_URL + '/upload' | |
const formData = new FormData() | |
formData.append('file', file) | |
const config: AxiosRequestConfig<FormData> = { | |
method: 'POST', | |
url, | |
headers: { | |
'Content-Type': 'multipart/form-data', | |
}, | |
data: formData, | |
onUploadProgress: onProgress, | |
} | |
try { | |
const response = await axios.request(config) | |
return Promise.resolve(response.data) | |
} catch (error) { | |
return Promise.reject(error) | |
} | |
} | |
export { fetchMovies, fetchMovie, createMovie, updateMovie, deleteMovie, uploadFile } |
This service provides a set of functions to interact with a movie database, allowing the application to fetch movies, fetch a single movie by slug, create a new movie, update an existing movie, delete a movie, and upload files, using API requests and handling errors.
Application Pages
Let’s review and update the various pages associated with our application. You wouldn’t need to change many things, just the ones highlighted here.
Create Movie Page
This page is a movie publishing form that allows users to upload video and image files, input movie details, and submit the form to publish the movie, with validation and error handling, using React and Wagmi libraries.
Now, update the file found in /frontend/app/pages/create/page.tsx
with the codes below.
'use client' | |
import { useState } from 'react' | |
import { toast } from 'react-toastify' | |
import Uploader from '@/app/components/shared/Uploader' | |
import Uploaded from '@/app/components/shared/Uploaded' | |
import { createMovie } from '@/app/services/api.service' | |
import { useAccount } from 'wagmi' | |
interface FilesState { | |
image: string | File | |
video: string | File | |
} | |
export default function Page() { | |
const { address, isDisconnected } = useAccount() | |
const [files, setFiles] = useState<FilesState>({ | |
image: '', | |
video: '', | |
}) | |
const [movieDetails, setMovieDetails] = useState({ | |
name: '', | |
image: '', | |
videoUrl: '', | |
background: '', | |
genre: '', | |
duration: '', | |
release: '', | |
rating: '', | |
language: '', | |
}) | |
const handleSelectedFile = (name: string, value: string) => { | |
console.log(name, value) | |
setFiles((prevDetails) => ({ | |
...prevDetails, | |
[name]: value, | |
})) | |
} | |
const isAllFieldsFilled = () => | |
Object.values(movieDetails).every((value) => value.trim() !== '') | |
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | |
event.preventDefault() | |
if (!isAllFieldsFilled) | |
return toast.error('Please fill in all movie details.') | |
await toast.promise( | |
new Promise<void>(async (resolve, reject) => { | |
createMovie({ ...movieDetails, userId: address }) | |
.then((res) => { | |
resetForm() | |
resolve(res) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Publishing...', | |
success: 'Movie published successful 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |
const { name, value } = event.target | |
setMovieDetails((prevDetails) => ({ | |
...prevDetails, | |
[name]: value, | |
})) | |
} | |
const handleURLMount = (name: string, value: string) => { | |
setMovieDetails((prevDetails) => ({ | |
...prevDetails, | |
[name]: value, | |
})) | |
} | |
const resetForm = () => { | |
setMovieDetails({ | |
name: '', | |
image: '', | |
videoUrl: '', | |
background: '', | |
genre: '', | |
duration: '', | |
release: '', | |
rating: '', | |
language: '', | |
}) | |
} | |
return ( | |
<div className="flex flex-col items-center"> | |
<div className="bg-gray-800 bg-opacity-75 border border-slate-500 w-full md:w-2/5 p-4 rounded-xl text-slate-200"> | |
<div className="flex flex-col items-center justify-center gap-2"> | |
<div className="flex justify-between items-center space-x-2 w-full"> | |
{!movieDetails.videoUrl ? ( | |
<Uploader | |
name="Video" | |
type="video/mp4" | |
size={100} | |
onUploadSuccess={(response) => | |
handleURLMount('videoUrl', response.url) | |
} | |
onFileSelected={(file) => handleSelectedFile('video', file)} | |
/> | |
) : ( | |
<Uploaded | |
name="Video" | |
file={files.video} | |
onRemove={() => handleURLMount('videoUrl', '')} | |
/> | |
)} | |
{!movieDetails.image ? ( | |
<Uploader | |
name="Poster" | |
type="image/png, image/jpg, image/jpeg" | |
size={2} | |
onUploadSuccess={(response) => | |
handleURLMount('image', response.url) | |
} | |
onFileSelected={(file) => handleSelectedFile('image', file)} | |
/> | |
) : ( | |
<Uploaded | |
name="Poster" | |
file={files.image} | |
onRemove={() => handleURLMount('image', '')} | |
/> | |
)} | |
</div> | |
<form onSubmit={handleSubmit} className="w-full mt-4"> | |
<div className="mb-4"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Name | |
</label> | |
<input | |
type="text" | |
name="name" | |
placeholder="E.g. Batman Return" | |
value={movieDetails.name} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
<div className="flex justify-between items-center space-x-2 mb-4"> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Genres | |
</label> | |
<input | |
type="text" | |
name="genre" | |
placeholder="E.g. Action" | |
value={movieDetails.genre} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Language | |
</label> | |
<input | |
type="text" | |
name="language" | |
placeholder="E.g. Russian" | |
value={movieDetails.language} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
</div> | |
<div className="flex justify-between items-center space-x-2 mb-4"> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Duration | |
</label> | |
<input | |
type="text" | |
name="duration" | |
placeholder="E.g. 2h 19m" | |
value={movieDetails.duration} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Release | |
</label> | |
<input | |
type="text" | |
name="release" | |
placeholder="E.g. 2020" | |
value={movieDetails.release} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Rating | |
</label> | |
<input | |
type="text" | |
name="rating" | |
placeholder="E.g. PG" | |
value={movieDetails.rating} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
</div> | |
<div className="mb-4"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Background | |
</label> | |
<input | |
type="text" | |
name="background" | |
placeholder="Synopsis of the movie..." | |
value={movieDetails.background} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
/> | |
<small className="text-slate-400"> | |
Keep all inputs short and simple no lengthy words needed. | |
</small> | |
</div> | |
{!isDisconnected && address && isAllFieldsFilled() && ( | |
<button | |
className="w-full bg-green-500 text-white py-2.5 rounded-lg hover:bg-transparent | |
hover:border-green-800 border border-transparent hover:text-green-500" | |
> | |
Publish | |
</button> | |
)} | |
</form> | |
</div> | |
</div> | |
</div> | |
) | |
} |
The changes made in this code compared to the original one are:
- Imported the
createMovie
function fromapi.service
and used it in thehandleSubmit
function to create a new movie. - Added the
userId
parameter to thecreateMovie
function call, passing the user's address from theuseAccount
hook. - Updated the
handleSubmit
function to usetoast.promise
to handle the promise returned bycreateMovie
. - Added error handling to the
createMovie
function call in thehandleSubmit
function.
These changes enable the form to submit movie data to the API and create a new movie entry, while also handling errors and displaying a success message.
Edit Movie Page
This movie editing page allows authorized users to update movie details, upload posters and videos, and save changes, with validation and error handling, utilizing React, Wagmi, and Next.js, specifically designed for users to edit their movies.
Now, update the file found in /frontend/app/pages/movies/edit/[slug]/page.tsx
with the codes below.
'use client' | |
import { useEffect, useState } from 'react' | |
import { toast } from 'react-toastify' | |
import { useParams } from 'next/navigation' | |
import { PosterInterface } from '@/utils/interfaces' | |
import Link from 'next/link' | |
import Uploader from '@/app/components/shared/Uploader' | |
import Uploaded from '@/app/components/shared/Uploaded' | |
import { posters } from '@/app/data/posters' | |
import { fetchMovie, updateMovie } from '@/app/services/api.service' | |
import { useAccount } from 'wagmi' | |
interface FilesState { | |
image: string | File | |
video: string | File | |
} | |
export default function Page() { | |
const { slug } = useParams() | |
const { address, isDisconnected, isConnecting } = useAccount() | |
const [movie, setMovie] = useState<PosterInterface | null>(null) | |
const [files, setFiles] = useState<FilesState>({ | |
image: '', | |
video: '', | |
}) | |
const [movieDetails, setMovieDetails] = useState({ | |
name: '', | |
image: '', | |
videoUrl: '', | |
background: '', | |
genre: '', | |
duration: '', | |
release: '', | |
rating: '', | |
language: '', | |
}) | |
useEffect(() => { | |
const fetchMovieData = async () => { | |
const movieData = await fetchMovie(slug as string) | |
if (!movieData) return | |
setMovieDetails(movieData as any) | |
setMovie(movieData as PosterInterface) | |
handleSelectedFile('image', movieData.image as string) | |
handleSelectedFile('videoUrl', movieData.videoUrl as string) | |
} | |
fetchMovieData() | |
}, [slug, address, isDisconnected]) | |
const handleSelectedFile = (name: string, value: string) => { | |
setFiles((prevDetails) => ({ | |
...prevDetails, | |
[name]: value, | |
})) | |
} | |
const isAllFieldsFilled = () => | |
Object.values(movieDetails).every((value) => value !== '') | |
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |
const { name, value } = event.target | |
setMovieDetails((prevDetails) => ({ | |
...prevDetails, | |
[name]: value, | |
})) | |
} | |
const handleURLMount = (name: string, value: string) => { | |
setMovieDetails((prevDetails) => ({ | |
...prevDetails, | |
[name]: value, | |
})) | |
} | |
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { | |
event.preventDefault() | |
if (!isAllFieldsFilled()) return toast.warning('Some fields are empty') | |
await toast.promise( | |
new Promise<void>(async (resolve, reject) => { | |
updateMovie({ ...movie, ...movieDetails } as PosterInterface) | |
.then((res) => resolve(res)) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Updating...', | |
success: 'Movie updated successful 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<div className="flex flex-col items-center"> | |
<Link href={'/movies/' + movie?.slug} className="mb-4 text-green-500"> | |
Back to movie | |
</Link> | |
<div className="bg-gray-800 bg-opacity-75 border border-slate-500 w-full md:w-2/5 p-4 rounded-xl text-slate-200"> | |
<div className="flex flex-col items-center justify-center gap-2"> | |
<div className="flex justify-between items-center space-x-2 w-full"> | |
{!movieDetails.videoUrl ? ( | |
<Uploader | |
name="Video" | |
type="video/mp4" | |
size={100} | |
onUploadSuccess={(response) => | |
handleURLMount('videoUrl', response.url) | |
} | |
onFileSelected={(file) => handleSelectedFile('video', file)} | |
/> | |
) : ( | |
<Uploaded | |
name="Video" | |
file={files.video} | |
onRemove={() => handleURLMount('videoUrl', '')} | |
/> | |
)} | |
{!movieDetails.image ? ( | |
<Uploader | |
name="Poster" | |
type="image/png, image/jpg, image/jpeg" | |
size={2} | |
onUploadSuccess={(response) => | |
handleURLMount('image', response.url) | |
} | |
onFileSelected={(file) => handleSelectedFile('image', file)} | |
/> | |
) : ( | |
<Uploaded | |
name="Poster" | |
file={files.image} | |
onRemove={() => handleURLMount('image', '')} | |
/> | |
)} | |
</div> | |
<form onSubmit={handleSubmit} className="w-full mt-4"> | |
<div className="mb-4"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Name | |
</label> | |
<input | |
type="text" | |
name="name" | |
placeholder="E.g. Batman Return" | |
value={movieDetails.name} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
<div className="flex justify-between items-center space-x-2 mb-4"> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Genres | |
</label> | |
<input | |
type="text" | |
name="genre" | |
placeholder="E.g. Action" | |
value={movieDetails.genre} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Language | |
</label> | |
<input | |
type="text" | |
name="language" | |
placeholder="E.g. Russian" | |
value={movieDetails.language} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
</div> | |
<div className="flex justify-between items-center space-x-2 mb-4"> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Duration | |
</label> | |
<input | |
type="text" | |
name="duration" | |
placeholder="E.g. 2h 19m" | |
value={movieDetails.duration} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Release | |
</label> | |
<input | |
type="text" | |
name="release" | |
placeholder="E.g. 2020" | |
value={movieDetails.release} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
<div className="w-full"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Rating | |
</label> | |
<input | |
type="text" | |
name="rating" | |
placeholder="E.g. PG" | |
value={movieDetails.rating} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
required | |
/> | |
</div> | |
</div> | |
<div className="mb-4"> | |
<label className="block text-sm font-medium text-slate-200 mb-1"> | |
Background | |
</label> | |
<input | |
type="text" | |
name="background" | |
placeholder="Synopsis of the movie..." | |
value={movieDetails.background} | |
onChange={handleInputChange} | |
className="w-full p-2 bg-gray-700 border border-slate-500 rounded text-sm" | |
/> | |
<small className="text-slate-400"> | |
Keep all inputs short and simple no lengthy words needed. | |
</small> | |
</div> | |
{movie && movie.userId === address && !isDisconnected && ( | |
<button | |
className="w-full bg-green-500 text-white py-2.5 rounded-lg hover:bg-transparent | |
hover:border-green-800 border border-transparent hover:text-green-500" | |
> | |
Update | |
</button> | |
)} | |
</form> | |
</div> | |
</div> | |
</div> | |
) | |
} |
The upgrades made to the code that is different from the original are:
- Imported
fetchMovie
andupdateMovie
functions from@/app/services/api.service
and used them in theuseEffect
hook andhandleSubmit
function, respectively. - Replaced the
posters.find()
method with thefetchMovie
function to retrieve movie data. - Updated the
handleSubmit
function to call theupdateMovie
function with the updated movie details. - Added error handling to the
updateMovie
function call in thehandleSubmit
function.
These changes enable our application to interact with our API endpoints to retrieve and update movie data, whereas the original code relied on our local posters
array.
Home Page
This home page renders the banners component, a list of movies (either from an API source or a loading UI), and subscription options, utilizing React and Next.js, to provide an engaging and informative landing page for users.
Update the file found in /frontend/app/pages/page.tsx
with the following codes.
'use client' | |
import React, { useEffect, useState } from 'react' | |
import Banners from '@/app/components/home/Banners' | |
import Posters from '@/app/components/home/Posters' | |
import Subscriptions from '@/app/components/home/Subscriptions' | |
import { PosterInterface } from '@/utils/interfaces' | |
import PostersUI from './components/home/PostersUI' | |
import { fetchMovies } from './services/api.service' | |
const Page = () => { | |
const [movies, setMovies] = useState<PosterInterface[]>([]) | |
const [loaded, setLoaded] = useState<boolean>(false) | |
useEffect(() => { | |
const fetchMoviesData = async () => { | |
const moviesData = await fetchMovies() | |
setMovies(moviesData) | |
setLoaded(true) | |
} | |
fetchMoviesData() | |
}, []) | |
return ( | |
<> | |
<Banners /> | |
{loaded ? <Posters movies={movies} /> : <PostersUI posters={3} />} | |
<Subscriptions /> | |
</> | |
) | |
} | |
export default Page |
The changes we made to the home page are:
- Imported
fetchMovies
function from./services/api.service
and used it in theuseEffect
hook to retrieve movie data from our API. - Replaced the local
posters
data with thefetchMovies
function call, which fetches data from our API. - Added
await
keyword to wait for the promise returned byfetchMovies
to resolve before setting themovies
state.
These changes help our application to retrieve movie data from our API instead of relying on local data, making the application more dynamic and data-driven.
User Account Page
This page displays a list of movies posted by the currently connected user, with a loading skeleton placeholder while the data is being fetched, and a message prompting the user to connect their account if they haven't done so, utilizing Wagmi, and react-loading-skeleton.
Update the file found in /frontend/app/pages/account/page.tsx
with the following codes.
'use client' | |
import MovieCard from '@/app/components/shared/MovieCard' | |
import { useEffect, useState } from 'react' | |
import { PosterInterface } from '@/utils/interfaces' | |
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' | |
import { fetchMovies } from '@/app/services/api.service' | |
import { useAccount } from 'wagmi' | |
const Page = () => { | |
const [loaded, setLoaded] = useState(false) | |
const [movies, setMovies] = useState<PosterInterface[]>([]) | |
const { address, isConnecting, isDisconnected } = useAccount() | |
useEffect(() => { | |
const fetchMovieData = async () => { | |
const moviesData = await fetchMovies(null, address) | |
setMovies(moviesData) | |
setLoaded(true) | |
} | |
fetchMovieData() | |
}, [address, isConnecting]) | |
return ( | |
<div className="flex flex-col w-full items-center"> | |
<h3 className="text-lg mb-4"> | |
{!loaded | |
? 'Please connect your account to view your movies.' | |
: movies.length < 1 | |
? 'You have no movies posted yet...' | |
: 'Your Movies'} | |
</h3> | |
{loaded && ( | |
<ul className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5 h-full item-center "> | |
{movies.map((movie) => ( | |
<MovieCard | |
key={movie.id} | |
movie={movie} | |
width="w-52 rounded-2xl" | |
height="h-96" | |
/> | |
))} | |
</ul> | |
)} | |
{!loaded && ( | |
<SkeletonTheme | |
baseColor="#202020" | |
highlightColor="#444" | |
borderRadius={15} | |
> | |
<ul className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5 w-full h-full"> | |
{Array.from({ length: 3 }).map((_, i) => ( | |
<Skeleton key={i} height={400} width={200} /> | |
))} | |
</ul> | |
</SkeletonTheme> | |
)} | |
</div> | |
) | |
} | |
export default Page |
The changes made to the page are:
- Imported
fetchMovies
function from@/app/services/api.service
and used it in theuseEffect
hook to retrieve movie data from our API. - Replaced the local
posters
data with thefetchMovies
function call, which fetches data from our API. - Passed
address
as an argument to thefetchMovies
function to retrieve user-specific movie data. - Removed the conditional check for
address
before rendering the movie list, as thefetchMovies
function now handles this logic. - Simplified the conditional statement for displaying the loading skeleton, as it now only depends on the
loaded
state.
These changes retrieve movie data from our API, specific to the connected user, and display a loading skeleton while the data is being fetched.
Movies Details Page
This page displays a single movie's details, including its name, release year, rating, duration, genre, and background information, along with a video player and related movies, and provides options to edit or delete the movie if the user is the owner, utilizing Next.js, and Wagmi.
Update the file found in /frontend/app/pages/movies/[slug]/page.tsx
with the following codes.
'use client' | |
import { FaStar, FaEdit, FaTrash } from 'react-icons/fa' | |
import MovieCard from '@/app/components/shared/MovieCard' | |
import { useParams } from 'next/navigation' | |
import ReactPlayer from 'react-player' | |
import { useEffect, useState } from 'react' | |
import { PosterInterface } from '@/utils/interfaces' | |
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton' | |
import Link from 'next/link' | |
import { useAccount } from 'wagmi' | |
import { toast } from 'react-toastify' | |
import { | |
deleteMovie, | |
fetchMovie, | |
fetchMovies, | |
} from '@/app/services/api.service' | |
const Page = () => { | |
const { slug } = useParams() | |
const { address, isDisconnected, isConnecting } = useAccount() | |
const [loaded, setLoaded] = useState(false) | |
const [movie, setMovie] = useState<PosterInterface | null>(null) | |
const [movies, setMovies] = useState<PosterInterface[]>([]) | |
useEffect(() => { | |
const fetchMovieData = async () => { | |
const movieData = await fetchMovie(slug as string) | |
setMovie(movieData as PosterInterface) | |
const moviesData = await fetchMovies(3) | |
setMovies(moviesData) | |
setLoaded(true) | |
} | |
fetchMovieData() | |
}, [slug, address, isDisconnected]) | |
const handleSubmit = async () => { | |
if (!window.confirm('Are you sure you want to delete this movie?')) return | |
await toast.promise( | |
new Promise<void>(async (resolve, reject) => { | |
deleteMovie(movie as PosterInterface) | |
.then((res) => { | |
window.location.href = '/account' | |
resolve(res) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Deleting...', | |
success: 'Movie deleted successful 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
const truncateAddress = (address: string): string => { | |
const startLength = 4 | |
const endLength = 4 | |
const addressString = String(address) | |
const truncatedAddress = `${addressString.substring( | |
0, | |
startLength | |
)}...${addressString.substring(addressString.length - endLength)}` | |
return truncatedAddress | |
} | |
return ( | |
<div | |
className="flex flex-col md:flex-row w-full md:items-start items-center | |
md:justify-between justify-center text-gray-200 md:space-x-6 space-x-0 | |
space-y-6 md:space-y-0 " | |
> | |
<div className="w-full sm:w-2/3 space-y-4"> | |
<div className="flex items-center justify-start"> | |
{loaded ? ( | |
<div className="flex justify-start items-center space-x-2"> | |
<div className="flex justify-start items-center space-x-2"> | |
<h2 className="text-2xl"> | |
{movie?.name} ({movie?.release}) | |
</h2> | |
<FaStar className="text-yellow-500" /> | |
</div> | |
<div className="flex justify-start items-center space-x-2 text-gray-400"> | |
<p className="text-sm"> | |
{movie?.rating} {movie?.duration}, {movie?.genre} | |
{' | ' + truncateAddress(movie?.userId || '')} | |
</p> | |
{!isDisconnected && address && movie?.userId === address && ( | |
<div className="flex space-x-4"> | |
<span className="w-1"></span> | |
<Link | |
href={'/movies/edit/' + movie?.slug} | |
className="flex justify-start items-center space-x-1 text-green-500" | |
> | |
<FaEdit /> <span>Edit</span> | |
</Link> | |
<button | |
onClick={handleSubmit} | |
className="flex justify-start items-center space-x-1 text-red-500" | |
> | |
<FaTrash /> <span>Delete</span> | |
</button> | |
</div> | |
)} | |
</div> | |
</div> | |
) : ( | |
<h4>Loading...</h4> | |
)} | |
</div> | |
<SkeletonTheme | |
baseColor="#202020" | |
highlightColor="#444" | |
borderRadius={15} | |
> | |
<div className="w-full h-[450px] rounded-2xl overflow-hidden relative"> | |
{loaded ? ( | |
<ReactPlayer | |
url={movie?.videoUrl} | |
width="100%" | |
height="100%" | |
className="absolute top-0 left-0" | |
controls={true} | |
playing={false} | |
preload="none" | |
/> | |
) : ( | |
<Skeleton height={500} /> | |
)} | |
</div> | |
</SkeletonTheme> | |
<p className="text-gray-400">{movie?.background}</p> | |
</div> | |
<div className="w-full sm:w-1/3 space-y-4"> | |
<h3 className="text-lg">Related Movies</h3> | |
<div className="h-[450px] overflow-y-auto"> | |
{loaded ? ( | |
<ul className="flex flex-col gap-5"> | |
{movies.map((movie) => ( | |
<MovieCard | |
key={movie.id} | |
movie={movie} | |
width="w-full rounded-2xl" | |
height="h-40" | |
/> | |
))} | |
</ul> | |
) : ( | |
<SkeletonTheme | |
baseColor="#202020" | |
highlightColor="#444" | |
borderRadius={15} | |
> | |
{Array.from({ length: 3 }).map((_, i) => ( | |
<Skeleton key={i} height={150} /> | |
))} | |
</SkeletonTheme> | |
)} | |
</div> | |
</div> | |
</div> | |
) | |
} | |
export default Page |
We made some huge changes here! Here's a summary of what we did:
- Imported
deleteMovie
,fetchMovie
, andfetchMovies
functions from@/app/services/api.service
and used them to interact with our API endpoints. - Replaced local data with API calls to retrieve movie data.
- Implemented movie deletion functionality using the
deleteMovie
function. - Used
toast.promise
to display a notification while deleting a movie. - Removed the
posters
local data and replaced it with API calls. - Updated the
handleSubmit
function to call thedeleteMovie
function and handle the response. - Updated the
useEffect
hook to call thefetchMovie
andfetchMovies
functions.
These changes cause our application to interact with our API to retrieve and delete movie data and display notifications to the user during the deletion process.
This part of the video below will show you hands-on how we integrated these pages with the endpoint, please feel free to watch that part if you run into any problem. Just make sure you stop at the 04:57:41 timestamp.
Application Components
Let’s discuss the purpose of each component in our application. We will update any component that needs to be modified.
Banner Component
This component displays a rotating background image of movie banners, cycling through an array of movie images every 5 seconds, creating a simple and automatic slideshow effect. This component code can be assessed at /frontend/app/components/home/Banner.tsx
.
Posters Component
This component displays a responsive and interactive carousel of movie posters, using the Swiper library, with features like autoplay, pagination, and navigation, showcasing a list of movies passed as a prop, with a dynamic layout adapting to different screen sizes. This component code can be assessed at /frontend/app/components/home/Posters.tsx
.
Poster UI Component
This component displays a placeholder skeleton layout for a movie posters section, using the react-loading-skeleton library, showing a dynamic number of skeleton posters based on the "posters" prop, with a responsive design adapting to different screen sizes, indicating a loading state until the actual posters data is fetched and displayed. This component code can be assessed at /frontend/app/components/home/PosterUI.tsx
.
Subscriptions Component
This component displays a subscription plans section, showcasing various dummy plans with their details, prices, and benefits. It allows users to choose a plan that suits their needs, utilizing a responsive grid layout and interactive hover effects to enhance the user experience. This component code can be assessed at /frontend/app/components/home/Subscription.tsx
.
Header Component
This component renders a fixed navigation bar at the top of the page, featuring a logo, a navigation menu with links to various sections, a menu toggle button for responsive design, and a login button, providing a consistent and accessible header section across the application. This component code can be assessed at /frontend/app/components/layout/Header.tsx
.
Footer Component
This component renders a footer section at the bottom of the page, featuring the application's logo, a brief description, navigation links, contact information, and a credit mentioning the decentralized storage solution powered by Sia Foundation, providing a clear and organized footer section with relevant information and links. This component code can be assessed at /frontend/app/components/layout/Footer.tsx
.
Menu Component
This component renders a responsive menu toggle button, which when clicked, opens or closes a dropdown menu containing navigation links, allowing users to access various sections of the application on smaller screens while hiding the menu on larger screens where the navigation links are already visible. This component code can be assessed at /frontend/app/components/shared/Menu.tsx
.
Movie Card Component
This component displays a single movie's poster with a hover effect, showing additional information such as the movie's name, release year, and background summary, while also serving as a link to the movie's details page, utilizing a responsive design and animated transitions to enhance the user experience. This component code can be assessed at /frontend/app/components/shared/MovieCard.tsx
.
Uploaded Component
This component displays a preview of an uploaded file, either an image or a video, with a progress bar and a removal button, allowing users to review and delete the uploaded file, while also providing a visually appealing and interactive interface with animations and hover effects. This component code can be assessed at /frontend/app/components/shared/Uploaded.tsx
.
Uploader Component
This component provides a user interface for uploading files, specifically videos or posters, with features like drag-and-drop, file type validation, size limits, upload progress tracking, and success/error notifications, utilizing a combination of React state management, event handling, and API integration to handle the upload process.
Update the file found in /frontend/app/components/shared/uploader.tsx
with the following codes.
/* eslint-disable @next/next/no-img-element */ | |
import { uploadFile } from '@/app/services/api.service' | |
import React, { useState } from 'react' | |
import { AiOutlineClose } from 'react-icons/ai' | |
import { FaCloudUploadAlt } from 'react-icons/fa' | |
import { LuPlus } from 'react-icons/lu' | |
import { toast } from 'react-toastify' | |
interface ComponentProps { | |
name: 'Poster' | 'Video' | |
type: string // Ensure this accepts any valid MIME type string | |
size: number | |
onUploadSuccess: (response: any) => void | |
onFileSelected: (file: any) => void | |
} | |
const Uploader: React.FC<ComponentProps> = ({ | |
type, | |
size, | |
name, | |
onUploadSuccess, | |
onFileSelected, | |
}) => { | |
const [uploading, setUploading] = useState<boolean>(false) | |
const [progress, setProgress] = useState(0) | |
const [files, setFiles] = useState<File[]>([]) | |
const removeFile = () => { | |
setFiles([]) | |
} | |
const handleUploadProgress = (progressEvent: any) => { | |
const { loaded, total } = progressEvent | |
const percentCompleted = Math.round((loaded / Number(total)) * 100) | |
console.log(`Upload progress: ${percentCompleted}%`) | |
setProgress(percentCompleted) | |
} | |
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { | |
e.preventDefault() | |
} | |
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => { | |
e.preventDefault() | |
} | |
const handleDrop = (event: React.DragEvent) => { | |
event.preventDefault() | |
const file = event.dataTransfer.files[0] | |
// Check file size | |
if (file.size > size * 1024 * 1024) { | |
alert(`File size must be less than ${size}MB.`) | |
return | |
} | |
// Dynamically create regex for filetype validation | |
const fileTypeRegex = createFileTypeRegex(type.split(', ')) | |
// Check file type | |
if (!fileTypeRegex.test(file.type)) { | |
alert(`Only ${type} files are allowed.`) | |
return | |
} | |
onFileSelected(file) | |
setFiles([file]) | |
} | |
const createFileTypeRegex = (fileTypes: string[]): RegExp => { | |
// Join the file types with the '|' character to indicate OR in regex | |
const joinedFileTypes = fileTypes.join('|') | |
return new RegExp(`^${joinedFileTypes}$`) | |
} | |
const handleClickOpenFileExplorer = () => { | |
// Create a hidden file input element | |
const fileInput = document.createElement('input') | |
fileInput.type = 'file' | |
fileInput.accept = type // Limit file types to MP4 | |
fileInput.style.display = 'none' // Hide the file input | |
const handleFileSelection = (event: Event) => { | |
// Use event.currentTarget to access the input element | |
const target = event.currentTarget as HTMLInputElement | |
const file = target.files?.[0] // Now safely accessing the files property | |
if (!file) return | |
// Check file size | |
if (file.size > size * 1024 * 1024) { | |
// 150 MB in bytes | |
alert(`File size must be less than ${size}MB.`) | |
return | |
} | |
// Dynamically create regex for filetype validation | |
const fileTypeRegex = createFileTypeRegex(type.split(', ')) | |
// Check file type | |
if (!fileTypeRegex.test(file.type)) { | |
alert(`Only ${type} files are allowed.`) | |
return | |
} | |
onFileSelected(file) | |
setFiles([file]) | |
} | |
// Attach the modified event listener | |
fileInput.onchange = handleFileSelection | |
// Append the file input to the body temporarily | |
document.body.appendChild(fileInput) | |
fileInput.click() // Open the file dialog | |
// Remove the file input after the dialog is closed | |
fileInput.addEventListener('change', () => { | |
document.body.removeChild(fileInput) | |
}) | |
} | |
const handleUpload = async () => { | |
setUploading(true) | |
await toast.promise( | |
new Promise<void>(async (resolve, reject) => { | |
await uploadFile(files[0], handleUploadProgress) | |
.then((res) => { | |
onUploadSuccess(res) | |
console.log(res) | |
resolve(res) | |
}) | |
.catch((error) => reject(error)) | |
}), | |
{ | |
pending: 'Uploading...', | |
success: 'File successfully uploaded 👌', | |
error: 'Encountered error 🤯', | |
} | |
) | |
} | |
return ( | |
<> | |
{files.length < 1 && ( | |
<div | |
className="flex flex-col items-center border-dashed border-2 | |
border-slate-500 w-full h-32 justify-center rounded-xl | |
cursor-pointer hover:border-green-500" | |
onDragOver={handleDragOver} | |
onDragLeave={handleDragLeave} | |
onDrop={handleDrop} | |
onClick={handleClickOpenFileExplorer} | |
> | |
<LuPlus className="text-3xl" /> | |
<p>Upload {name}</p> | |
<p className="text-sm text-slate-400">{size}mb Max</p> | |
</div> | |
)} | |
{files.length > 0 && ( | |
<div className="flex flex-col justify-center shadow-md w-full h-32 rounded-lg overflow-hidden"> | |
<div className="relative w-full h-full"> | |
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black opacity-75"></div> | |
<div className="relative h-full"> | |
{type.includes('video') ? ( | |
<video | |
className="w-full h-full object-cover" | |
src={URL.createObjectURL(files[0])} | |
/> | |
) : ( | |
<img | |
className="w-full h-full object-cover" | |
src={URL.createObjectURL(files[0])} | |
alt="photo" | |
/> | |
)} | |
{uploading ? ( | |
<> | |
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white text-green-600 rounded-full p-1 py-1.5"> | |
<span className="text-sm h-6 w-6">{progress}%</span> | |
</div> | |
<div className="absolute bottom-0 left-0 right-0 bg-gray-700 rounded-full h-[2px]"> | |
<span | |
className={`bg-green-500 h-full w-[${progress}%] flex`} | |
></span> | |
</div> | |
</> | |
) : ( | |
<> | |
<button | |
onClick={handleUpload} | |
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 | |
bg-white text-green-600 rounded-full p-1.5 py-1.5 | |
hover:bg-black hover:bg-opacity-70" | |
> | |
<FaCloudUploadAlt size={30} /> | |
</button> | |
<button | |
className="absolute top-1 right-1 text-white bg-black bg-opacity-70 | |
hover:text-red-500 rounded-full p-1" | |
onClick={removeFile} | |
> | |
<AiOutlineClose /> | |
</button> | |
</> | |
)} | |
</div> | |
</div> | |
</div> | |
)} | |
</> | |
) | |
} | |
export default Uploader |
The changes made to this component are:
-
Upload File Functionality: The original code didn't have the actual upload file functionality implemented. It only showed a success toast notification without uploading the file. This updated code includes the
uploadFile
function fromapi.service
which handles the file upload. - Progress Tracking: The updated code tracks the upload progress and displays it on the UI.
- Error Handling: The updated code includes error handling for the file upload process.
- File Type Validation: The updated code uses a more robust file type validation using a regular expression.
- Code Organization: The updated code is better organized, with separate functions for handling different tasks, making it easier to read and maintain.
- UI Updates: The updated code includes some UI updates, such as showing the upload progress and a cancel button during upload.
This updated code is more complete and robust, with actual file upload functionality, progress tracking, error handling, and better code organization.
The video below explains what each component does in more detail, kindly check it out for your betterment.
And that is it guys, we have completed this project, and the last step we need to take is to launch this project on the browser. Run $ yarn build && yarn start
to see the project live on the browser.
If you encounter any issues, refer to the following resources for troubleshooting, till next time, all the best!
About Author
I am a web3 developer and the founder of Dapp Mentors, a company that helps businesses and individuals build and launch decentralized applications. I have over 8 years of experience in the software industry, and I am passionate about using blockchain technology to create new and innovative applications. I run a YouTube channel called Dapp Mentors where I share tutorials and tips on web3 development, and I regularly post articles online about the latest trends in the blockchain space.
Top comments (0)