This commit is contained in:
HangerThem 2024-08-12 15:15:13 +02:00
parent 58bd6769d0
commit 0e58ec6d11
24 changed files with 1539 additions and 269 deletions

16
app/(withNav)/layout.tsx Normal file
View file

@ -0,0 +1,16 @@
import Footer from "@/components/footer"
import Navbar from "@/components/navbar"
export default function Layout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<>
<Navbar />
{children}
<Footer />
</>
)
}

View file

@ -3,6 +3,7 @@ import About from "@/components/about"
import Divider from "@/components/divider" import Divider from "@/components/divider"
import Skills from "@/components/skills" import Skills from "@/components/skills"
import Projects from "@/components/projects" import Projects from "@/components/projects"
import Contact from "@/components/contact"
export default function Home() { export default function Home() {
return ( return (
@ -14,6 +15,7 @@ export default function Home() {
<Divider /> <Divider />
<Projects /> <Projects />
<Divider /> <Divider />
<Contact />
</> </>
) )
} }

71
app/api/v1/email/route.ts Normal file
View file

@ -0,0 +1,71 @@
import {
successResponse,
optionsResponse,
badRequestResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
} from "@/helpers/apiHelper"
import { NextRequest, NextResponse } from "next/server"
import { sendEmail } from "@/utils/emailUtils"
import rateLimit from "express-rate-limit"
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: "Too many requests, please try again later.",
})
export async function POST(req: NextRequest, res: NextResponse) {
if (process.env.NODE_ENV !== "development") {
await new Promise((resolve, reject) => {
limiter(req, res, (result: any) => {
if (result instanceof Error) {
return tooManyRequestsResponse()
}
resolve(result)
})
})
}
try {
const { email, name, message, recaptchaToken } = await req.json()
if (!email || !name || !message || !recaptchaToken) {
return badRequestResponse("Missing required fields")
}
const recaptchaResponse = await fetch(
`https://www.google.com/recaptcha/api/siteverify`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${recaptchaToken}`,
}
)
const recaptchaData = await recaptchaResponse.json()
if (!recaptchaData.success) {
return badRequestResponse("reCAPTCHA verification failed")
}
await sendEmail({
to: process.env.CONTACT_EMAIL as string,
subject: `New message from ${name} - ${email}`,
text: message,
})
return successResponse("Email sent successfully")
} catch (e) {
console.error(e)
return internalServerErrorResponse()
}
}
export async function OPTIONS() {
return optionsResponse({
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
})
}

View file

@ -1,7 +1,7 @@
import type { Metadata } from "next" import type { Metadata } from "next"
import { GlobalStyle } from "@/styles/globalStyle" import { GlobalStyle } from "@/styles/globalStyle"
import Navbar from "@/components/navbar"
import StyledComponentsRegistry from "@/lib/registry" import StyledComponentsRegistry from "@/lib/registry"
import { ReCaptchaProvider } from "next-recaptcha-v3"
import { K2D } from "next/font/google" import { K2D } from "next/font/google"
const k2d = K2D({ const k2d = K2D({
@ -22,6 +22,25 @@ export const metadata: Metadata = {
creator: "Frank Borisjuk", creator: "Frank Borisjuk",
publisher: "Frank Borisjuk", publisher: "Frank Borisjuk",
keywords: ["Portfolio", "Personal", "Frank Borisjuk", "HangerThem"], keywords: ["Portfolio", "Personal", "Frank Borisjuk", "HangerThem"],
metadataBase: new URL("https://hangerthem.com"),
openGraph: {
title: "HangerThem",
siteName: "HangerThem",
description: "Personal portfolio of HangerThem (Frank Borisjuk)",
type: "website",
locale: "en_US",
alternateLocale: ["cs_CZ"],
countryName: "Czechia",
emails: ["f.borisjuk@hangerthem.com"],
url: "https://hangerthem.com",
determiner: "auto",
ttl: 604800,
audio: [],
faxNumbers: [],
images: [],
phoneNumbers: [],
videos: [],
},
} }
export default function RootLayout({ export default function RootLayout({
@ -30,15 +49,13 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<StyledComponentsRegistry> <html lang="en" className={k2d.className}>
<html lang="en" className={k2d.className}> <ReCaptchaProvider>
<GlobalStyle /> <StyledComponentsRegistry>
<body> <GlobalStyle />
<Navbar /> <body>{children}</body>
</StyledComponentsRegistry>
{children} </ReCaptchaProvider>
</body> </html>
</html>
</StyledComponentsRegistry>
) )
} }

68
app/not-found.tsx Normal file
View file

@ -0,0 +1,68 @@
"use client"
import Link from "next/link"
import styled from "styled-components"
const Container = styled.div`
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
`
const Heading = styled.h2`
font-size: 3rem;
font-weight: 600;
`
const Paragraph = styled.p`
font-size: 1.2rem;
font-weight: 400;
`
const Button = styled(Link)`
padding: 0.5rem 0.75rem;
background-color: rgba(var(--primary), 0.8);
color: rgb(var(--white));
font-size: 1.2rem;
font-weight: 600;
border-radius: 0.25rem;
text-decoration: none;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: rgba(var(--primary), 1);
}
`
const Layout = styled.div`
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
`
export default function NotFound() {
return (
<Layout>
<Container>
<Heading>404 - Page Not Found</Heading>
<Paragraph>
We can&apos;t seem to find the page you&apos;re looking for.
</Paragraph>
<Paragraph>
Please check the URL or click the button below to be redirected to the
home page.
</Paragraph>
<Button href="/">Go Home</Button>
</Container>
</Layout>
)
}

View file

@ -1,6 +1,10 @@
"use client" "use client"
import styled, { keyframes } from "styled-components" import styled, { keyframes } from "styled-components"
import {
Container as PrestyledContainer,
Content as PrestyledContent,
} from "@/styles/pageStyles"
import Image from "next/image" import Image from "next/image"
import confetti from "canvas-confetti" import confetti from "canvas-confetti"
import Link from "next/link" import Link from "next/link"
@ -17,64 +21,40 @@ const wave = keyframes`
} }
` `
const AboutContainer = styled.section` const Container = styled(PrestyledContainer)`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
gap: 2rem;
min-height: 100vh;
@media (min-width: 768px) { @media (min-width: 768px) {
flex-direction: row; flex-direction: row;
align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 4rem;
} }
` `
const AboutContent = styled.div` const Content = styled(PrestyledContent)`
display: flex; align-items: flex-start;
flex-direction: column;
gap: 2rem;
h2 { h2 {
font-size: 1.75rem; font-size: 1.75rem;
margin-left: 2.5rem; padding-left: 2rem;
font-weight: 600;
position: relative; @media (min-width: 768px) {
padding-left: 2rem;
font-size: 2rem;
}
&::before { &::before {
content: "👋"; content: "👋";
position: absolute; position: absolute;
left: -3rem; left: 1.75rem;
transform-origin: 70% 70%; transform-origin: 70% 70%;
animation: ${wave} 1s ease-in-out infinite; animation: ${wave} 1s ease-in-out infinite;
}
}
p { @media (min-width: 768px) {
font-size: 1.2rem; left: 3rem;
font-weight: 400;
a {
color: rgb(var(--primary));
font-weight: 500;
text-decoration: none;
&:hover {
text-decoration: underline;
} }
} }
} }
@media (min-width: 768px) { @media (min-width: 768px) {
width: 50%; width: 50%;
h2 {
font-size: 2.5rem;
}
} }
` `
@ -99,8 +79,8 @@ const AboutImage = styled.div`
export default function About() { export default function About() {
return ( return (
<AboutContainer id="about"> <Container id="about">
<AboutContent> <Content>
<h2>Hi! My name is Frank! </h2> <h2>Hi! My name is Frank! </h2>
<p> <p>
Hey there! I&apos;m Frank Borisjuk, a software developer from Czechia Hey there! I&apos;m Frank Borisjuk, a software developer from Czechia
@ -142,7 +122,7 @@ export default function About() {
Thanks for visiting, and feel free to reach out if you want to connect Thanks for visiting, and feel free to reach out if you want to connect
or collaborate! or collaborate!
</p> </p>
</AboutContent> </Content>
<AboutImage> <AboutImage>
<Image <Image
src="/borisjuk_01.png" src="/borisjuk_01.png"
@ -162,6 +142,6 @@ export default function About() {
}} }}
/> />
</AboutImage> </AboutImage>
</AboutContainer> </Container>
) )
} }

156
components/contact.tsx Normal file
View file

@ -0,0 +1,156 @@
"use client"
import styled from "styled-components"
import { useReCaptcha } from "next-recaptcha-v3"
import { useCallback, useState } from "react"
import { requestSendEmail } from "@/services/api-services/emailServices"
import { Container, Content } from "@/styles/pageStyles"
import { Instagram, Github, Discord, Linkedin } from "react-bootstrap-icons"
import Link from "next/link"
const ContactForm = styled.form`
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
input,
textarea {
width: 100%;
padding: 1rem;
border: 1px solid rgba(var(--white), 0.1);
background-color: rgba(var(--white), 0.1);
color: rgb(var(--white));
font-family: inherit;
outline: none;
border-radius: 0.25rem;
&:active,
&:focus {
border-color: rgb(var(--primary));
}
}
textarea {
height: 200px;
resize: none;
}
button {
padding: 0.5rem 0.75rem;
background-color: rgb(var(--primary), 0.8);
color: rgb(var(--white));
border: none;
cursor: pointer;
font-size: 1.2rem;
font-weight: 600;
font-family: inherit;
transition: background-color 0.2s ease-in-out;
border-radius: 0.25rem;
&:hover {
background-color: rgb(var(--primary));
}
&:disabled {
background-color: rgba(var(--primary), 0.5);
cursor: not-allowed;
}
}
`
const SocialMedia = styled.div`
display: flex;
gap: 1rem;
`
export default function Contact() {
const [data, setData] = useState({
name: "",
email: "",
message: "",
})
const [loading, setLoading] = useState(false)
const { executeRecaptcha } = useReCaptcha()
const handleSubmit = useCallback(
async (event: React.FormEvent) => {
event.preventDefault()
setLoading(true)
const recaptchaToken = await executeRecaptcha("form_submit")
const response = await requestSendEmail({ ...data, recaptchaToken })
if (response.status === 200) {
alert("Email sent successfully!")
} else {
alert("Failed to send email! Please try again later.")
}
setLoading(false)
},
[executeRecaptcha, data]
)
return (
<Container id="contact">
<Content>
<h2> Get in touch</h2>
<p>Feel free to reach out to me for any queries or just to say hi!</p>
<ContactForm onSubmit={handleSubmit}>
<input
type="text"
placeholder="Name"
onChange={(e) => setData({ ...data, name: e.target.value })}
/>
<input
type="email"
placeholder="Email"
onChange={(e) => setData({ ...data, email: e.target.value })}
/>
<textarea
placeholder="Message"
onChange={(e) => setData({ ...data, message: e.target.value })}
></textarea>
<button
type="submit"
disabled={!data.name || !data.email || !data.message || loading}
>
{loading ? "Sending..." : "Send"}
</button>
</ContactForm>
<p>Or connect with me on social media:</p>
<SocialMedia>
<Link
href="https://www.instagram.com/ferda_borisjuk/"
target="_blank"
rel="noreferrer"
>
<Instagram size={32} />
</Link>
<Link
href="https://github.com/HangerThem"
target="_blank"
rel="noreferrer"
>
<Github size={32} />
</Link>
<Link
href="https://discord.com/users/495134242825699328"
target="_blank"
rel="noreferrer"
>
<Discord size={32} />
</Link>
<Link
href="https://www.linkedin.com/in/franti%C5%A1ek-borisjuk-022686225/"
target="_blank"
rel="noreferrer"
>
<Linkedin size={32} />
</Link>
</SocialMedia>
</Content>
</Container>
)
}

56
components/footer.tsx Normal file
View file

@ -0,0 +1,56 @@
"use client"
import Link from "next/link"
import styled from "styled-components"
const FooterContainer = styled.footer`
width: 100%;
background-color: rgb(var(--white), 0.1);
color: rgb(var(--white));
padding: 1rem 0;
`
const FooterContent = styled.div`
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1rem;
p {
flex: 1;
text-align: center;
font-size: 1rem;
&:not(:last-child) {
@media (min-width: 768px) {
border-right: 1px solid rgba(var(--white), 0.1);
}
}
}
@media (min-width: 768px) {
flex-direction: row;
}
`
export default function Footer() {
return (
<FooterContainer>
<FooterContent>
<p>
Made with by{" "}
<Link href="https://github.com/HangerThem">Frank Borisjuk</Link>
</p>
<p>&copy; 2024 - All rights reserved</p>
<p>
Sources available on{" "}
<Link href="https://github.com/HangerThem/hangerthem.com">
GitHub
</Link>
</p>
</FooterContent>
</FooterContainer>
)
}

View file

@ -1,7 +1,7 @@
"use client" "use client"
import styled, { keyframes } from "styled-components" import styled, { keyframes } from "styled-components"
import { useRef, useState, useEffect } from "react" import { useRef, useState, useEffect, useCallback } from "react"
import links from "@/data/links" import links from "@/data/links"
import socialLinks from "@/data/socialLinks" import socialLinks from "@/data/socialLinks"
import Link from "next/link" import Link from "next/link"
@ -28,7 +28,7 @@ const reverseRipple = keyframes`
const NavbarContainer = styled.nav` const NavbarContainer = styled.nav`
width: 100%; width: 100%;
padding: 2rem; padding: 1rem 2rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: column; flex-direction: column;
@ -161,7 +161,6 @@ const SocialLinksContainer = styled.div`
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
color: rgba(var(--white), 0.8); color: rgba(var(--white), 0.8);
transition: all 0.3s ease;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
@ -173,7 +172,7 @@ const SocialLinksContainer = styled.div`
display: none; display: none;
} }
@media (min-width: 820px) { @media (min-width: 980px) {
display: flex; display: flex;
} }
` `
@ -279,65 +278,71 @@ const Hamburger = styled.div`
const Navbar = () => { const Navbar = () => {
const hanburgerRef = useRef<HTMLDivElement>(null) const hanburgerRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false) const [navState, setNavState] = useState({
const [closing, setClosing] = useState(false) open: false,
const [visible, setVisible] = useState(false) closing: false,
const [activeTag, setActiveTag] = useState("home") visible: false,
activeTag: "home",
})
const handleClick = () => { const handleClick = useCallback(() => {
if (open) { if (navState.open) {
setOpen(false) setNavState((prev) => ({ ...prev, open: false, closing: true }))
setClosing(true)
setTimeout(() => { setTimeout(() => {
setVisible(false) setNavState((prev) => ({ ...prev, visible: false }))
}, 200) }, 200)
setTimeout(() => { setTimeout(() => {
setClosing(false) setNavState((prev) => ({ ...prev, closing: false }))
}, 500) }, 500)
document.body.style.overflow = "auto" document.body.style.overflow = "auto"
} else { } else {
setOpen(true) setNavState((prev) => ({ ...prev, open: true }))
setTimeout(() => { setTimeout(() => {
setVisible(true) setNavState((prev) => ({ ...prev, visible: true }))
}, 300) }, 300)
document.body.style.overflow = "hidden" document.body.style.overflow = "hidden"
} }
} }, [navState.open])
useEffect(() => { const handleResize = useCallback(() => {
const handleResize = () => { setNavState((prev) => ({
setOpen(false) ...prev,
setClosing(false) open: false,
setVisible(false) visible: false,
document.body.style.overflow = "auto" closing: false,
} }))
document.body.style.overflow = "auto"
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, []) }, [])
useEffect(() => { const handleClickOutside = useCallback(
const handleClickOutside = (e: MouseEvent) => { (e: MouseEvent) => {
if (!open) return if (!navState.open) return
if ( if (
hanburgerRef.current && hanburgerRef.current &&
!hanburgerRef.current.contains(e.target as Node) !hanburgerRef.current.contains(e.target as Node)
) { ) {
setOpen(false) setNavState((prev) => ({ ...prev, open: false, closing: true }))
setClosing(true)
setTimeout(() => { setTimeout(() => {
setVisible(false) setNavState((prev) => ({ ...prev, visible: false }))
}, 200) }, 200)
setTimeout(() => { setTimeout(() => {
setClosing(false) setNavState((prev) => ({ ...prev, closing: false }))
}, 500) }, 500)
document.body.style.overflow = "auto" document.body.style.overflow = "auto"
} }
} },
[navState.open]
)
useEffect(() => {
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [handleResize])
useEffect(() => {
window.addEventListener("click", handleClickOutside) window.addEventListener("click", handleClickOutside)
return () => window.removeEventListener("click", handleClickOutside) return () => window.removeEventListener("click", handleClickOutside)
}, [open]) }, [navState.open, handleClickOutside])
return ( return (
<NavbarContainer> <NavbarContainer>
@ -348,19 +353,21 @@ const Navbar = () => {
<HamburgerWrapper <HamburgerWrapper
ref={hanburgerRef} ref={hanburgerRef}
onClick={handleClick} onClick={handleClick}
className={open ? "open" : closing ? "closing" : ""} className={navState.open ? "open" : navState.closing ? "closing" : ""}
> >
<Hamburger className={open ? "open" : ""} /> <Hamburger className={navState.open ? "open" : ""} />
</HamburgerWrapper> </HamburgerWrapper>
</Topbar> </Topbar>
<LinksWrapper className={visible ? "visible" : ""}> <LinksWrapper className={navState.visible ? "visible" : ""}>
<LinksContainer> <LinksContainer>
{links.map((link) => ( {links.map((link) => (
<li key={link.id}> <li key={link.id}>
<Link <Link
href={link.href} href={link.href}
className={activeTag === link.id ? "active" : ""} className={navState.activeTag === link.id ? "active" : ""}
onClick={() => setActiveTag(link.id)} onClick={() =>
setNavState((prev) => ({ ...prev, activeTag: link.id }))
}
> >
{link.text} {link.text}
</Link> </Link>

View file

@ -1,53 +1,12 @@
"use client" "use client"
import styled from "styled-components" import styled from "styled-components"
import { useState } from "react" import { useCallback, useState } from "react"
import { CaretLeftFill, CaretRightFill, Git } from "react-bootstrap-icons" import { CaretLeftFill, CaretRightFill, Git } from "react-bootstrap-icons"
import projects from "@/data/projects" import projects from "@/data/projects"
import Link from "next/link" import Link from "next/link"
import Image from "next/image" import Image from "next/image"
import { Container, Content } from "@/styles/pageStyles"
const ProjectsContainer = styled.section`
width: 100%;
min-height: 100vh;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
@media (max-width: 1200px) {
padding: 1rem;
gap: 1rem;
}
`
const ProjectsContent = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 2rem;
h2 {
font-size: 3rem;
font-weight: 600;
@media (max-width: 1200px) {
font-size: 2rem;
}
}
p {
font-size: 1.2rem;
font-weight: 400;
@media (max-width: 1200px) {
font-size: 1rem;
}
}
`
const CarouselWrapper = styled.div` const CarouselWrapper = styled.div`
display: flex; display: flex;
@ -251,25 +210,25 @@ const Projects = () => {
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const projectCount = projects.length const projectCount = projects.length
const handlePrev = () => { const handlePrev = useCallback(() => {
setCurrentIndex( setCurrentIndex(
(prevIndex) => (prevIndex - 1 + projectCount) % projectCount (prevIndex) => (prevIndex - 1 + projectCount) % projectCount
) )
} }, [projectCount])
const handleNext = () => { const handleNext = useCallback(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % projectCount) setCurrentIndex((prevIndex) => (prevIndex + 1) % projectCount)
} }, [projectCount])
const getVisibleProjects = () => { const getVisibleProjects = useCallback(() => {
const prevIndex = (currentIndex - 1 + projectCount) % projectCount const prevIndex = (currentIndex - 1 + projectCount) % projectCount
const nextIndex = (currentIndex + 1) % projectCount const nextIndex = (currentIndex + 1) % projectCount
return [projects[prevIndex], projects[currentIndex], projects[nextIndex]] return [projects[prevIndex], projects[currentIndex], projects[nextIndex]]
} }, [currentIndex, projectCount])
return ( return (
<ProjectsContainer id="projects"> <Container id="projects">
<ProjectsContent> <Content>
<h2>📂 My Projects</h2> <h2>📂 My Projects</h2>
<p>Here are some of the projects I&apos;ve worked on recently</p> <p>Here are some of the projects I&apos;ve worked on recently</p>
<CarouselContainer> <CarouselContainer>
@ -341,8 +300,8 @@ const Projects = () => {
))} ))}
</CarouselIndicator> </CarouselIndicator>
</CarouselContainer> </CarouselContainer>
</ProjectsContent> </Content>
</ProjectsContainer> </Container>
) )
} }

View file

@ -2,31 +2,7 @@
import styled from "styled-components" import styled from "styled-components"
import skills from "@/data/skills" import skills from "@/data/skills"
import { Container, Content } from "@/styles/pageStyles"
const SkillsContainer = styled.section`
width: 100%;
min-height: 100vh;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
h2 {
font-size: 3rem;
font-weight: 600;
}
p {
font-size: 1.2rem;
font-weight: 400;
}
@media (min-width: 768px) {
padding: 4rem;
}
`
const Categories = styled.div` const Categories = styled.div`
width: 100%; width: 100%;
@ -51,9 +27,13 @@ const SkillCategory = styled.div`
gap: 2rem; gap: 2rem;
flex: 1; flex: 1;
h3 { h4 {
font-size: 1.75rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
@media (min-width: 768px) {
font-size: 1.5rem;
}
} }
ul { ul {
@ -72,12 +52,6 @@ const SkillCategory = styled.div`
align-items: center; align-items: center;
} }
} }
@media (min-width: 768px) {
h3 {
font-size: 2rem;
}
}
` `
const SkillOMeter = styled.div<{ $level: number }>` const SkillOMeter = styled.div<{ $level: number }>`
@ -115,75 +89,79 @@ export default function Skills() {
skills.tools.sort((a, b) => b.persentage - a.persentage) skills.tools.sort((a, b) => b.persentage - a.persentage)
return ( return (
<SkillsContainer id="skills"> <Container id="skills">
<h2>🎖 Skills</h2> <Content>
<p> <h2>🎖 Skills</h2>
Here are some of the languages, technologies, and tools that I have <p>
experience with. I am always learning new things and I am excited to Here are some of the languages, technologies, and tools that I have
learn more! experience with. I am always learning new things and I am excited to
</p> learn more!
<Categories> </p>
{skills.languages && ( <Categories>
<SkillCategory> {skills.languages && (
<h3>Languages</h3> <SkillCategory>
<ul> <h4>Languages</h4>
{skills.languages.map((language, index) => ( <ul>
<li key={index}> {skills.languages.map((language, index) => (
<span> <li key={index}>
{language.name} - {language.level} <span>
</span> {language.name} - {language.level}
<SkillOMeter $level={language.persentage} /> </span>
</li> <SkillOMeter $level={language.persentage} />
))} </li>
</ul> ))}
</SkillCategory> </ul>
)} </SkillCategory>
{skills.programmingLanguages && ( )}
<SkillCategory> {skills.programmingLanguages && (
<h3>Programming Languages</h3> <SkillCategory>
<ul> <h4>Programming Languages</h4>
{skills.programmingLanguages.map((programmingLanguage, index) => ( <ul>
<li key={index}> {skills.programmingLanguages.map(
<span> (programmingLanguage, index) => (
{programmingLanguage.name} - {programmingLanguage.level} <li key={index}>
</span> <span>
<SkillOMeter $level={programmingLanguage.persentage} /> {programmingLanguage.name} - {programmingLanguage.level}
</li> </span>
))} <SkillOMeter $level={programmingLanguage.persentage} />
</ul> </li>
</SkillCategory> )
)} )}
{skills.technologies && ( </ul>
<SkillCategory> </SkillCategory>
<h3>Technologies</h3> )}
<ul> {skills.technologies && (
{skills.technologies.map((technology, index) => ( <SkillCategory>
<li key={index}> <h4>Technologies</h4>
<span> <ul>
{technology.name} - {technology.level} {skills.technologies.map((technology, index) => (
</span> <li key={index}>
<SkillOMeter $level={technology.persentage} /> <span>
</li> {technology.name} - {technology.level}
))} </span>
</ul> <SkillOMeter $level={technology.persentage} />
</SkillCategory> </li>
)} ))}
{skills.tools && ( </ul>
<SkillCategory> </SkillCategory>
<h3>Tools</h3> )}
<ul> {skills.tools && (
{skills.tools.map((tool, index) => ( <SkillCategory>
<li key={index}> <h4>Tools</h4>
<span> <ul>
{tool.name} - {tool.level} {skills.tools.map((tool, index) => (
</span> <li key={index}>
<SkillOMeter $level={tool.persentage} /> <span>
</li> {tool.name} - {tool.level}
))} </span>
</ul> <SkillOMeter $level={tool.persentage} />
</SkillCategory> </li>
)} ))}
</Categories> </ul>
</SkillsContainer> </SkillCategory>
)}
</Categories>
</Content>
</Container>
) )
} }

