mirror of
https://github.com/skidoodle/albert.lol.git
synced 2025-02-15 06:09:15 +01:00
Initial commit
This commit is contained in:
commit
1f8d8f5b68
31 changed files with 4523 additions and 0 deletions
51
src/components/Icon.tsx
Normal file
51
src/components/Icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
22
src/components/SocialLayout.tsx
Normal file
22
src/components/SocialLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
61
src/components/SpotifyCard.tsx
Normal file
61
src/components/SpotifyCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
35
src/components/ThemeSwitcher.tsx
Normal file
35
src/components/ThemeSwitcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
46
src/components/data/Socials.ts
Normal file
46
src/components/data/Socials.ts
Normal 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
24
src/pages/404.tsx
Normal 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
27
src/pages/_app.tsx
Normal 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
36
src/pages/_document.tsx
Normal 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
44
src/pages/api/spotify.ts
Normal 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
27
src/pages/index.tsx
Normal 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
69
src/service/spotify.ts
Normal 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
36
src/styles/globals.scss
Normal 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
6
src/utils/age.ts
Normal 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
20
src/utils/interface.ts
Normal 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
29
src/utils/result.ts
Normal 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
2
src/utils/truncate.ts
Normal 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
16
src/utils/type.ts
Normal 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue