This commit is contained in:
skidoodle 2023-04-09 05:31:08 +02:00
commit 9cf213d556
29 changed files with 3825 additions and 0 deletions

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

@ -0,0 +1,42 @@
import Link from 'next/link';
import toast from 'react-hot-toast';
import copy from 'copy-to-clipboard';
type Icon = {
children: any;
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`}
onClick={() => {
notify(), copy(reference);
}}
>
{children}
</Link>
);
}
return (
<Link href={reference} target='_blank' className={'cursor-pointer'}>
{children}
</Link>
);
};

View file

@ -0,0 +1,30 @@
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';
export const MainLayout = () => {
return (
<>
<ThemeSwitcher />
<FadeIn>
<div className='ml-[10%] mr-[10%]'>
<div className='flex flex-col max-w-3xl mx-auto mb-16 mt-32'>
<h1 className='text-7xl font-bold'>albert</h1>
<p className='text-2xl text-gray-600 dark:text-gray-400 mt-2 font-semibold'>
{Math.floor(
(new Date().getTime() - new Date('2004-07-22').getTime()) /
(1000 * 60 * 60 * 24 * 365.25)
)}
-year-old system administrator
</p>
<SocialLayout />
<NowPlayingCard />
<Toaster position='bottom-center' reverseOrder={false} />
</div>
</div>
</FadeIn>
</>
);
};

View file

@ -0,0 +1,21 @@
import { Icon } from '@/components/Icon';
import { socials } from '@/components/data/Socials';
import React from 'react';
export const SocialLayout = () => {
return (
<>
<div className='grid grid-flow-col w-48 mt-3 text-2xl space-x-8'>
{socials.map((social) => (
<Icon
key={social.id}
reference={social.ref}
copyValue={social.copyValue}
>
{React.createElement(social.icon)}
</Icon>
))}
</div>
</>
);
};

View file

@ -0,0 +1,48 @@
import { truncate } from '@/utils/truncate';
import FadeIn from 'react-fade-in';
import Image from 'next/image';
import useSWR from 'swr';
import SongImage from '@/public/song.webp';
import Link from 'next/link';
export const fetcher = (url: RequestInfo) => fetch(url).then((r) => r.json());
export const NowPlayingCard = () => {
var { data: spotify } = useSWR('/api/spotify', fetcher, {
refreshInterval: 3000,
fallbackData: 'loading',
});
return (
<FadeIn>
<div className='mt-5 focus:outline-none transition duration-300 ease-in-out transform hover:scale-105 p-3 rounded-md border border-gray-800 shadow flex flex-row max-w-sm'>
{spotify.song ? (
<Image
height={45}
width={45}
alt='Song cover art'
src={spotify.song?.image}
/>
) : (
<Image height={45} width={45} alt='Song cover art' src={SongImage} />
)}
<div className='my-auto ml-4'>
<div className='font-semibold text-l sm:text-regular'>
Listening to{' '}
{spotify.song ? (
<Link
href={`${spotify.song.url}`}
target='_blank'
className='text-[#32a866]'
>
{truncate(`${spotify.song.title}`, 20)}
</Link>
) : (
<span className='text-[#32a866]'>nothing</span>
)}
</div>
</div>
</div>
</FadeIn>
);
};

View file

@ -0,0 +1,35 @@
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { BsSunFill, BsMoonFill } from 'react-icons/bs';
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='Toggle Dark Mode'
type='button'
className='focus:outline-none bg-none xs:bg-gray-200 xs:dark:bg-gray-800 rounded-lg p-3 h-10 w-10 flex items-center justify-center ml-auto'
onClick={() => toggle()}
>
{theme === 'light' ? (
<BsMoonFill style={{ fill: 'black' }} size={300} />
) : (
<BsSunFill size={300} />
)}
</button>
);
};

View file

@ -0,0 +1,45 @@
import { IconType } from 'react-icons/lib';
import {
FaDiscord,
FaEnvelope,
FaGithub,
FaInstagram,
FaSteam,
} from 'react-icons/fa';
type Socials = {
id: number;
ref: string;
icon: IconType;
copyValue?: boolean;
};
export const socials: Array<Socials> = [
{
id: 1,
ref: 'https://github.com/skidoodle',
icon: FaGithub,
},
{
id: 2,
ref: 'https://steamcommunity.com/id/_albert',
icon: FaSteam,
},
{
id: 3,
ref: 'contact@albert.lol',
icon: FaEnvelope,
copyValue: true,
},
{
id: 4,
ref: 'https://www.instagram.com/albertadam_/',
icon: FaInstagram,
},
{
id: 5,
ref: 'albert#8838',
icon: FaDiscord,
copyValue: true,
},
];

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

@ -0,0 +1,19 @@
import { AppProps } from 'next/app';
import { Analytics } from '@vercel/analytics/react';
import { ThemeProvider } from 'next-themes';
import Head from 'next/head';
import '@/styles/globals.scss';
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>albert</title>
</Head>
<ThemeProvider>
<Component {...pageProps} />
</ThemeProvider>
<Analytics />
</>
);
}

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

@ -0,0 +1,21 @@
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang='en'>
<Head>
<link rel='preconnect' href='https://vitals.vercel-insights.com' />
<meta name='title' content='albert' />
<meta name='og:title' content='albert' />
<meta name='description' content='system administrator' />
<meta name='og:description' content='system administrator' />
<meta name='theme-color' content='#000000' />
<meta property='og:image' content='/favicon.ico' />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

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

@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { SpotifyService } from '@/service/spotify';
const { CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN } = process.env;
const spotify = new SpotifyService(CLIENT_ID!, CLIENT_SECRET!, REFRESH_TOKEN!);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
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,
},
});
}

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

@ -0,0 +1,9 @@
import { MainLayout } from '@/components/MainLayout';
export default function Home() {
return (
<>
<MainLayout />
</>
);
}

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

@ -0,0 +1,61 @@
import { SongResult } from '@/utils/type'
import { SongResultMap } from '@/utils/result'
import axios from 'axios'
export class SpotifyService {
private accessToken: string = ''
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 = 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',
}
}).then((res) => res.data)
this.setAccessToken(response.access_token)
} catch {
throw new Error('Invalid credentials were given')
}
}
public async getCurrentSong(): Promise<SongResult> {
try {
if(!this.hasAccessToken()) {
await this.getAccessToken()
}
const response = await axios({ url: 'https://api.spotify.com/v1/me/player/currently-playing',
method: 'GET',
headers: {
'Authorization': 'Bearer ' + this.accessToken
}
}).then((res) => res.data)
return SongResultMap.parseSong(response)
} catch {
return await this.getAccessToken() as any
}
}
}

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

@ -0,0 +1,38 @@
@import url('https://cdn.albert.lol/ClearSansRegular.woff');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Clear Sans', sans-serif;
scrollbar-width: thin;
scrollbar-color: #8a58e0 transparent;
}
}
@layer components {
::selection {
background-color: #8039e2;
color: #fff;
}
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-thumb {
background-color: #8a58e0;
border-radius: 10px;
}
}
:root {
--background: #fff;
--foreground: #000;
}
[data-theme='dark'] {
--background: #000;
--foreground: #fff;
}

1
src/utils/mapper.ts Normal file
View file

@ -0,0 +1 @@
export interface Mapper<T> {}

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

@ -0,0 +1,25 @@
import { Mapper } from '@/utils/mapper'
import { SongResult } from '@/utils/type'
export class SongResultMap implements Mapper<SongResult> {
public static parseSong(result: any): SongResult {
const { item } = result
return {
progress: result.progress_ms,
title: item.name,
album: {
name: item.album.name,
image: item.album.images[1].url,
release: item.album.release_date,
},
artists: {
name: item.artists.map((x: any) => x.name),
url: item.artists.map((x: any) => 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
}