mirror of
				https://github.com/r-freeman/portfolio.git
				synced 2025-11-04 03:51:12 +00:00 
			
		
		
		
	Drop spotify integration
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				Build And Publish / BuildAndPublish (push) Successful in 3m10s
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	Build And Publish / BuildAndPublish (push) Successful in 3m10s
				
			This commit is contained in:
		
							parent
							
								
									d420655464
								
							
						
					
					
						commit
						b7fa177d94
					
				@ -1,6 +1,3 @@
 | 
				
			|||||||
SPOTIFY_CLIENT_ID=
 | 
					 | 
				
			||||||
SPOTIFY_CLIENT_SECRET=
 | 
					 | 
				
			||||||
SPOTIFY_REFRESH_TOKEN=
 | 
					 | 
				
			||||||
NEXT_PUBLIC_SITE_URL=
 | 
					NEXT_PUBLIC_SITE_URL=
 | 
				
			||||||
GITHUB_ACCESS_TOKEN=
 | 
					GITHUB_ACCESS_TOKEN=
 | 
				
			||||||
GITHUB_USER_ID=
 | 
					GITHUB_USER_ID=
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,6 @@ and skills, as well as provide information about me and my interests.
 | 
				
			|||||||
- Database: [Supabase](https://supabase.com/)
 | 
					- Database: [Supabase](https://supabase.com/)
 | 
				
			||||||
- Deployment: [Self-Hosted on Raspberry Pi 5](https://ryanfreeman.dev/writing/migrating-from-vercel-to-raspberry-pi-5)
 | 
					- Deployment: [Self-Hosted on Raspberry Pi 5](https://ryanfreeman.dev/writing/migrating-from-vercel-to-raspberry-pi-5)
 | 
				
			||||||
- Styling: [Tailwind CSS](https://tailwindcss.com/)
 | 
					- Styling: [Tailwind CSS](https://tailwindcss.com/)
 | 
				
			||||||
- Integrations: [Spotify](https://spotify.com/)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Project structure
 | 
					## Project structure
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,54 +0,0 @@
 | 
				
			|||||||
import {NextResponse} from 'next/server'
 | 
					 | 
				
			||||||
import {getCurrentlyPlaying} from '@/lib/spotify'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Song = {
 | 
					 | 
				
			||||||
    item: {
 | 
					 | 
				
			||||||
        album: {
 | 
					 | 
				
			||||||
            name: string
 | 
					 | 
				
			||||||
            images: [
 | 
					 | 
				
			||||||
                {
 | 
					 | 
				
			||||||
                    url: string
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            ]
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        artists: [
 | 
					 | 
				
			||||||
            {
 | 
					 | 
				
			||||||
                name: string
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
        external_urls: {
 | 
					 | 
				
			||||||
            spotify: string
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        name: string
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    is_playing: boolean
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export async function GET(request: Request) {
 | 
					 | 
				
			||||||
    const response = await getCurrentlyPlaying()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (response.status === 204 || response.status > 400) {
 | 
					 | 
				
			||||||
        return new Response(JSON.stringify({isPlaying: false}), {
 | 
					 | 
				
			||||||
            status: 200
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const song = await response.json() as Song
 | 
					 | 
				
			||||||
    const {item} = song
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const artist = item.artists.map(artist => artist.name).join(', ')
 | 
					 | 
				
			||||||
    const title = item.name;
 | 
					 | 
				
			||||||
    const songUrl = item.external_urls.spotify
 | 
					 | 
				
			||||||
    const album = item.album.name
 | 
					 | 
				
			||||||
    const albumImageUrl = item.album.images[0].url
 | 
					 | 
				
			||||||
    const isPlaying = song.is_playing;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return NextResponse.json({
 | 
					 | 
				
			||||||
        artist,
 | 
					 | 
				
			||||||
        title,
 | 
					 | 
				
			||||||
        songUrl,
 | 
					 | 
				
			||||||
        album,
 | 
					 | 
				
			||||||
        albumImageUrl,
 | 
					 | 
				
			||||||
        isPlaying
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,56 +0,0 @@
 | 
				
			|||||||
import {NextResponse} from 'next/server'
 | 
					 | 
				
			||||||
import {getRecentlyPlayed} from '@/lib/spotify'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Tracks = {
 | 
					 | 
				
			||||||
    items: [
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            track: {
 | 
					 | 
				
			||||||
                name: string
 | 
					 | 
				
			||||||
                artists: [
 | 
					 | 
				
			||||||
                    {
 | 
					 | 
				
			||||||
                        name: string
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                ]
 | 
					 | 
				
			||||||
                external_urls: {
 | 
					 | 
				
			||||||
                    spotify: string
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
                album: {
 | 
					 | 
				
			||||||
                    name: string
 | 
					 | 
				
			||||||
                    images: [
 | 
					 | 
				
			||||||
                        {
 | 
					 | 
				
			||||||
                            url: string
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
                    ]
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            played_at: string
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export async function GET(request: Request) {
 | 
					 | 
				
			||||||
    const response = await getRecentlyPlayed()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (response.status > 400) {
 | 
					 | 
				
			||||||
        return new Response(JSON.stringify({status: response.statusText}), {
 | 
					 | 
				
			||||||
            status: response.status
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const tracks = await response.json() as Tracks
 | 
					 | 
				
			||||||
    const {track} = tracks.items.reduce((r, a) => r.played_at > a.played_at ? r : a)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const title = track.name;
 | 
					 | 
				
			||||||
    const artist = track.artists.map(artist => artist.name).join(', ')
 | 
					 | 
				
			||||||
    const songUrl = track.external_urls.spotify
 | 
					 | 
				
			||||||
    const album = track.album.name
 | 
					 | 
				
			||||||
    const albumImageUrl = track.album.images[0].url
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return NextResponse.json({
 | 
					 | 
				
			||||||
        artist,
 | 
					 | 
				
			||||||
        title,
 | 
					 | 
				
			||||||
        songUrl,
 | 
					 | 
				
			||||||
        album,
 | 
					 | 
				
			||||||
        albumImageUrl
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,7 +1,6 @@
 | 
				
			|||||||
import React from 'react'
 | 
					import React from 'react'
 | 
				
			||||||
import {InnerContainer, OuterContainer} from './Container'
 | 
					import {InnerContainer, OuterContainer} from './Container'
 | 
				
			||||||
import {NavLink} from '@/components/ui/Navigation'
 | 
					import {NavLink} from '@/components/ui/Navigation'
 | 
				
			||||||
import {SpotifyPlayer} from '@/components/ui/SpotifyPlayer'
 | 
					 | 
				
			||||||
import {SocialLink} from '@/components/ui/SocialLink'
 | 
					import {SocialLink} from '@/components/ui/SocialLink'
 | 
				
			||||||
import {GitHubIcon, LinkedInIcon} from '@/components/icons/SocialIcons'
 | 
					import {GitHubIcon, LinkedInIcon} from '@/components/icons/SocialIcons'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -10,11 +9,8 @@ export function Footer() {
 | 
				
			|||||||
    return (
 | 
					    return (
 | 
				
			||||||
        <footer className="mt-32">
 | 
					        <footer className="mt-32">
 | 
				
			||||||
            <OuterContainer>
 | 
					            <OuterContainer>
 | 
				
			||||||
                <div className="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40">
 | 
					                <div className="border-t border-zinc-100 pb-16 dark:border-zinc-700/40">
 | 
				
			||||||
                    <InnerContainer>
 | 
					                    <InnerContainer>
 | 
				
			||||||
                        {process.env.NODE_ENV !== 'development' &&
 | 
					 | 
				
			||||||
                            <SpotifyPlayer/>
 | 
					 | 
				
			||||||
                        }
 | 
					 | 
				
			||||||
                        <div className="flex flex-col items-center justify-between gap-6 mt-12">
 | 
					                        <div className="flex flex-col items-center justify-between gap-6 mt-12">
 | 
				
			||||||
                            <div
 | 
					                            <div
 | 
				
			||||||
                                className="flex flex-wrap justify-center gap-6 text-sm font-medium text-zinc-800 dark:text-zinc-200">
 | 
					                                className="flex flex-wrap justify-center gap-6 text-sm font-medium text-zinc-800 dark:text-zinc-200">
 | 
				
			||||||
 | 
				
			|||||||
@ -1,124 +0,0 @@
 | 
				
			|||||||
'use client'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import useSWR from 'swr'
 | 
					 | 
				
			||||||
import fetcher from '@/lib/fetcher'
 | 
					 | 
				
			||||||
import Image from 'next/image'
 | 
					 | 
				
			||||||
import clsx from 'clsx'
 | 
					 | 
				
			||||||
import {ElementType, ReactElement} from 'react'
 | 
					 | 
				
			||||||
import Link from 'next/link'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Artist = {
 | 
					 | 
				
			||||||
    as?: ElementType
 | 
					 | 
				
			||||||
    artist: string
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Title = {
 | 
					 | 
				
			||||||
    as?: ElementType
 | 
					 | 
				
			||||||
    title: string
 | 
					 | 
				
			||||||
    songUrl: string
 | 
					 | 
				
			||||||
    className?: string
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Album = {
 | 
					 | 
				
			||||||
    album: string
 | 
					 | 
				
			||||||
    albumImageUrl: string
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Song = {
 | 
					 | 
				
			||||||
    as?: ElementType
 | 
					 | 
				
			||||||
} & Artist & Title & Album
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type PlayerStateResponse = {
 | 
					 | 
				
			||||||
    data: Song
 | 
					 | 
				
			||||||
    error: string
 | 
					 | 
				
			||||||
    isLoading: boolean
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function usePlayerState(path: string) {
 | 
					 | 
				
			||||||
    const {data, error, isLoading} = useSWR(`/api/spotify/${path}`, fetcher) as PlayerStateResponse
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
        song: data,
 | 
					 | 
				
			||||||
        isLoading,
 | 
					 | 
				
			||||||
        isError: error
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
function Song({as: Component = 'div', artist, title, songUrl, album, albumImageUrl}: Song) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
        <Component
 | 
					 | 
				
			||||||
            className="flex items-center space-x-4">
 | 
					 | 
				
			||||||
            <Song.Album
 | 
					 | 
				
			||||||
                album={album}
 | 
					 | 
				
			||||||
                albumImageUrl={albumImageUrl}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            <div>
 | 
					 | 
				
			||||||
                <Song.Title
 | 
					 | 
				
			||||||
                    title={title}
 | 
					 | 
				
			||||||
                    songUrl={songUrl}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
                <Song.Artist artist={artist}/>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </Component>
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Song.Album = function SongAlbum({album, albumImageUrl}: Album) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
        <Image
 | 
					 | 
				
			||||||
            width="64"
 | 
					 | 
				
			||||||
            height="64"
 | 
					 | 
				
			||||||
            alt={album}
 | 
					 | 
				
			||||||
            src={albumImageUrl}
 | 
					 | 
				
			||||||
            className="aspect-square rounded-2xl object-cover"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Song.Title = function SongTitle({as: Component = 'h2', title, songUrl, className}: Title) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
        <Component
 | 
					 | 
				
			||||||
            className={clsx(className, 'text-sm font-semibold text-zinc-800 dark:text-zinc-100 line-clamp-1 lg:line-clamp-none')}>
 | 
					 | 
				
			||||||
            <Link href={songUrl}>
 | 
					 | 
				
			||||||
                {title}
 | 
					 | 
				
			||||||
            </Link>
 | 
					 | 
				
			||||||
        </Component>
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Song.Artist = function SongArtist({as: Component = 'p', artist}: Artist) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
        <Component className="text-sm text-zinc-600 dark:text-zinc-400 line-clamp-1 lg:line-clamp-none">
 | 
					 | 
				
			||||||
            {artist}
 | 
					 | 
				
			||||||
        </Component>
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Song.Skeleton = function SongSkeleton() {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
        <div className="flex items-center space-x-4 animate-pulse">
 | 
					 | 
				
			||||||
            <div
 | 
					 | 
				
			||||||
                className="w-[64px] h-[64px] bg-zinc-100 rounded-2xl dark:bg-zinc-900"
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            <div>
 | 
					 | 
				
			||||||
                <p className="w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
 | 
					 | 
				
			||||||
                <p className="mt-3 w-[128px] h-3 bg-zinc-100 rounded-2xl dark:bg-zinc-900"/>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export function SpotifyPlayer(): ReactElement | null {
 | 
					 | 
				
			||||||
    const lastPlayed = usePlayerState('last-played')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (lastPlayed.isError) return null
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
        <div className="grid">
 | 
					 | 
				
			||||||
            {lastPlayed.isLoading
 | 
					 | 
				
			||||||
                ? <Song.Skeleton/>
 | 
					 | 
				
			||||||
                : <Song {...lastPlayed.song}/>
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@ -1,55 +0,0 @@
 | 
				
			|||||||
import fetch from 'node-fetch'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID
 | 
					 | 
				
			||||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET
 | 
					 | 
				
			||||||
const SPOTIFY_REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN
 | 
					 | 
				
			||||||
const SPOTIFY_TOKEN = 'https://accounts.spotify.com/api/token'
 | 
					 | 
				
			||||||
const SPOTIFY_CURRENTLY_PLAYING = 'https://api.spotify.com/v1/me/player/currently-playing'
 | 
					 | 
				
			||||||
const SPOTIFY_RECENTLY_PLAYED = 'https://api.spotify.com/v1/me/player/recently-played'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const basic = Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Response = {
 | 
					 | 
				
			||||||
    access_token: string
 | 
					 | 
				
			||||||
    token_type: string
 | 
					 | 
				
			||||||
    scope: string
 | 
					 | 
				
			||||||
    expires_in: number
 | 
					 | 
				
			||||||
    refresh_token: string
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const getAccessToken = async () => {
 | 
					 | 
				
			||||||
    const response = await fetch(SPOTIFY_TOKEN, {
 | 
					 | 
				
			||||||
        method: 'POST',
 | 
					 | 
				
			||||||
        headers: {
 | 
					 | 
				
			||||||
            Authorization: `Basic ${basic}`,
 | 
					 | 
				
			||||||
            'Content-Type': 'application/x-www-form-urlencoded'
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        body: new URLSearchParams({
 | 
					 | 
				
			||||||
            'grant_type': 'refresh_token',
 | 
					 | 
				
			||||||
            'refresh_token': `${SPOTIFY_REFRESH_TOKEN}`
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return response.json()
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const getCurrentlyPlaying = async () => {
 | 
					 | 
				
			||||||
    const {access_token}: Response = await getAccessToken() as Response
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return await fetch(SPOTIFY_CURRENTLY_PLAYING, {
 | 
					 | 
				
			||||||
        headers: {
 | 
					 | 
				
			||||||
            Authorization: `Bearer ${access_token}`
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const getRecentlyPlayed = async () => {
 | 
					 | 
				
			||||||
    const {access_token}: Response = await getAccessToken() as Response
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return await fetch(SPOTIFY_RECENTLY_PLAYED, {
 | 
					 | 
				
			||||||
        headers: {
 | 
					 | 
				
			||||||
            Authorization: `Bearer ${access_token}`
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user