GradientHub Devlog

Tue Jan 25 2022 Fri Feb 18 2022

Starting from Overengineered

25 Jan 2022

When starting a project, it is best to begin with an idea and implement it as simply as possible to get it to market quickly. I am not doing that.

I was inspired to make a project that was at least complicated enough that I could use Kubernetes in production, have to think about scaling, and optimistically add Consul to the deployment. I work on Consul’s integration with Kubernetes, but I’ve never operated a production Kubernetes cluster. I think I should.

Today, I had the idea for this sufficiently complicated project that was still simple enough that I could build it on my own and write about it.

GradientHub

GradientHub is a social network where users can create and share gradients. Gradients are a very popular part of web design today and I think this is a fun way to play with that.

I like gradients as something users can create and display because I don’t have to worry about users uploading photos which are hard to store and vet for inappropriate content.

I registered the domain gradienthub.art for $4/year from Namecheap. Other domains were closer to $36/year and above and I just didn’t want to spend that money up front.

Architecture

The architecture here is the real goal. I’m making this more complicated than it needs to be so I can learn. I’m going to start with a React frontend using Next.js. This will be supported by three backend services:

  • Users: manages user profiles and which gradients they have made or liked.
  • Gradients: manages gradients, loading them from the database and showing users who’ve liked them. This can also be a caching layer.
  • Generator: generates random gradients.

I am going to use PostgreSQL as my database. I’ll use a managed instance outside of Kubernetes.

Getting something shipped

I believe the best way to get work shipped is to start it out with a pipeline deploying into a production environment. This way I know from the start how the project behaves in a production environment. I have had issues before with doing so much development locally that I end up with something I have to modify in unintuitive ways in order to get it to production.

My first task then is to get a base layer frontend, containerize it, and ship it to a Kubernetes cluster.

Setting up Kubernetes

I decided to use Google Cloud Platform to run my Kubernetes cluster. Just because.

I set up the cluster in us-east and set it to use the release channel Kubernetes. I’m doing this by clicking through the web UI. I should be doing this with Terraform. Maybe I can formalize a TF file for this configuration later.

I’m not quite sure what the cost will be for this deployment. I’m setting up a pricing alert to let me know if the cost exceeds $20/month.

Starting a Next.js Project

I am going to create a base Next.js project with Tailwind and TypeScript. I love TypeScript for frontend projects because it saves me from creating silly bugs.

I start off with

npx create-next-app@latest --ts

Yay!

The default NextJS App

Using TailwindCSS, I made a splash screen.

<div className="pt-40 flex flex-col items-center justify-center">
  <h1 className="mb-4 text-7xl font-semibold text-transparent bg-clip-text bg-gradient-to-tr from-pink-500 via-red-500 to-yellow-500">
    GradientHub
  </h1>
  <p>This is a work in progress.</p>
  <p>
    Check the{" "}
    <a
        href="https://github.com/t-eckert/gradienthub"
        target="_blank"
        rel="noopener noreferrer"
        className="font-medium bg-clip-text bg-gradient-to-tr from-pink-500 via-red-500 to-yellow-500 hover:text-transparent  transition"
    >
        GitHub repository
    </a>{" "}
    for more info.
  </p>
</div>

A splash screen which reads 'GradientHub This is a work in progress. Check the GitHub repository for more info.'

Containerizing

I’m going to containerize the frontend service and push it to DockerHub. I pulled the Dockerfile and configuration from the NextJS with Docker example.

I built the image and pushed it to DockerHub as tecke/gradienthub-frontend.

Deploying to Kubernetes

Now I need to tell Kubernetes to run 3 pods with that image. I connected the Google Cloud Kubernetes cluster to kubectl. Now I need to write a deployment file and apply it to the cluster.

Here is the frontend.yaml deployment file.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  labels:
    app: gradienthub-frontend
spec:
  replicas: 3
  selector:
    matchLabels:
      app: gradienthub-frontend
  template:
    metadata:
      labels:
        app: gradienthub-frontend
    spec:
      containers:
        - name: frontend
          image: tecke/gradienthub-frontend:latest
          ports:
            - containerPort: 3000

I applied the deployment and three replicas came right up!

Next, I need to create a Service to get a fixed IP to route requests to the pods. Eventually, I want to set all this up with the Consul API Gateway using the tech preview.

