Initial commit

This commit is contained in:
skidoodle 2023-12-27 15:44:17 +01:00
commit 1f8d8f5b68
31 changed files with 4523 additions and 0 deletions

51
src/components/Icon.tsx Normal file
View file

@ -0,0 +1,51 @@
import { socials } from '@/components/data/Socials'
import copy from 'copy-to-clipboard'
import toast from 'react-hot-toast'
import Link from 'next/link'
type Icon = {
children: React.ReactNode
reference: string
copyValue?: boolean
}
const notify = () => {
toast.remove(),
toast.success('Copied to clipboard', {
style: {
background: '#0f1012',
color: '#fff',
fontSize: '1em',
},
})
}
export const Icon = ({ children, reference, copyValue }: Icon) => {
if (copyValue) {
return (
<Link
href={''}
className={`cursor-pointer`}
aria-label={
socials.find((social) => social.ref === reference)?.ariaLabel
}
onClick={() => {
notify(), copy(reference)
}}
>
{children}
</Link>
)
}
return (
<Link
href={reference}
target='_blank'
className={'cursor-pointer'}
aria-label={socials.find((social) => social.ref === reference)?.ariaLabel}
>
{children}
</Link>
)
}

View file

@ -0,0 +1,22 @@
import { socials } from '@/components/data/Socials'
import { Icon } from '@/components/Icon'
import React from 'react'
export const SocialLayout = () => {
return (
<div className='mt-3 grid w-48 grid-flow-col space-x-8 pl-1 text-2xl'>
{socials.map((social) => (
<Icon
key={social.id}
reference={social.ref}
copyValue={social.copyValue}
>
{React.createElement(social.icon, {
className:
'fill-current focus:outline-none transition duration-300 ease-in-out hover:text-[#ad87ed]',
})}
</Icon>
))}
</div>
)
}

View file

@ -0,0 +1,61 @@
import { HiMusicNote } from 'react-icons/hi'
import { truncate } from '@/utils/truncate'
import { useEffect, useState } from 'react'
import io from 'socket.io-client'
import Image from 'next/image'
import Link from 'next/link'
interface SpotifyData {
song?: {
artist: string[]
title: string
url: string
image: string
}
}
export const NowPlayingCard = () => {
const [spotify, setSpotify] = useState<SpotifyData>({})
useEffect(() => {
const socket = io('wss://ws.albert.lol')
socket.on('nowPlayingData', (data) => {
setSpotify(data as SpotifyData)
})
return () => {
socket.disconnect()
}
}, [])
return (
<div className='mt-5 flex max-w-sm transform flex-row rounded-md border border-gray-800 p-3 shadow transition duration-300 ease-in-out hover:scale-105 focus:outline-none'>
{spotify.song ? (
<Image
height={45}
width={45}
alt='Song cover art'
quality={100}
className='select-none rounded-md'
draggable={false}
src={spotify.song.image}
/>
) : (
<HiMusicNote size={45} className='p-2.5' />
)}
<div className='my-auto ml-4'>
<div className='text-l sm:text-regular font-semibold'>
Listening to{' '}
{spotify.song ? (
<Link href={`${spotify.song.url}`} target='_blank'>
{truncate(`${spotify.song.title}`, 20)}
</Link>
) : (
<span>nothing</span>
)}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,35 @@
import { BsSunFill, BsMoonFill } from 'react-icons/bs'
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
export const ThemeSwitcher = () => {
const [mounted, setMounted] = useState(false)
const { theme, setTheme } = useTheme()
const toggle = () => {
if (theme === 'dark') {
setTheme('light')
} else {
setTheme('dark')
}
}
useEffect(() => setMounted(true), [])
if (!mounted) return null
return (
<button
aria-label='Switch Theme'
type='button'
className='ml-auto mr-5 mt-5 flex'
onClick={() => toggle()}
>
{theme === 'light' ? (
<BsMoonFill style={{ fill: 'black' }} size={25} />
) : (
<BsSunFill size={25} />
)}
</button>
)
}

View file

@ -0,0 +1,46 @@
import { FaDiscord, FaEnvelope, FaGithub, FaSteam } from 'react-icons/fa'
import { RiInstagramFill } from 'react-icons/ri'
import type { IconType } from 'react-icons/lib'
type Socials = {
id: number
ref: string
icon: IconType
copyValue?: boolean
ariaLabel?: string
}
export const socials: Array<Socials> = [
{
id: 1,
ref: 'https://github.com/skidoodle',
icon: FaGithub as IconType,
ariaLabel: 'GitHub',
},
{
id: 2,
ref: 'https://steamcommunity.com/id/_albert',
icon: FaSteam as IconType,
ariaLabel: 'Steam',
},
{
id: 3,
ref: 'contact@albert.lol',
icon: FaEnvelope as IconType,
copyValue: true,
ariaLabel: 'Email',
},
{
id: 4,
ref: 'https://www.instagram.com/albertadam_/',
icon: RiInstagramFill as IconType,
ariaLabel: 'Instagram',
},
{
id: 5,
ref: 'albert.lol',
icon: FaDiscord as IconType,
copyValue: true,
ariaLabel: 'Discord',
},
]

24
src/pages/404.tsx Normal file
View file

@ -0,0 +1,24 @@
import { ThemeSwitcher } from '@/components/ThemeSwitcher'
import FadeIn from 'react-fade-in'
import Link from 'next/link'
export default function Error() {
return (
<>
<ThemeSwitcher />
<FadeIn>
<div className='ml-[10%] mr-[10%]'>
<div className='mx-auto mb-16 mt-32 flex max-w-3xl flex-col'>
<h1 className='text-7xl font-bold'>404</h1>
<div className='text-2xl font-semibold text-gray-600'>
<p className='mt-2'>This page could not be found.</p>
<p className='mt-8'>
<Link href='/'>{'<-- Home'}</Link>
</p>
</div>
</div>
</div>
</FadeIn>
</>
)
}

27
src/pages/_app.tsx Normal file
View file

@ -0,0 +1,27 @@
import { Analytics } from '@vercel/analytics/react'
import { ThemeProvider } from 'next-themes'
import { Inter } from 'next/font/google'
import type { AppProps } from 'next/app'
import '@/styles/globals.scss'
import Head from 'next/head'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>albert</title>
</Head>
<main className={`${inter.variable} font-sans`}>
<ThemeProvider>
<Component {...pageProps} />
</ThemeProvider>
</main>
<Analytics />
</>
)
}

