Blog home

How to Build a Chattanooga Video Wall

Steve

Published on

10 min read

How to Build a Chattanooga Video Wall

Remember what it was like during the early Facebook years, when “posting on a wall” meant something? Ever want to bring those moments back? Well, I did, and I particularly wanted to bring it closer to home. I’ve been living in Chattanooga for years now and there’s a lot to love here, so much that I thought it would be fun to have a Chattanooga Video Wall app where people can post their experiences. You can check it out at chatt.video, but even better, I’m going to show you how you can build it yourself using Pinata!

Setup

There’s a few moving pieces here so let’s go over all the things you’ll need before we start building the app itself.

Pinata

First thing you’ll want to do is create a free Pinata account. Once inside the Pinata App, select “API Keys” from the sidebar, then click “New Key” in the top right. We would recommend starting with Admin privileges and unlimited uses to start. You will receive a pinata_api_keypinata_api_secret, and a JWT. The JWT is the most common authentication method and what we’ll be using for this tutorial.

Next, you will want to grab your Gateway domain by clicking the Gateways tab in the sidebar. You should see it listed in the format fun-llama-300.mypinata.clou**d** and you will want to copy it exactly like that. Save all those details and we’ll use them again soon.

Supabase

To handle the records of our podcasts and episodes, we’re going to use Supabase to spin up a quick database. To do that, create a free account and then make a new project. With that project you should get a Project URL and an API Key that you will want to save somewhere safe.

Now, to create our table, you can simple copy the code below and past it into the SQL Editor located in the top left.

create table
  public.videos (
    id bigint generated by default as identity not null,
    created_at timestamp with time zone not null default now(),
    name text null,
    description text null,
    video_url text null,
    constraint videos_pkey primary key (id)
  ) tablespace pg_default;

This is a pretty simple database setup where we have a videos table that has columns like name, description, and video_url for the video itself.

Next App

With all of that taken care of, we can start moving it into our app. To do this, you’ll need a little experience with Next.js, or React frameworks in general, Node installed on your computer, and a good text editor. When you’re ready, run the following command in your terminal and select all the defaults:

npx create-next-app@latest chattanooga-video-wall

Once it’s been initialized, you will want to run the next command to cd into the repo and install some dependencies.

cd chattanooga-video-wall && npm i pinata uuid @supabase/supabase-js

Now, like we said earlier, we’re going to use shadcn/ui for our components; they look great and save us a bunch of time from doing our own styles. Installation is simple:

npx shadcn@latest init

Once that has finished running, we will run the next command to add our components.

npx shadcn@latest add button card dialog form input

This will put all of our UI components into a folder under components/ui. Since they’re only installed incrementally, you can edit the components in that folder to meet whatever style you want down the road!

It’s time to get our API keys and URLs setup, and we’ll do that by creating a new file called .env.local in the root of our project with the following variables:

PINATA_JWT= # the Pinata JWT API key from earlier
NEXT_PUBLIC_GATEWAY_URL= # the Pinata Gateway URL from your acccount, in the format <my-domain>.mypinata.cloud
SUPABASE_URL= # your Supabase Project URL
SUPABASE_KEY= # your Supabase API Key

With those values in place, we can add some little utility files to create instances we can use throughout our app. To start, make a new file under the lib folder called pinata.ts and put in the following code:

import { PinataSDK } from "pinata";

export const pinata = new PinataSDK({
	pinataJwt: process.env.PINATA_JWT!,
	pinataGateway: process.env.NEXT_PUBLIC_GATEWAY_URL,
});

This tiny little constant will be our ticket to easy uploads, as you’ll experience soon! If you ever need the docs to see what else it can do, be sure to check them out here.

Another piece we’ll need is access to our database. In the same lib folder make a new file called db.ts with this piece of code.

import { createClient } from "@supabase/supabase-js";

// Create a single supabase client for interacting with your database
export const supabase = createClient(
	process.env.SUPABASE_URL!,
	process.env.SUPABASE_KEY!,
);