View file

@ -22,6 +22,8 @@ const projects: Project[] = [
"Prisma", "Prisma",
"PostgreSQL", "PostgreSQL",
"JWT", "JWT",
"Nodemailer",
"Node.js",
], ],
href: "https://ephemr.net", href: "https://ephemr.net",
source: "https://github.com/HangerThem/ephemr", source: "https://github.com/HangerThem/ephemr",
@ -46,6 +48,16 @@ const projects: Project[] = [
source: "https://github.com/HangerThem/linkboard", source: "https://github.com/HangerThem/linkboard",
state: "done", state: "done",
}, },
{
title: "Ephemeris",
description:
"My personal blog, a place where I write about tech, programming, and other stuff.",
image: "/ephemeris.png",
techStack: ["Jekyll", "Markdown"],
href: "https://blog.hangerthem.com",
source: "https://github.com/HangerThem/ephemeris",
state: "done",
},
{ {
title: "Evolution algorithm Flappy Bird", title: "Evolution algorithm Flappy Bird",
description: description:

View file

@ -1,4 +1,4 @@
import { Github } from "react-bootstrap-icons" import { Github, JournalAlbum } from "react-bootstrap-icons"
type SocialLink = { type SocialLink = {
id: string id: string
@ -14,6 +14,12 @@ const socialLink: SocialLink[] = [
text: "GitHub", text: "GitHub",
icon: <Github />, icon: <Github />,
}, },
{
id: "blog",
href: "https://blog.hangerthem.com",
text: "Blog",
icon: <JournalAlbum />,
},
] ]
export default socialLink export default socialLink

45
helpers/apiHelper.ts Normal file
View file

@ -0,0 +1,45 @@
function jsonResponse(status: number, data?: any) {
const response = new Response(JSON.stringify({ status, ...data }), {
status,
headers: {
"Content-Type": "application/json",
},
})
return response
}
export function downloadResponse(status: number, data: any) {
const response = new Response(data, {
status,
headers: {
"Content-Type": "application/json",
"Content-Disposition": 'attachment; filename="user_data.json"',
},
})
return response
}
export function optionsResponse(headers: { [key: string]: string }) {
const response = new Response(null, {
status: 204,
headers,
})
return response
}
export function successResponse(data: any) {
return jsonResponse(200, data)
}
export function badRequestResponse(errorMessage?: string | {}) {
return jsonResponse(400, { error: errorMessage ?? "Bad request" })
}
export function tooManyRequestsResponse() {
return jsonResponse(429, { error: "Too many requests" })
}
export function internalServerErrorResponse() {
return jsonResponse(500, { error: "Internal server error" })
}

6
interface/IEmailData.ts Normal file
View file

@ -0,0 +1,6 @@
interface IEmailData {
email: string
name: string
message: string
recaptchaToken: string
}

View file

@ -0,0 +1,4 @@
interface IErrorResponse {
error: string
status: number
}

730
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,10 @@
}, },
"dependencies": { "dependencies": {
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
"express-rate-limit": "^7.4.0",
"next": "14.1.0", "next": "14.1.0",
"nodemailer": "^6.9.9", "next-recaptcha-v3": "^1.4.1",
"nodemailer": "^6.9.14",
"react": "^18", "react": "^18",
"react-bootstrap-icons": "^1.11.4", "react-bootstrap-icons": "^1.11.4",
"react-dom": "^18", "react-dom": "^18",
@ -20,8 +22,10 @@
"devDependencies": { "devDependencies": {
"@types/canvas-confetti": "^1.6.4", "@types/canvas-confetti": "^1.6.4",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^6.4.15",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/react-google-recaptcha": "^2.1.9",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.1.0", "eslint-config-next": "14.1.0",
"typescript": "^5" "typescript": "^5"

BIN
public/ephemeris.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View file

@ -0,0 +1,19 @@
import { post } from "@/services/requestHelpers"
export const requestSendEmail = async (
emailData: IEmailData
): Promise<
| {
emailData: IEmailData
status: number
}
| IErrorResponse
> => {
return await post<
| {
emailData: IEmailData
status: number
}
| IErrorResponse
>("/email", emailData)
}

View file

@ -0,0 +1,87 @@
const API_URL = "/api/v1"
export async function get<Res>(
url: string,
cache?: "no-store" | "force-cache",
revalidate?: number
): Promise<Res> {
const token = localStorage.getItem("ephemrToken")
return (
await fetch(API_URL + url, {
method: "GET",
//cache: cache ?? "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
next: { revalidate: revalidate ?? 0 },
})
).json() as Res
}
export async function post<Res>(
url: string,
body: any,
cache?: "no-store" | "force-cache",
revalidate?: number
): Promise<Res> {
const token = localStorage.getItem("ephemrToken")
return (
await fetch(API_URL + url, {
method: "POST",
//cache: cache ?? "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
next: { revalidate: revalidate ?? 0 },
})
).json() as Res
}
export async function patch<Res>(
url: string,
body: any,
cache?: "no-store" | "force-cache",
revalidate?: number
): Promise<Res> {
const token = localStorage.getItem("ephemrToken")
return (
await fetch(API_URL + url, {
method: "PATCH",
//cache: cache ?? "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
next: { revalidate: revalidate ?? 0 },
})
).json() as Res
}
export async function requestDelete<Res>(
url: string,
body: any,
cache?: "no-store" | "force-cache",
revalidate?: number
): Promise<Res> {
const token = localStorage.getItem("ephemrToken")
return (
await fetch(API_URL + url, {
method: "DELETE",
//cache: cache ?? "no-store",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
next: { revalidate: revalidate ?? 0 },
})
).json() as Res
}

View file

@ -46,4 +46,14 @@ export const GlobalStyle = createGlobalStyle`
background-color: rgb(var(--black)); background-color: rgb(var(--black));
color: rgb(var(--white)); color: rgb(var(--white));
} }
a {
color: rgb(var(--primary));
text-decoration: none;
transition: color 0.2s ease-in-out;
&:hover {
color: rgb(var(--primary), 0.8);
}
}
` `

42
styles/pageStyles.ts Normal file
View file

@ -0,0 +1,42 @@
import styled from "styled-components"
export const Container = styled.section`
width: 100%;
min-height: 100vh;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
@media (min-width: 768px) {
padding: 4rem;
}
`
export const Content = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 2rem;
h2 {
font-size: 3rem;
font-weight: 600;
@media (max-width: 768px) {
font-size: 2rem;
}
}
p {
font-size: 1.2rem;
font-weight: 400;
@media (max-width: 768px) {
font-size: 1rem;
}
}
`

33
utils/emailUtils.ts Normal file
View file

@ -0,0 +1,33 @@
import nodemailer from "nodemailer"
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: Boolean(process.env.SMTP_SECURE),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
const sendEmail = async ({
to,
subject,
text,
html,
}: {
to: string
subject: string
text: string
html?: string
}) => {
await transporter.sendMail({
from: `${process.env.SMTP_NAME} <${process.env.SMTP_USER}>`,
to,
subject,
text,
html: html || text,
})
}
export { sendEmail }