Here is frontend-service.yaml.

apiVersion: v1
kind: Service
metadata:
  name: frontend-service
spec:
  type: LoadBalancer
  selector:
    app: gradienthub-frontend
  ports:
    - port: 80
      targetPort: 3000

Deploying the Service of type LoadBalancer gave me a public IP of 34.75.24.253.

I was able to visit the site in the browser! That’s enough for the first day.

Changing Clouds

25 Jan 2022

Google Cloud is too expensive for me. After running yesterday’s deployment for 24 hours, I’ve run up a bill of $2. Extrapolating, that is $730/year. That’s too steep for me for a side project. While I know Kubernetes is more expensive to run than serverless or individual VMs, I feel pretty confident I can get Kubernetes up and running for cheaper.

Before digging in, I have 3 candidates I’m considering: Azure, Linode, and DigitalOcean. I have credits on Azure that will cover the cost. I have confidence in Linode from listening to The Changelog. I’ve used DigitalOcean for a client project before and I like their UI.

I think for now, I’ll move to Azure. I have $150 in monthly credits so I just won’t have to worry about cost for now.

Setting up Kubernetes on Azure

I’m going to give Terraform a go for setting up Kubernetes on Azure. I don’t know what I’m doing, but I have the internet to help.

I started from this walkthrough. To get to the point where this walkthrough starts, I had to install the Terraform CLI and the Azure CLI for authentication. I was a little confused on how to add a provider. I am guessing I should just have a separate azure.tf file that I run terraform plan on to set up?

After some consideration, I decided to just have everything for the infrastructure exist in an azure.tf file.

Altogether here is my azure.tf file.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "=2.93.1"
    }
  }
}

provider "azurerm" {
  features {}
}


resource "azurerm_resource_group" "gradienthub" {
  name     = "gradienthub"
  location = "East US 2"
}

resource "azurerm_kubernetes_cluster" "gradienthub-cluster-00" {
  name                      = "gradienthub-cluster-00"
  location                  = azurerm_resource_group.gradienthub.location
  resource_group_name       = azurerm_resource_group.gradienthub.name
  dns_prefix                = "gradienthub-cluster-00"
  automatic_channel_upgrade = "stable"

  default_node_pool {
    name       = "default"
    node_count = 1
    vm_size    = "Standard_D2_v2"
  }

  identity {
    type = "SystemAssigned"
  }

  tags = {
    environment = "production"
  }
}

That infrastructure deployment worked wonderfully.

Deployment

I ran the same deployment files that I wrote yesterday and got an external IP of 20.88.116.197. I’ll update my DNS then go to bed.

Designing User Experience

5 Feb 2022

Now I want to begin sketching out what the site will be like for a user. I have a lot of ideas floating around in my head, but I need to give them some form so I can see what works well and what doesn’t.

I like to begin with the mobile experience – what is it like to use the site on a phone. This is the most constrained experience so it helps with removing extrenuous information.

I’m going to take inspiration from Instagram here. The most important part of the site is browsing gradients. It’s also visually captivating and communicates a lot of information quickly. I could communicate the same information just as well by displaying a dense table of the CSS which creates the gradient, but that wouldn’t be engaging. I think that engagement comes from not making the brain work to hard to extrapolate on a concept. The trick is to give the brain information in a way that it can immediately interact with.

Ok, so stealing from Instagram. Cool. We’ll come back to that.

When a user first loads the site, they should be able to browse gradients. I don’t care if they are logged in. They will just see the most popular gradients. I’m not trying to convert users and have them make profiles. The profiles are just there so that people can save gradients and create them.

There are three modalities of browsing gradients I can immediately think of: popular, recent, and saved. Maybe some day I want users to be able to follow other users and see what those users create, but I’ll set that aside to avoid scope creep.

Without being logged in, the user will only have access to the popular and recent modalities. They will also need to have a clear way to create a profile or sign in.

I sketched out these two mobile views, one for a user who is not logged in, the other for a user who is.

UX sketch of mobile phone layout with user not logged in.

UX sketch of mobile user logged in

The desktop experience will be similar, but laid out as a grid. I’m going to go ahead and build that home screen to see how it feels.

Building the home page