Normally, these keys could be public, but since we are not implementing auth or RLS (row level security) in this tutorial, we’ll just keep all our database interactions server side and treat them as secret.

With all of that set-up, we can finally start building our video app!

Rendering the Video Feed

Let’s start by building the feed that will have our videos. Take out the boiler template code out of page.tsx and paste this in:

import { CreateVideoForm } from "@/components/create-video-form";
import { supabase } from "@/lib/db";
import { Card } from "@/components/ui/card";

export const revalidate = 0;

export type Video = {
	id: number;
	created_at: string;
	name: string;
	description: string;
	video_url: string;
	author: string;
};

async function fetchData(): Promise<Video[]> {
	try {
		const supabase = createClient();
		const { data, error } = await supabase
			.from("videos")
			.select("*")
			.order("id", { ascending: false });
		return data as Video[];
	} catch (error) {
		console.log(error);
		return [];
	}
}

export default async function Home() {
	const data = await fetchData();
	console.log(data);

	return (
		<main className="flex min-h-screen flex-col items-center justify-start py-24 gap-12">
			<div className="flex flex-col gap-2 text-center">
				<h1 className="bg-cover bg-top bg-clip-text bg-[url('/bg.webp')] text-transparent scroll-m-20 lg:text-9xl md:text-8xl text-6xl font-extrabold tracking-tight">
					Chattanooga
				</h1>
				<h2 className="lg:text-7xl md:text-6xl text-4xl font-extrabold">
					Video Wall
				</h2>
				<h4 className="scroll-m-20 text-xl font-semibold tracking-tight">
					Share your favorite moments from Chattanooga, TN
				</h4>
			</div>
			<CreateVideoForm />
			{data.length === 0 && <h3>No videos yet!</h3>}
			{data.map((item: Video) => (
				<Card
					className="overflow-hidden max-w-[350px] sm:max-w-[500px]"
					key={item.id}
				>
					<video
						className=" h-auto w-full object-cover"
						playsInline
						controls
						autoPlay
						loop
						muted
						preload="auto"
					>
						<source src={item.video_url} type="video/mp4" />
						<source src={item.video_url} type="video/webm" />
						<track kind="captions" src="" label="English" />
					</video>
					<div className="p-2 flex flex-col">
						<h3 className="scroll-m-20 text-2xl font-semibold tracking-tight">
							{item.name}
						</h3>
						<p className="mt-2">{item.description}</p>
					</div>
				</Card>
			))}
		</main>
	);
}

The page might look a little complicated, but it’s actually pretty straight forward. It’s a server rendered page, so we can declare a fetchData function that will run when the page is loaded. In there, we fetch all the videos from our database and map over them in the UI. However we don’t have any videos right now, so let’s fix that.

Adding Videos to the Feed

Before we start uploading videos, we want to do just a little bit of prep work. All files uploaded to Pinata are, by default, private. If you want to view them, you either need to create a temporary signed url or just make them public. Since we want people to view these videos, we’ll go with option 2 and make all videos uploaded public by default. To do that, we’ll make a group, an easy way to organize your files on Pinata. Under the lib folder make a new file called createGroup.js and put in the following script.

import { pinata } from "./pinata";

async function createGroup() {
	const group = await pinata.groups.create({
		name: "Chattanooga Video Wall",
		isPublic: true,
	});
	console.log(group);
}

createGroup();

This is just a one-time script we want to run, so in the terminal run node lib/createGroup.js and you should get an output like this:

{
  "id": "01919976-955f-7d06-bd59-72e80743fb95",
  "name": "Chattanooga Video Wall",
  "public": true,
  "created_at": "2024-09-09T14:49:31.246596Z"
}

Back in our .env.local file let’s save this id like so:

NEXT_PUBLIC_GROUP_ID=01919976-955f-7d06-bd59-72e80743fb95

This will make it easier to always upload to this group for all our videos to make sure they’re public.