36
src/pages/_document.tsx Normal file
View file

@ -0,0 +1,36 @@
import { Html, Head, Main, NextScript } from 'next/document'
import age from '@/utils/age'
export default function Document() {
return (
<Html lang='zxx'>
<Head>
<meta name='theme-color' content='#121212' />
<meta name='title' content='albert' />
<meta name='og:title' content='albert' />
<meta property='og:url' content='https://albert.lol' />
<link
rel='preconnect'
href='https://vitals.vercel-insights.com'
crossOrigin='anonymous'
/>
<meta
name='description'
content={`${age()}-year-old sysadmin from hungary`}
/>
<meta
name='og:description'
content={`${age()}-year-old sysadmin from hungary`}
/>
<meta
property='og:image'
content='https://cdn.albert.lol/KmxuVVvWGUtGa.webp'
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

44
src/pages/api/spotify.ts Normal file
View file

@ -0,0 +1,44 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { SpotifyService } from '@/service/spotify'
function getEnvVar(key: string): string {
const value = process.env[key]
if (!value) {
throw new Error(`Missing environment variable: ${key}`)
}
return value
}
const CLIENT_ID = getEnvVar('CLIENT_ID')
const CLIENT_SECRET = getEnvVar('CLIENT_SECRET')
const REFRESH_TOKEN = getEnvVar('REFRESH_TOKEN')
const spotify = new SpotifyService(CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN)
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const song = await spotify.getCurrentSong()
if (!song || !song.isPlaying) {
return res.status(200).json({
nowplaying: false,
})
}
res.status(200).json({
nowplaying: true,
song: {
artist: song.artists.name,
title: song.title,
url: song.url,
image: song.album.image,
progress: song.progress,
},
})
} catch (error) {
res
.status(500)
.json({ error: 'An error occurred while fetching the song.' })
}
}

27
src/pages/index.tsx Normal file
View file