For this sketched out home page, I’m going to work solely in the Next app that I created on day one. There won’t be any other services involved yet.

I think a solid place to start is a Gradient component. This component will take in a gradient encoded as CSS and render it.

A browser displays a gradient square

Incredible.

type Props = {
  gradient: string
  className?: string
}

const Gradient: React.FC<Props> = ({ gradient, className }) => {
  return <div className={className} style={{ background: gradient }} />
}

export default Gradient

Now we need the mock API which will always return this gradient. Later, this will make an actual call to a service which will fetch gradients from a database.

Fantastic and obnoxious.

import type { NextApiRequest, NextApiResponse } from 'next'

type Data = {
  gradient: string
}[]

const mockGradients: Data = []

for (let i = 0; i < 20; i++) {
  mockGradients.push({
    gradient: `linear-gradient(to right, #fad0c4 0%, #ffd1ff 100%)`,
  })
}


export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  res.status(200).json(mockGradients)
}

The home page now needs to hit this endpoint, /api/gradients, and display the gradient. I’m going to use React Query to do this as it has nice caching and whatnot that will be helpful later.

I’ll get back to that tomorrow though.

Sketching out the Frontend

5 Feb 2022

Homepage

Let’s start with a sketch of the homepage. It’s a starting point to getting work out there. I have the Gradient component from yesterday’s work, now I want to use it.

For this, I’m going to create a \“section\“. I normally arrange my component-based UIs into components, sections, and pages. Components are small and reusable. Sections contain structure and layout and are composed of components, but they aren’t full pages. A section might be a navigation bar or a form input. A page is a fully laid out webpage.

I’m going to call this section Gallery. It will handle fetching the gradients from the API and displaying them. I like having state managed at the section layer because it allows for flexibility and reuse while minimizing how much state managing components are running around the site.

As the user scales the browser the gradient previews go from inline to a grid.

Three browsers of different sizes where the gradient preview scales from a single column to a grid.

I deployed this by pushing the Docker image to DockerHub and then deleting all of the pods in my Kubernetes cluster and letting them come back up with the new image. This is silly. Don’t do this. I’m thinking I’ll replace this workflow with Waypoint. I’ve been wanting to check that out.

Improving the Mock Gradients

The next things I want to build are a page where a user will see a single gradient and a way for them to see popular or recent gradients. So I need to beef up the mock gradients.

So far, /api/gradients has just returned a set of JSON objects with the CSS gradient inside. Now, I’m going to expand the gradient object metadata with \“createdAt\”, \“createdBy\”, and \“likes\“. I’m also going to give each gradient a UUID. In the future, the UUID will be the primary key for the gradient in the database.

I started with the TypeScript type:

type Gradient = {
    id: string
    name: string
    gradient: string
    createdAt: Date
    createdBy: string
    likes: number
}

Now the API. I’m going to stick the mock gradients in a directory called \“fixtures\“. To flesh these out, I’m going to take gradients from uiGradients which is a fairly similar site to what I’m building. It’s open source and MIT licensed so I hope the creator feels ok with me taking some gradients for testing. I’m also letting GitHub Copilot generate some gradients and it does an ok job!

Sprucing up the Gallery

I now display the names of the gradients. I am so nervous about letting users name their gradients. I think it’s cool, but I need to add a way to check the names for innapropriate words and phrases. Mental note I guess. I also removed the word \“copy\” from the gradient previews. It looked so repeatative and I think the clipboard icon is common enough that the users of the site would get what it does.

I mocked out the Header and removed the navbar specific section. That’s now part of the header. I also added a thin line beneath the information about each gradient. I’m inspired here by Pantone swatches and Polaroids which both have this nice \“chin\” where metadata is displayed.

I think it’s starting to look good!

Layout of the full homepage

Though I’m going to comment out most of the Header aside from the word mark because I don’t want to expose features that are unfinished.

Gradient display pages

Now I want each Gradient to have a page where it is displayed solo. I created a page in Next at /gradients/[id].tsx. That will give me a dynamic page with access to the id of a particular gradient on that page.

I made the name of each gradient in the preview a link to this page. I had an issue doing this to the gradient itself so I’ll come back and do that later.

I added an API route /api/gradients/[id] which will return the gradient with the matching id. It’s a little simplistic, but it will work.

import type { NextApiRequest, NextApiResponse } from 'next'

import Gradient from '../../../types/Gradient'
import gradients from ".../../../fixtures/gradients"

export default function handler(req: NextApiRequest, res: NextApiResponse<Gradient>) {
    const { id } = req.query

    const gradient = gradients.find(g => g.id === id)

    if (!gradient) {
        res.status(404).end()
    } else {
        res.status(200).json(gradient)
    }
}

Excellent. Next I recreated the Preview component in a new context with a different layout. As I work on the site, I’m falling towards a design that mimics an art gallery. In the \“display\” page, the gradient is large, but doesn’t fill the whole screen necessarily. It’s allowed space to breathe. Honestly, I love it. It will change as I add likes as a real functionality and users, etc., but I think this is cool.

Single display page of an indigo gradient.

Multiple views of the site on a desktop.

I think that’s a solid day of work. I’m going to bed after I heathenistically yeet the code to DockerHub. You can see the site live at http://gradienthub.art/.

Completely Rethinking the Architecture

5 Feb 2022

I started this project with the goal of practicing my Kubernetes. I have gotten to do that a bit and enjoyed it. I came up with this idea of “GradientHub” as a gossamer veneer to wrap around the Kubernetes body of the project. However, I unexpectedly fell in love with the idea and I want to pursue it with my full energy. It never really made sense to overengineer the backend except for the purposes of learning. Now I want to change my focus to cutting out the complexity and running towards this project.

So I’m going to do things differently. I’ll keep the NextJS frontend I wrote and instead host it on Netlify. They will handle scaling and SSL certs and everything that I would have handled with the Kubernetes route. I can implement a backend within Next if I want to use TypeScript or using Lambda functions if I want to use something else. The backend was going to be very simple anyways – mostly just a CRUD API.

I’m excited.

Deploying to Netlify

I removed the Kubernetes deployment files and the Terraform to set up a Kubernetes cluster. I already host my website and one other project on Netlify so I just created a new project there and pointed it at the GitHub repository where this project is hosted.

I then changed the nameservers on Namecheap to use Netlify’s nameservers. I can finish setting up the domain after that change propogates. For now, I have a randomly generated URL of https://serene-curran-2b1915.netlify.app/.

Connecting to a Database

Now I want to set up a database have the frontend display gradients stored in that database. I’m going to use Supabase as the database because I heard the CEO, Paul Copplestone, on an episode of The Changelog recently and he made the product sound so cool! It also uses Postgres and I like Postgres.

I was able to log in easily with GitHub. (As a sidenote, I think I’m going to use the \“magic link\” sign in method for GradientHub. So much less to think about and there isn’t a point to connecting this to another SSO or having email and password.)

I really like the user experience here. A lot of the things I don’t need immediately are just out of the way. There is a little callout \“If your dashboard hasn’t connected within 2 minutes, send us an email: support@supabase.io\”. That gives me so much confidence that they care and are paying attention. (Azure Kubernetes Service’s 1 hour to deploy a PVC can see me after class.)

There is a reference repository for NextJS auth. Cool. I added the supabase package to the project. Browsing the reference repository, I wasn’t quite sure which changes I needed to make to get up and running with my project so I found Nader Dabit’s video on Magic Link Auth

That was a super helpful video. I highly recommend it. I was able to take that walkthrough and add magic link auth very easily!

Now there is a sign in page.

Webpage which allows a user to sign in with a magic link

When a user clicks their magic link, they will be redirected to the \“profile\” page of the site. For now this just lists a bunch of gradients and allows a user to sign out.

Mocked up profile page with a bunch of gradients and a sign out button

Gradients in the Database

18 Feb 2022

Now that we have authentication set up with Supabase, I’m going to create a table in the database of gradients. For now, I’ll just replicate the gradients used to mock up the site. Later, I’ll need to add in functionality around “ownership” of gradients and figuring out which users favorited which gradients.

I formatted “likes” as a signed 4 byte int. This will be fine so long as I don’t have more than 2 trillion users. While I like to believe in myself, I don’t foresee this as being an issue. If it is, there will be other, larger, problems to solve.