With that prep out of the way, let’s make a new file called create-video-form.tsx in the components folder and put in the following code:

"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
	Form,
	FormControl,
	FormDescription,
	FormField,
	FormItem,
	FormLabel,
	FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
	Dialog,
	DialogContent,
	DialogTrigger,
	DialogTitle,
} from "@/components/ui/dialog";
import { ReloadIcon } from "@radix-ui/react-icons";
import { pinata } from "@/lib/pinata";
import { revalidatePath } from "next/cache";

const formSchema = z.object({
	name: z.string(),
	description: z.string(),
	file: z.any(),
});

export function CreateVideoForm() {

	const [isLoading, setIsLoading] = useState(false);
	const [open, setOpen] = useState(false);
	
	const form = useForm<z.infer<typeof formSchema>>({
		resolver: zodResolver(formSchema),
		defaultValues: {
			name: "",
			description: "",
			file: "",
		},
	});

	const fileRef = form.register("file");

	async function onSubmit(values: z.infer<typeof formSchema>) {
		setIsLoading(true);
		const file: File | null = values.file ? values.file[0] : null;
		if (file) {
			const keyRequest = await fetch("/api/key", {
				method: "GET",
				headers: {
					Authorization: `Bearer ${session.access_token}`,
				},
			});
			const keys = await keyRequest.json();
			const { cid } = await pinata.upload
				.file(file)
				.key(keys.JWT)
				.group(`${process.env.NEXT_PUBLIC_GROUP_ID}`);
			const data = new FormData();
			data.append("name", values.name);
			data.append("description", values.description);
			data.append(
				"video_url",
				`https://${process.env.NEXT_PUBLIC_GATEWAY_URL}/files/${cid}`,
			);
			const createVideoRequest = await fetch("/api/video", {
				method: "POST",
				headers: {
					Authorization: `Bearer ${session.access_token}`,
				},
				body: data,
			});
			const createVideo = await createVideoRequest.json();
			console.log(createVideo);
			setIsLoading(false);
			setOpen(false);
		} else {
			console.log("no file selected");
			setIsLoading(false);
		}
	}

	function ButtonLoading() {
		return (
			<Button disabled>
				<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
				Uploading...
			</Button>
		);
	}
	return (
		<Dialog open={open} onOpenChange={setOpen}>
			<DialogTrigger>
				<Button>Create Video</Button>
			</DialogTrigger>

			<DialogContent className="max-w-[350px] sm:max-w-[500px]">
				<DialogTitle>Create a Video</DialogTitle>
				<Form {...form}>
					<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
						<FormField
							control={form.control}
							name="name"
							render={({ field }) => (
								<FormItem>
									<FormLabel>Title</FormLabel>
									<FormControl>
										<Input placeholder="Walnut St Bridge" {...field} />
									</FormControl>
									<FormDescription>
										What's the title of your video?
									</FormDescription>
									<FormMessage />
								</FormItem>
							)}
						/>
						<FormField
							control={form.control}
							name="description"
							render={({ field }) => (
								<FormItem>
									<FormLabel>Description</FormLabel>
									<FormControl>
										<Input
											placeholder="A nice walk across the bridge to Coolidge Park"
											{...field}
										/>
									</FormControl>
									<FormDescription>
										Give your video a description
									</FormDescription>
									<FormMessage />
								</FormItem>
							)}
						/>
						<FormField
							control={form.control}
							name="file"
							render={({ field }) => (
								<FormItem>
									<FormLabel>Video File</FormLabel>
									<FormControl>
										<Input type="file" {...fileRef} />
									</FormControl>
									<FormDescription>Select your video file</FormDescription>
									<FormMessage />
								</FormItem>
							)}
						/>
						{isLoading ? (
							ButtonLoading()
						) : (
							<Button type="submit">Submit</Button>
						)}
					</form>
				</Form>
			</DialogContent>
		</Dialog>
	);
}

There’s a lot of code here due to building the UI, but the actual mechanics are pretty straight forward. Let’s look past a lot of the setup which gets the form working and look at the onSubmit function.

setIsLoading(true);
const file: File | null = values.file ? values.file[0] : null;
if (file) {
	const keyRequest = await fetch("/api/key");
	const keys = await keyRequest.json();
	const { cid } = await pinata.upload
		.file(file)
		.key(keys.JWT)
		.group(`${process.env.NEXT_PUBLIC_GROUP_ID}`);

First thing we do is set the loading state to true, then grab the file selected from the form. If there is a file, then we make an API request to /api/key which generates a temporary API key and uses it to upload the file to our public group. If you happened to read our previous blog post on Uploading Files with Next.js and Pinata, then this might look a bit different, and that’s because our file sizes will likely exceed the 4mb limit put in by Next.js for API routes - we will want to upload client side. We do this using that /key route, but we don’t have that API route built yet, so let’s do that now! Make a folder inside of the app directory called api, then make another folder inside of that one called key. Finally, make a file called route.ts with the following content:

import { type NextRequest, NextResponse } from "next/server";
const { v4: uuidv4 } = require("uuid");
import { pinata } from "@/lib/pinata";

export const dynamic = "force-dynamic";

export async function GET(req: NextRequest, res: NextResponse) {
	try {
		const uuid = uuidv4();
		const keyData = await pinata.keys.create({
			keyName: uuid.toString(),
			permissions: {
				endpoints: {
					pinning: {
						pinFileToIPFS: true,
					},
				},
			},
			maxUses: 1,
		});
		return NextResponse.json(keyData, { status: 200 });
	} catch (error) {
		console.log(error);
		return NextResponse.json(
			{ text: "Error creating API Key:" },
			{ status: 500 },
		);
	}
}

This is a really simple API endpoint that returns a temporary key we generate using the Pinata SDK; it’s scoped and it’s only good for one use, which is perfect for our use case. Now, going back to our onSubmit function, let’s see what happens after we upload our file.

// previous code that upload our file with temporary key
const data = new FormData();
data.append("name", values.name);
data.append("description", values.description);
data.append(
	"video_url",
	`https://${process.env.NEXT_PUBLIC_GATEWAY_URL}/files/${cid}`,
);
const createVideoRequest = await fetch("/api/video", {
	method: "POST",
	body: data,
});
const createVideo = await createVideoRequest.json();
console.log(createVideo);
setIsLoading(false);
setOpen(false);

With our video upload response on hand, we start a FormData request with our files, such as the name, description, and a video_url which we build using our Gateway domain, followed by the path /files and finally the cid returned from our upload. Then, we just send that payload to another API endpoint /api/video, so let’s build that now the same way we did earlier:

import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { supabase } from "@/lib/db";

export async function POST(request: NextRequest) {
	try {
		const formData = await request.formData();
		const name = formData.get("name");
		const description = formData.get("description");
		const videoUrl = formData.get("video_url");
		const { data, error } = await supabase
			.from("videos")
			.insert({
				name: name,
				description: description,
				video_url: videoUrl,
			})
			.select();
		console.log(data);
		if (error) {
			console.log(error);
			return NextResponse.json(
				{ error: "Internal Server Error" },
				{ status: 500 },
			);
		}

		return NextResponse.json(data, { status: 200 });
	} catch (e) {
		console.log(e);
		return NextResponse.json(
			{ error: "Internal Server Error" },
			{ status: 500 },
		);
	}
}

This one is pretty straight forward; grab all the fields from our API request payload and insert them as a row in our database. Easy!

Wrapping Up

With that, we have a fully functioning video wall for Chattanooga so that you can share all of your favorite moments! This is just the beginning, as there’s so much you can do with Pinata’s File API, and we encourage you to explore our docs.

Happy building!

Stay up to date

Join our newsletter for the latest stories & product updates from the Pinata community.

No spam, notifications only about new products, updates and freebies. You can always unsubscribe.