@ -0,0 +1,27 @@
import { ThemeSwitcher } from '@/components/ThemeSwitcher'
import { NowPlayingCard } from '@/components/SpotifyCard'
import { SocialLayout } from '@/components/SocialLayout'
import { Toaster } from 'react-hot-toast'
import FadeIn from 'react-fade-in'
import age from '@/utils/age'
export default function Home() {
return (
<>
<ThemeSwitcher />
<FadeIn>
<div className='ml-[10%] mr-[10%]'>
<div className='mx-auto mb-16 mt-32 flex max-w-3xl flex-col'>
<h1 className='text-7xl font-bold'>albert</h1>
<p className='mt-2 text-2xl font-semibold text-gray-600'>
{age()}-year-old sysadmin
</p>
<SocialLayout />
<NowPlayingCard />
<Toaster position='top-left' />
</div>
</div>
</FadeIn>
</>
)
}

69
src/service/spotify.ts Normal file
View file

@ -0,0 +1,69 @@
import { SongResultMap } from '@/utils/result'
import type { SongResult } from '@/utils/type'
import type { Item } from '@/utils/interface'
import axios, { type AxiosResponse } from 'axios'
export class SpotifyService {
private accessToken: string | undefined
private clientId: string
private clientSecret: string
private refreshToken: string
constructor(clientId: string, clientSecret: string, refreshToken: string) {
this.clientId = clientId
this.clientSecret = clientSecret
this.refreshToken = refreshToken
}
private hasAccessToken(): boolean {
return !!this.accessToken
}
private setAccessToken(token: string): void {
this.accessToken = token
}
private async getAccessToken(): Promise<void> {
try {
const response: AxiosResponse<{ access_token: string }> = await axios({
url: 'https://accounts.spotify.com/api/token',
method: 'POST',
params: {
client_id: this.clientId,
client_secret: this.clientSecret,
refresh_token: this.refreshToken,
grant_type: 'refresh_token',
},
})
this.setAccessToken(response.data.access_token)
} catch (error) {
throw new Error('Invalid credentials were given')
}
}
public async getCurrentSong(): Promise<SongResult> {
try {
if (!this.hasAccessToken()) {
await this.getAccessToken()
}
const response: AxiosResponse<{
progress_ms: number
item: Item
is_playing: boolean
}> = await axios({
url: 'https://api.spotify.com/v1/me/player/currently-playing',
method: 'GET',
headers: {
Authorization: 'Bearer ' + this.accessToken,
},
})
return SongResultMap.parseSong(response.data)
} catch (error) {
await this.getAccessToken()
throw error
}
}
}

36
src/styles/globals.scss Normal file
View file

@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scrollbar-width: thin;
scrollbar-color: #8040ee transparent;
}
}
@layer components {
::selection {
background-color: #8040ee;
color: #fff;
}
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-thumb {
background-color: #8040ee;
border-radius: 10px;
}
}
:root {
--background: #fff;
--foreground: #000;
}
[data-theme='dark'] {
--background: #000;
--foreground: #fff;
}

6
src/utils/age.ts Normal file
View file

@ -0,0 +1,6 @@
export default function age() {
return Math.floor(
(new Date().getTime() - new Date('2004-07-22').getTime()) /
(1000 * 60 * 60 * 24 * 365.25)
)
}

20
src/utils/interface.ts Normal file
View file

@ -0,0 +1,20 @@
interface Album {
name: string
images: {
url: string
}[]
release: string[]
}
export interface Artist {
name: string
external_urls: { spotify: string }
}
export interface Item {
name: string
album: Album
artists: Artist[]
external_urls: { spotify: string }
duration_ms: number
}

29
src/utils/result.ts Normal file
View file

@ -0,0 +1,29 @@
import type { SongResult } from '@/utils/type'
import type { Artist, Item } from '@/utils/interface'
export class SongResultMap {
public static parseSong(result: {
progress_ms: number
item: Item
is_playing: boolean
}): SongResult {
const { item } = result
return {
progress: result.progress_ms,
title: item.name,
album: {
name: item.album.name,
image: item.album.images[0]?.url,
release: item.album.release,
},
artists: {
name: item.artists.map((x: Artist) => x.name),
url: item.artists.map((x: Artist) => x.external_urls.spotify),
},
url: item.external_urls.spotify,
length: item.duration_ms,
isPlaying: result.is_playing,
}
}
}

2
src/utils/truncate.ts Normal file
View file

@ -0,0 +1,2 @@
export const truncate = (str: string, n: number) =>
str.length > n ? str.slice(0, n - 1) + '...' : str

16
src/utils/type.ts Normal file
View file

@ -0,0 +1,16 @@
export type SongResult = {
progress: number
album: {
name: string
image?: string
release: string[]
}
artists: {
name: string[]
url: string[]
}
url: string
title: string
length: number
isPlaying: boolean
}