I also added a field for “isDeleted” as a soft delete functionality. If there is some kind of mobbing of the site with inappropriate content, perhaps spamming something in the title of the gradients, I can quickly perform a soft delete of all potentially inappropriate content with a script without fear that I would inadvertently delete someone’s legitimate content permanently.

Supabase has a great interface for adding tables.

Creating the Gradient table

Fetching the Gradients

I’m going to fetch the gradients by creating a function in the “actions” directory (the way I structure my React code, actions are functions which are usually asynchronous and could fail as they depend on external sources). That function will take a string which must match the Sort type:

type Sort = "MostPopular" 
    | "LeastPopular" 
    | "Newest" 
    | "Oldest" 
    | "A-Z" 
    | "Z-A"

And that will select which private function in the fetchGradient module gets called.

For this request to work, I had to turn off row level security. I’m not sure why.

I was able to replace the API endpoint with this clever little code.

const db = "gradients"

type Order = {
    column: string,
    ascending: boolean
}

const sortOrders = new Map<Sort, Order>(
	[
		["MostPopular", { column: "likes", ascending: false }],
		["LeastPopular", { column: "likes", ascending: true }],
		["Newest", { column: "createdAt", ascending: false }],
		["Oldest", { column: "createdAt", ascending: true }],
		["A-Z", { column: "name", ascending: true }],
		["Z-A", { column: "name", ascending: false }]
	]
)


export default async (sort: Sort): Promise<Gradient[] | null> => {
	const order = sortOrders.get(sort)

	if (!order) {
		return null
	}

	const { data } = await client.from(db).select()
        .order(order.column, { ascending: order.ascending })
	return data
}

Replicating Supabase locally

To get this work done, I want to replicate the Supabase environment in my local development environment. Supabase does provide a way to do so, but I’m stuck on this error when I try to clone the database down.

Error: ERROR: prepared statement "lrupsc_1_0" already exists (SQLSTATE 42P05)

I took a break and opened an issue on the Supabase CLI repository (which support the local development environment) and got such a clear answer so quickly.

Working with Supabase locally is amazing once you figure it out. There are still some things I had to figure out on my own, but once I got the hang of it, I was able to get a nice replica of my database with some seed data.

With some seeded data, I really love how the site is shaping up.

The homepage of GradientHub with many cool gradients in a minimalistic overall feel.

Copying Gradients to the Clipboard

This is one of the main features of the site. A creator should be able to copy any gradient to use in their own projects. I added an \“action\” called \“copy\” which will just copy the gradient to the clipboard. It’s relatively simple and I’m not sure it even makes sense to abstract it out into a separate module, but whatever. At least if I need to modify it for browsers which haven’t implemented this API yet, I can do that easily.

export default async (text: string): Promise<boolean> => {
	try {
		await navigator.clipboard.writeText(text)
		return true
	} catch (error) {
		return false
	}
}

In the gradient preview component, when a user clicks the clipboard button, this function will be called, but I haven’t figured out how to run it async from there. It works and I will come back to make it better.

I also want the user to get some feedback when they click the copy button so I added an Alerts component to the site which sits on every page and listens for alerts using a useState hook. However, the Alerts component isn’t rendering the alerts when they are added to the list of alerts. I’m betting it’s just not getting the trigger to rerender.

To get this working, I can do one of several things in order of increasing complexity

  • Pass the state down through the components.
  • Create a context manually.
  • Use a state manager like Mobx or Redux.

I think a state manager is overkill at this point because the only state I need so far is for alerts. User state is stored in local storage so I don’t need it for that. I think the context method would be cleaner than passing the state around everywhere and more extensible.

I made a context which contains the alerts and a function which will be wired up to a useState hook to update the alerts.

type Ctx = {
	alerts: Alert[],
	setAlerts: (alerts: Alert[]) => void,
}

const AlertsContext = createContext<Ctx>({ alerts: [], setAlerts: () => { } })

I can then use the AlertsContext provider to wrap all of the components in the site.

const [alerts, setAlerts] = useState<AlertType[]>([])

return (
	<QueryClientProvider client={queryClient}>
		<Head>
			<title>GradientHub</title>
		</Head>
		<AlertsContext.Provider value={{ alerts, setAlerts }}>
			<Component {...pageProps} />
			<Alerts />
		</AlertsContext.Provider>
	</QueryClientProvider>
)

