Теперь, когда мой личный сайт находится в Vercel и написан на Next.js, я решил переработать свою страницу сейчас, используя различные социальные API. Я начал с того, что просмотрел различные платформы, которые я регулярно использую, и отследил те, которые предоставляют доступ к API или RSS-каналы. Для тех, у кого есть API, я написал код для доступа к своим данным через указанные API, для тех, у кого есть только фиды, я использовал @extractus/feed-extractor, чтобы преобразовать их в ответы JSON.

Шаблон /now в моем каталоге pages выглядит следующим образом:

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-primary-500 hover:text-primary-600 dark:hover:text-primary-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-primary-500 hover:text-primary-600 dark:hover:text-primary-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-primary-500 hover:text-primary-600 dark:hover:text-primary-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-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
                            href="https://nownownow.com/about"
                            target="_blank"
                            rel="noopener noreferrer"
                        >
                            now page
                        </Link>
                        , and if you have your own site, <Link
                            className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
                            href="https://nownownow.com/about"
                            target="_blank"
                            rel="noopener noreferrer"
                        >
                            you should make one, too
                        </Link>
                        .)
                    </p>
                </div>
            </div>
        </>
    )
}

Вы увидите, что верхняя часть в основном статична, с текстом, стилизованным с помощью Tailwind, и соответствующими значками из пакета Hero Icons. Мы также экспортируем экземпляр getStaticProps, который перепроверяется каждый час и вызывает метод из моего каталога lib под названием loadNowData. loadNowData принимает строку с разделителями-запятыми в качестве аргумента, чтобы указать, какие свойства я хочу вернуть в составном объекте из этого метода 1. Метод выглядит так 2:

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 = `http://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 = `http://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 = `http://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
}

Отдельные медиа-компоненты текущей страницы просты и презентативны, например, Albums.tsx:

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

Этот компонент и Artists.tsx используют Cover.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-primary-500 hover:text-primary-600 dark:hover:text-primary-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-primary-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

Все компоненты этой страницы можно посмотреть на GitHub. Каждый из них использует объект из объекта loadNowData и отображает его на странице. Страница также периодически перепроверяется через маршрут API, который просто вызывает этот же метод:

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)
}

И, со всем этим, у нас есть страница с небольшим трафиком, которая обновляется (за некоторыми исключениями), когда я рассказываю о своих привычках использования Last.fm, Trakt, Letterboxd, Oku и так далее.

Сноски

  1. Я знаю о GraphQL, но здесь мы будем иметь дело со старыми простыми вызовами fetch.
  2. Он также используется в индексном представлении моего сайта для получения моего статуса, текущей воспроизводимой дорожки и книг, которые я сейчас читаю.