mirror of
https://github.com/skidoodle/albert.lol.git
synced 2025-02-15 06:09:15 +01:00
.
This commit is contained in:
commit
9cf213d556
29 changed files with 3825 additions and 0 deletions
42
src/components/Icon.tsx
Normal file
42
src/components/Icon.tsx
Normal 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>
|
||||
);
|
||||
};
|
30
src/components/MainLayout.tsx
Normal file
30
src/components/MainLayout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
21
src/components/SocialLayout.tsx
Normal file
21
src/components/SocialLayout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
48
src/components/SpotifyCard.tsx
Normal file
48
src/components/SpotifyCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
35
src/components/ThemeSwitcher.tsx
Normal file
35
src/components/ThemeSwitcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
45
src/components/data/Socials.ts
Normal file
45
src/components/data/Socials.ts
Normal 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
19
src/pages/_app.tsx
Normal 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
21
src/pages/_document.tsx
Normal 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
26
src/pages/api/spotify.ts
Normal 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
9
src/pages/index.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { MainLayout } from '@/components/MainLayout';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<MainLayout />
|
||||
</>
|
||||
);
|
||||
}
|
61
src/service/spotify.ts
Normal file
61
src/service/spotify.ts
Normal 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
38
src/styles/globals.scss
Normal 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
1
src/utils/mapper.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export interface Mapper<T> {}
|
25
src/utils/result.ts
Normal file
25
src/utils/result.ts
Normal 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
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