I can then use this context from within the useAlerts hook.

export default function useAlerts(): { alerts: Alert[], addAlert: (alert: Alert) => void } {
	const { alerts, setAlerts } = useContext(AlertsContext)

	const addAlert = (alert: Alert) => {
		setAlerts(alerts.concat(alert))
	}

	return { alerts, addAlert }
}

With this problem solved, it is clearly time to solve the problem of automatically removing the alert after a timeout.

Alerts which do not disappear fill up the entire vertical space of the screen

For this, I need to give each Alert a unique id. I thought I could get away with just using the index of the alert in the array, but I couldn’t figure out a way for that to work. I’ll just give each one a cute little random id and filter on that when I remove the alert.

I made a function which can generate an id. I’ll keep it in a different module so I can reuse it later. It’s sufficiently random for my case.

export default function generateId(): string {
	return Math.random().toString(36)
}

Hm, actually, after going down this road for a bit, I realized that it only makes sense to show the latest alert at any given time. Alerts are used on this site to let you know the last thing you did. It doesn’t really make sense to stack them and that adds other complexities for rendering, animation, etc. So I’m just going to have a single alert that will disappear after a time or get overwritten if a new alert comes in. Cool.

I changed useAlert to this.

export default function useAlert(): { 
    alert: Alert | null, setAlert: (message: string) => void 
} {
	const { alert, setAlert } = useContext(AlertsContext)

	const setAlertFromString = (message: string) => {
		const id = Math.random().toString(26).slice(2)
		setAlert({ id, message })
		/* TODO: there is a bug here.
           If the user clicks a second copy button,
           the alert will still be cleared by the first call.
        */
		setTimeout(() => setAlert(null), 2500)
	}

	return { alert, setAlert: setAlertFromString }
}

As you can see, I noted a little bug on that timeout code that I’ll come back and fix. If a user clicks \“copy\” on one gradient a 2.5 second timeout to clear the alert will begin. If the user then clicks another gradient to copy within that window, the alert will be cleared by the first timeout. It’s worth fixing, but it’s ok to ship for this small project.

Animating the Alert

I want the alert to scale in and fade out. In the past, I’ve used React Spring for animations, but I’ve been hearing a lot about Framer Motion and I want to check that out.

Framer Motion was very easy to set up and run. Now the Alert section looks like this.

export default function Alert() {
	const { alert } = useAlert()

	return (
		<section className="pointer-events-none fixed bottom-4 z-10 flex
        w-full flex-col items-center gap-2">
			<AnimatePresence>
				{alert && (
					<motion.span
						initial={{ opacity: 0, scale: 0.1 }}
						animate={{ opacity: 1, scale: 1, transition: {
                            duration: 0.2 } }}
						exit={{ opacity: 0 }}
						className="border border-slate-700 bg-white/10 px-4
                        py-2 font-medium text-slate-900 backdrop-blur-3xl"
					>
						{alert.message}
					</motion.span>
				)}
			</AnimatePresence>
		</section>
	)
}

Designing a Logo

The logo for GradientHub was pretty obvious to me from the start. I want to use the gradient symbol from math, the nabla: ∇. It’s a nice visual pun. The design is simple with a downward pointing triangle. It’s sometimes rendered with the right downward side of the triangle thinner than the other two. This add some character.

I’m going to design it in in Figma as an SVG. That way, down the line I can improve the look of the logo by having it act as a frame for a gradient. I’ll mock up some illustrations of this to show what I mean.

I started by making the logo with 2 triangles to get the angles just right. I then traced the outline with rectangle shapes in order to get a shape that can be drawn with a transparent center.

Tracing the logo in rectangle shapes.

Here is the final product with some demo images of what the logo looks like as a frame for different gradients. One of my stretch goals for the project is to have the logo be filled with a different random gradient from a creator at some regular interval. Just to add some spice.

Layout of logos resembling a downwards facing triangle with gradient backgrounds.

I have to say I’m not crazy about how the favicon looks in use. It falls a little flat and looks unprofessional. But, I’ll come back to it. We are still working our way towards a minimum viable product.

Insert of the favicon in use in the browser.

I think I’ll wrap up there for now. There is still a lot to do before I can call this a minimum viable product, but I like the progress I’m making so far.