This repository has been archived on 2025-03-28. You can view files and clone it, but cannot push or open issues or pull requests.
coryd.dev-eleventy/src/posts/2023/building-a-now-page-using-nextjs-and-social-apis.md
2023-10-28 17:26:30 -07:00

358 lines
13 KiB
Markdown

---
date: '2023-02-20'
title: 'Building a now page using Next.js and social APIs'
draft: false
tags: ['Next.js', 'React', 'API']
---
With my personal site now sitting at Vercel and written in Next.js I decided to rework my [now](https://coryd.dev/now) page by leveraging a variety of social APIs. I kicked things off by looking through various platforms I use regularly and tracking down those that provide either API access or RSS feeds. For those with APIs I wrote code to access my data via said APIs, for those with feeds only I've leveraged [@extractus/feed-extractor](https://www.npmjs.com/package/@extractus/feed-extractor) to transform them to JSON responses.<!-- excerpt -->
The `/now` template in my `pages` directory looks like the following:
```jsx
import siteMetadata from '@/data/siteMetadata'
import loadNowData from '@/lib/now'
import { useJson } from '@/hooks/useJson'
import Link from 'next/link'
import { PageSEO } from '@/components/SEO'
import { Spin } from '@/components/Loading'
import {
MapPinIcon,
CodeBracketIcon,
MegaphoneIcon,
CommandLineIcon,
} from '@heroicons/react/24/solid'
import Status from '@/components/Status'
import Albums from '@/components/media/Albums'
import Artists from '@/components/media/Artists'
import Reading from '@/components/media/Reading'
import Movies from '@/components/media/Movies'
import TV from '@/components/media/TV'
const env = process.env.NODE_ENV
let host = siteMetadata.siteUrl
if (env === 'development') host = 'http://localhost:3000'
export async function getStaticProps() {
return {
props: await loadNowData('status,artists,albums,books,movies,tv'),
revalidate: 3600,
}
}
export default function Now(props) {
const { response, error } = useJson(`${host}/api/now`, props)
const { status, artists, albums, books, movies, tv } = response
if (error) return null
if (!response) return <Spin className="my-2 flex justify-center" />
return (
<>
<PageSEO title={`Now - ${siteMetadata.author}`} description={siteMetadata.description.now} />
<div className="divide-y divide-gray-200 dark:divide-gray-700">
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
Now
</h1>
</div>
<div className="pt-12">
<h3 className="text-xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-2xl sm:leading-10 md:text-4xl md:leading-14">
Currently
</h3>
<div className="pl-5 md:pl-10">
<Status status={status} />
<p className="mt-2 text-lg leading-7 text-gray-500 dark:text-gray-100">
<MapPinIcon className="mr-1 inline h-6 w-6" />
Living in Camarillo, California with my beautiful family, 4 rescue dogs and a guinea pig.
</p>
<p className="mt-2 text-lg leading-7 text-gray-500 dark:text-gray-100">
<CodeBracketIcon className="mr-1 inline h-6 w-6" />
Working at <Link
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
href="https://hashicorp.com"
target="_blank"
rel="noopener noreferrer"
>
HashiCorp
</Link>
</p>
<p className="mt-2 text-lg leading-7 text-gray-500 dark:text-gray-100">
<MegaphoneIcon className="mr-1 inline h-6 w-6" />
Rooting for the{` `}
<Link
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
href="https://lakers.com"
target="_blank"
rel="noopener noreferrer"
>
Lakers
</Link>
, for better or worse.
</p>
</div>
<h3 className="pt-6 text-xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-2xl sm:leading-10 md:text-4xl md:leading-14">
Making
</h3>
<div className="pl-5 md:pl-10">
<p className="mt-2 text-lg leading-7 text-gray-500 dark:text-gray-100">
<CommandLineIcon className="mr-1 inline h-6 w-6" />
Hacking away on random projects like this page, my <Link
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
href="/blog"
passHref
>
blog
</Link> and whatever else I can find time for.
</p>
</div>
<Artists artists={artists} />
<Albums albums={albums} />
<Reading books={books} />
<Movies movies={movies} />
<TV tv={tv} />
<p className="pt-8 text-center text-xs text-gray-900 dark:text-gray-100">
(This is a{' '}
<Link
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
href="https://nownownow.com/about"
target="_blank"
rel="noopener noreferrer"
>
now page
</Link>
, and if you have your own site, <Link
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
href="https://nownownow.com/about"
target="_blank"
rel="noopener noreferrer"
>
you should make one, too
</Link>
.)
</p>
</div>
</div>
</>
)
}
```
You'll see that the top section is largely static, with text styled using Tailwind and associated icons from the [Hero Icons](https://heroicons.com) package. We're also exporting an instance of `getStaticProps` that's revalidated every hour and makes a call to a method exposed from my `lib` directory called `loadNowData`. `loadNowData` takes a comma delimited string as an argument to indicate which properties I want returned in the composed object from that method[^1]. The method looks like this[^2]:
```typescript
import { extract } from '@extractus/feed-extractor'
import siteMetadata from '@/data/siteMetadata'
import { Albums, Artists, Status, TransformedRss } from '@/types/api'
import { Tracks } from '@/types/api/tracks'
export default async function loadNowData(endpoints?: string) {
const selectedEndpoints = endpoints?.split(',') || null
const TV_KEY = process.env.API_KEY_TRAKT
const MUSIC_KEY = process.env.API_KEY_LASTFM
const env = process.env.NODE_ENV
let host = siteMetadata.siteUrl
if (env === 'development') host = 'http://localhost:3000'
let statusJson = null
let artistsJson = null
let albumsJson = null
let booksJson = null
let moviesJson = null
let tvJson = null
let currentTrackJson = null
// status
if ((endpoints && selectedEndpoints.includes('status')) || !endpoints) {
const statusUrl = 'https://api.omg.lol/address/cory/statuses/'
statusJson = await fetch(statusUrl)
.then((response) => response.json())
.catch((error) => {
console.log(error)
return {}
})
}
// artists
if ((endpoints && selectedEndpoints.includes('artists')) || !endpoints) {
const artistsUrl = `https://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user=cdme_&api_key=${MUSIC_KEY}&limit=8&format=json&period=7day`
artistsJson = await fetch(artistsUrl)
.then((response) => response.json())
.catch((error) => {
console.log(error)
return {}
})
}
// albums
if ((endpoints && selectedEndpoints.includes('albums')) || !endpoints) {
const albumsUrl = `https://ws.audioscrobbler.com/2.0/?method=user.gettopalbums&user=cdme_&api_key=${MUSIC_KEY}&limit=8&format=json&period=7day`
albumsJson = await fetch(albumsUrl)
.then((response) => response.json())
.catch((error) => {
console.log(error)
return {}
})
}
// books
if ((endpoints && selectedEndpoints.includes('books')) || !endpoints) {
const booksUrl = `${host}/feeds/books`
booksJson = await extract(booksUrl).catch((error) => {
console.log(error)
return {}
})
}
// movies
if ((endpoints && selectedEndpoints.includes('movies')) || !endpoints) {
const moviesUrl = `${host}/feeds/movies`
moviesJson = await extract(moviesUrl).catch((error) => {
console.log(error)
return {}
})
moviesJson.entries = moviesJson.entries.splice(0, 5)
}
// tv
if ((endpoints && selectedEndpoints.includes('tv')) || !endpoints) {
const tvUrl = `${host}/feeds/tv?slurm=${TV_KEY}`
tvJson = await extract(tvUrl).catch((error) => {
console.log(error)
return {}
})
tvJson.entries = tvJson.entries.splice(0, 5)
}
// current track
if ((endpoints && selectedEndpoints.includes('currentTrack')) || !endpoints) {
const currentTrackUrl = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=cdme_&api_key=${MUSIC_KEY}&limit=1&format=json&period=7day`
currentTrackJson = await fetch(currentTrackUrl)
.then((response) => response.json())
.catch((error) => {
console.log(error)
return {}
})
}
const res: {
status?: Status
artists?: Artists
albums?: Albums
books?: TransformedRss
movies?: TransformedRss
tv?: TransformedRss
currentTrack?: Tracks
} = {}
if (statusJson) res.status = statusJson.response.statuses.splice(0, 1)[0]
if (artistsJson) res.artists = artistsJson?.topartists.artist
if (albumsJson) res.albums = albumsJson?.topalbums.album
if (booksJson) res.books = booksJson?.entries
if (moviesJson) res.movies = moviesJson?.entries
if (tvJson) res.tv = tvJson?.entries
if (currentTrackJson) res.currentTrack = currentTrackJson?.recenttracks?.track?.[0]
// unified response
return res
}
```
The individual media components of the now page are simple and presentational, for example, `Albums.tsx`:
```jsx
import Cover from '@/components/media/display/Cover'
import { Spin } from '@/components/Loading'
import { Album } from '@/types/api'
const Albums = (props: { albums: Album[] }) => {
const { albums } = props
if (!albums) return <Spin className="my-12 flex justify-center" />
return (
<>
<h3 className="pt-4 pb-4 text-xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-2xl sm:leading-10 md:text-4xl md:leading-14">
Listening: albums
</h3>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{albums?.map((album) => (
<Cover key={album.mbid} media={album} type="album" />
))}
</div>
</>
)
}
export default Albums
```
This component and `Artists.tsx` leverage `Cover.tsx`, which renders music related elements:
```tsx
import { Media } from '@/types/api'
import ImageWithFallback from '@/components/ImageWithFallback'
import Link from 'next/link'
import { ALBUM_DENYLIST } from '@/utils/constants'
const Cover = (props: { media: Media; type: 'artist' | 'album' }) => {
const { media, type } = props
const image = (media: Media) => {
let img = ''
if (type === 'album')
img = !ALBUM_DENYLIST.includes(media.name.replace(/\s+/g, '-').toLowerCase())
? media.image[media.image.length - 1]['#text']
: `/media/artists/${media.name.replace(/\s+/g, '-').toLowerCase()}.jpg`
if (type === 'artist')
img = `/media/artists/${media.name.replace(/\s+/g, '-').toLowerCase()}.jpg`
return img
}
return (
<Link
className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400"
href={media.url}
target="_blank"
rel="noopener noreferrer"
title={media.name}
>
<div className="relative">
<div className="absolute left-0 top-0 h-full w-full rounded-lg border border-blue-500 bg-cover-gradient dark:border-gray-500"></div>
<div className="absolute left-1 bottom-2 drop-shadow-md">
<div className="px-1 text-xs font-bold text-white">{media.name}</div>
<div className="px-1 text-xs text-white">
{type === 'album' ? media.artist.name : `${media.playcount} plays`}
</div>
</div>
<ImageWithFallback
src={image(media)}
alt={media.name}
className="rounded-lg"
width="350"
height="350"
/>
</div>
</Link>
)
}
export default Cover
```
All the components for this page [can be viewed on GitHub](https://github.com/cdransf/coryd.dev/tree/main/components/media). Each one consumes an object from the `loadNowData` object and renders it to the page. The page is also periodically revalidated via an api route that simply calls this same method:
```ts
import loadNowData from '@/lib/now'
export default async function handler(req, res) {
res.setHeader('Cache-Control', 's-maxage=3600, stale-while-revalidate')
const endpoints = req.query.endpoints
const response = await loadNowData(endpoints)
res.json(response)
}
```
And, with all of that in place, we have a lightly trafficked page that updates itself (with a few exceptions) as I go about my habits of using Last.fm, Trakt, Letterboxd, Oku and so forth.
[^1]: I know about GraphQL, but we're just going to deal with plain old fetch calls here.
[^2]: It's also leveraged on the index view of my site to fetch my status, currently playing track and the books I'm currently reading.