mirror of
https://github.com/skidoodle/albert.lol.git
synced 2025-02-15 06:09:15 +01:00
app router
This commit is contained in:
commit
156764768d
27 changed files with 10813 additions and 0 deletions
51
src/app/components/AboutMe.tsx
Normal file
51
src/app/components/AboutMe.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import age from "@/utils";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const AboutMe = () => {
|
||||
return (
|
||||
<motion.div
|
||||
className="p-3 max-w-[325px] lg:max-w-lg md:max-w-md max-h-[300px] h-[265px] rounded-lg shadow-lg backdrop-blur-sm bg-white/20 dark:bg-black/20 object-fit"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="text-[250%] font-bold dark:text-[--dark-secondary] text-[--light-secondary]">
|
||||
about me
|
||||
</h2>
|
||||
<p className="text-[110%]">
|
||||
I’m a{" "}
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{age()}-year-old
|
||||
</span>
|
||||
, studying{" "}
|
||||
<span className="font-medium text-orange-600 dark:text-orange-400">
|
||||
Computer Science Operational Engineering
|
||||
</span>{" "}
|
||||
at{" "}
|
||||
<span className="font-medium text-green-600 dark:text-green-400">
|
||||
Óbuda University
|
||||
</span>{" "}
|
||||
in Hungary. I’m passionate about{" "}
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
systems engineering
|
||||
</span>
|
||||
, working on my{" "}
|
||||
<span className="italic text-green-700 dark:text-green-500">
|
||||
homelab
|
||||
</span>
|
||||
, and coding in{" "}
|
||||
<span className="font-medium text-purple-600 dark:text-purple-400">
|
||||
TypeScript
|
||||
</span>{" "}
|
||||
and{" "}
|
||||
<span className="font-medium text-purple-600 dark:text-purple-400">
|
||||
Go
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
42
src/app/components/Background.tsx
Normal file
42
src/app/components/Background.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const Background = () => {
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen overflow-hidden z-[-1] flex justify-center items-center">
|
||||
<motion.div
|
||||
className="absolute w-[800px] h-[800px] bg-gradient-to-r from-pink-500 to-purple-500 rounded-full blur-3xl"
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
animate={{
|
||||
scale: [1.5, 1.2, 1, 1.2, 1.5],
|
||||
x: [0, 200, 0, -200, 0],
|
||||
y: [0, -80, 150, -150, 0],
|
||||
opacity: [0.6, 1, 0.6],
|
||||
}}
|
||||
transition={{
|
||||
duration: 18,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute w-[800px] h-[800px] bg-gradient-to-r from-blue-400 to-red-500 rounded-full blur-3xl"
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
animate={{
|
||||
scale: [1, 1.3, 1.5, 1.3, 1],
|
||||
x: [-150, 150, -150, 150, -150],
|
||||
y: [180, -180, 180, -180, 180],
|
||||
opacity: [0.6, 1, 0.6],
|
||||
}}
|
||||
transition={{
|
||||
duration: 22,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
58
src/app/components/Icon.tsx
Normal file
58
src/app/components/Icon.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import copy from "copy-to-clipboard";
|
||||
import toast from "react-hot-toast";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTheme } from "next-themes";
|
||||
import type { IconType } from "@/utils/types";
|
||||
|
||||
export const Icon = ({
|
||||
children,
|
||||
reference,
|
||||
copyValue,
|
||||
ariaLabel,
|
||||
}: IconType) => {
|
||||
const { theme } = useTheme() as { theme: "light" | "dark" };
|
||||
|
||||
const handleCopy = () => {
|
||||
toast.remove();
|
||||
|
||||
const toastStyle = {
|
||||
light: {
|
||||
background: "var(--light-primary)",
|
||||
color: "var(--light-text)",
|
||||
},
|
||||
dark: {
|
||||
background: "var(--dark-primary)",
|
||||
color: "var(--dark-text)",
|
||||
},
|
||||
};
|
||||
|
||||
toast.success("Copied to clipboard", {
|
||||
style: {
|
||||
...toastStyle[theme || "light"],
|
||||
fontSize: "1em",
|
||||
},
|
||||
});
|
||||
|
||||
copy(reference);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="cursor-pointer transition-transform duration-200 ease-in-out hover:scale-110 text-[1.25em] text-[--light-text] dark:text-[--dark-text]"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
role={copyValue ? "button" : undefined}
|
||||
aria-label={ariaLabel}
|
||||
onClick={copyValue ? handleCopy : undefined}
|
||||
>
|
||||
<Link
|
||||
href={copyValue ? "" : reference}
|
||||
target={copyValue ? undefined : "_blank"}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
42
src/app/components/SocialLayout.tsx
Normal file
42
src/app/components/SocialLayout.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { Socials } from "@/components/data/Socials";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import React, { memo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const SocialLayout = memo(() => {
|
||||
return (
|
||||
<motion.div
|
||||
className="mt-3 grid w-fit grid-flow-col gap-8 pl-1 text-[1.7rem]"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.2 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{Socials.map(({ id, ref, copyValue, icon: IconComponent, ariaLabel }) => (
|
||||
<motion.div
|
||||
key={id}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
}}
|
||||
>
|
||||
<Icon reference={ref} copyValue={copyValue} ariaLabel={ariaLabel}>
|
||||
<IconComponent
|
||||
className="fill-current transition-transform duration-300 ease-in-out hover:text-[#ad87ed] hover:scale-105 focus:outline-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Icon>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
SocialLayout.displayName = "SocialLayout";
|
123
src/app/components/SpotifyCard.tsx
Normal file
123
src/app/components/SpotifyCard.tsx
Normal file
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import type { SpotifyData } from "@/utils/types";
|
||||
import { HiMusicNote } from "react-icons/hi";
|
||||
import { truncate } from "@/utils";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const useSpotify = (): SpotifyData | undefined => {
|
||||
const [spotify, setSpotify] = useState<SpotifyData | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket(
|
||||
process.env.NEXT_PUBLIC_SPOTIFY_WS || "ws://localhost:3001",
|
||||
);
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
setSpotify(data);
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
socket.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
socket.removeEventListener("message", handleMessage);
|
||||
socket.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return spotify;
|
||||
};
|
||||
|
||||
export const NowPlayingCard = () => {
|
||||
const spotify = useSpotify();
|
||||
|
||||
const progressPercentage = useMemo(() => {
|
||||
if (spotify && spotify.is_playing && spotify.item) {
|
||||
return (spotify.progress_ms / spotify.item.duration_ms) * 100;
|
||||
}
|
||||
return 0;
|
||||
}, [spotify]);
|
||||
|
||||
const renderSpotify = useCallback(() => {
|
||||
if (!spotify) {
|
||||
return (
|
||||
<div className="flex items-center text-[1.2rem]">
|
||||
<HiMusicNote size={75} className="p-3" />
|
||||
<div className="ml-4 text-left">
|
||||
<h1 className="font-semibold text-l">Loading...</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!spotify.is_playing) {
|
||||
return (
|
||||
<div className="flex items-center text-[1.2rem]">
|
||||
<HiMusicNote size={75} className="p-3" />
|
||||
<div className="ml-4 text-left">
|
||||
<h1 className="font-semibold text-l">Not listening to anything</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const song = spotify.item;
|
||||
const artists =
|
||||
song.artists?.map((artist) => artist.name).join(", ") || "Unknown artist";
|
||||
const albumImage =
|
||||
song.album.images[0]?.url || "https://placehold.co/50x50.webp";
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="w-[75px] h-[75px]">
|
||||
<Image
|
||||
priority={true}
|
||||
width={75}
|
||||
height={75}
|
||||
alt="Song cover art"
|
||||
className="rounded-md object-cover w-full h-full"
|
||||
draggable={false}
|
||||
src={albumImage}
|
||||
quality={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4 flex-1 text-left text-[1.15rem]">
|
||||
<Link href={song.external_urls.spotify || "/"}>
|
||||
<h1 className="font-semibold text-[#1ED760] hover:text-[#1DB954] truncate">
|
||||
{truncate(song.name, 20)}
|
||||
</h1>
|
||||
</Link>
|
||||
<h2 className="text-xs truncate">{truncate(artists, 35)}</h2>
|
||||
<motion.div
|
||||
className="mt-2 rounded-full h-1"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progressPercentage}%` }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="bg-[#1DB954] h-1 rounded-full"></div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [spotify, progressPercentage]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="mt-5 w-[325px] rounded-md shadow-lg p-3 dark:bg-black/20 bg-white/20 backfrop-blur-sm"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
{renderSpotify()}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
9
src/app/components/ThemeProvider.tsx
Normal file
9
src/app/components/ThemeProvider.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
42
src/app/components/ThemeSwitcher.tsx
Normal file
42
src/app/components/ThemeSwitcher.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { VscColorMode } from "react-icons/vsc";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const ThemeSwitcher = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
}, [theme, setTheme]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
aria-label="Switch Theme"
|
||||
type="button"
|
||||
className="fixed top-5 right-5 p-3 rounded-full"
|
||||
onClick={toggleTheme}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ rotate: 0 }}
|
||||
animate={{ rotate: theme === "light" ? 0 : 180 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<VscColorMode size={35} />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
);
|
||||
};
|
46
src/app/components/data/Socials.ts
Normal file
46
src/app/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 Social = {
|
||||
id: number;
|
||||
ref: string;
|
||||
icon: IconType;
|
||||
copyValue?: boolean;
|
||||
ariaLabel: string;
|
||||
};
|
||||
|
||||
export const Socials: readonly Social[] = [
|
||||
{
|
||||
id: 1,
|
||||
ref: "https://github.com/skidoodle",
|
||||
icon: FaGithub,
|
||||
ariaLabel: "GitHub",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
ref: "https://steamcommunity.com/id/_albert",
|
||||
icon: FaSteam,
|
||||
ariaLabel: "Steam",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
ref: "contact@albert.lol",
|
||||
icon: FaEnvelope,
|
||||
copyValue: true,
|
||||
ariaLabel: "Email",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
ref: "https://www.instagram.com/albertadam_/",
|
||||
icon: RiInstagramFill,
|
||||
ariaLabel: "Instagram",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
ref: "albert.lol",
|
||||
icon: FaDiscord,
|
||||
copyValue: true,
|
||||
ariaLabel: "Discord",
|
||||
},
|
||||
] as const;
|
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
43
src/app/globals.css
Normal file
43
src/app/globals.css
Normal file
|
@ -0,0 +1,43 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--dark-background: #000000;
|
||||
--dark-primary: #121212;
|
||||
--dark-secondary: #cecece;
|
||||
--dark-text: #eeeeee;
|
||||
|
||||
--light-background: #eeeeee;
|
||||
--light-primary: #dddddd;
|
||||
--light-secondary: #5c5c5c;
|
||||
--light-text: #222222;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #8040ee transparent;
|
||||
background-color: var(--light-background);
|
||||
}
|
||||
|
||||
.dark {
|
||||
background-color: var(--dark-background);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
::selection {
|
||||
background-color: #8040ee;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #8040ee;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
42
src/app/layout.tsx
Normal file
42
src/app/layout.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import { ReactNode } from "react";
|
||||
import { Albert_Sans } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import age from "@/utils";
|
||||
import "./globals.css";
|
||||
|
||||
const albert_sans = Albert_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-albert",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://albert.lol"),
|
||||
title: "albert",
|
||||
description: `${age()}yo student at Óbuda University`,
|
||||
openGraph: {
|
||||
images: "/profile.webp",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zxx" suppressHydrationWarning>
|
||||
<Script
|
||||
defer
|
||||
src="https://analytics.albert.lol/script.js"
|
||||
data-website-id="2c900d5e-c577-4824-ad37-0cdf68383c42"
|
||||
></Script>
|
||||
<body className={`${albert_sans.variable} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
48
src/app/page.tsx
Normal file
48
src/app/page.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { NowPlayingCard } from "@/components/SpotifyCard";
|
||||
import { SocialLayout } from "@/components/SocialLayout";
|
||||
import { AboutMe } from "@/components/AboutMe";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { motion } from "framer-motion";
|
||||
import { Background } from "@/components/Background";
|
||||
import { Fragment } from "react";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Fragment>
|
||||
<Background />
|
||||
<ThemeSwitcher />
|
||||
<Toaster position="top-left" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="mx-auto mt-44 flex max-w-3xl flex-col mb-12 z-10 relative">
|
||||
<motion.div
|
||||
className="flex flex-col lg:flex-row items-center justify-center lg:justify-between space-y-10 lg:space-y-5 lg:space-x-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex flex-col items-center text-center"
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-[7.5rem] leading-none font-bold dark:text-[--dark-text] text-[--light-text]">
|
||||
albert
|
||||
</h1>
|
||||
<SocialLayout />
|
||||
<NowPlayingCard />
|
||||
</motion.div>
|
||||
<AboutMe />
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
19
src/app/utils/index.ts
Normal file
19
src/app/utils/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export default function age() {
|
||||
const BIRTHDATE = process.env.NEXT_PUBLIC_BIRTHDATE;
|
||||
if (!BIRTHDATE) {
|
||||
console.warn("Missing environment variable: BIRTHDATE");
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.floor(
|
||||
(new Date().getTime() - new Date(BIRTHDATE).getTime()) / 3.15576e10,
|
||||
);
|
||||
}
|
||||
|
||||
export const truncate = (str: string, n: number): string => {
|
||||
if (str.length > n) {
|
||||
const truncated = str.slice(0, n - 3).trimEnd();
|
||||
return `${truncated}...`;
|
||||
}
|
||||
return str.trim();
|
||||
};
|
32
src/app/utils/types.ts
Normal file
32
src/app/utils/types.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export type IconType = {
|
||||
children: ReactNode;
|
||||
reference: string;
|
||||
copyValue?: boolean;
|
||||
ariaLabel: string;
|
||||
};
|
||||
|
||||
export interface SpotifyData {
|
||||
is_playing?: boolean;
|
||||
progress_ms: number;
|
||||
item: {
|
||||
artists: {
|
||||
name: string;
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
}[];
|
||||
album: {
|
||||
images: {
|
||||
url: string;
|
||||
}[];
|
||||
};
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
name: string;
|
||||
url: string;
|
||||
duration_ms: number;
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue