app router

This commit is contained in:
skidoodle 2024-10-29 08:48:57 +01:00
commit 156764768d
27 changed files with 10813 additions and 0 deletions

View 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%]">
Im 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. Im 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>
);
};

View 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>
);
};

View 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>
);
};

View 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";

View 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>
);
};

View 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>;
}

View 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>
);
};

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 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

43
src/app/globals.css Normal file
View 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
View 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
View 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
View 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
View 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;
};
}