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 Skills from "@/components/skills"
import Projects from "@/components/projects"
import Contact from "@/components/contact"
export default function Home() {
return (
@ -14,6 +15,7 @@ export default function Home() {
<Divider />
<Projects />
<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 { GlobalStyle } from "@/styles/globalStyle"
import Navbar from "@/components/navbar"
import StyledComponentsRegistry from "@/lib/registry"
import { ReCaptchaProvider } from "next-recaptcha-v3"
import { K2D } from "next/font/google"
const k2d = K2D({
@ -22,6 +22,25 @@ export const metadata: Metadata = {
creator: "Frank Borisjuk",
publisher: "Frank Borisjuk",
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({
@ -30,15 +49,13 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<StyledComponentsRegistry>
<html lang="en" className={k2d.className}>
<GlobalStyle />
<body>
<Navbar />
{children}
</body>
</html>
</StyledComponentsRegistry>
<html lang="en" className={k2d.className}>
<ReCaptchaProvider>
<StyledComponentsRegistry>
<GlobalStyle />
<body>{children}</body>
</StyledComponentsRegistry>
</ReCaptchaProvider>
</html>
)
}

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"
import styled, { keyframes } from "styled-components"
import {
Container as PrestyledContainer,
Content as PrestyledContent,
} from "@/styles/pageStyles"
import Image from "next/image"
import confetti from "canvas-confetti"
import Link from "next/link"
@ -17,64 +21,40 @@ const wave = keyframes`
}
`
const AboutContainer = styled.section`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
gap: 2rem;
min-height: 100vh;
const Container = styled(PrestyledContainer)`
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 4rem;
}
`
const AboutContent = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
const Content = styled(PrestyledContent)`
align-items: flex-start;
h2 {
font-size: 1.75rem;
margin-left: 2.5rem;
font-weight: 600;
position: relative;
padding-left: 2rem;
@media (min-width: 768px) {
padding-left: 2rem;
font-size: 2rem;
}
&::before {
content: "👋";
position: absolute;
left: -3rem;
left: 1.75rem;
transform-origin: 70% 70%;
animation: ${wave} 1s ease-in-out infinite;
}
}
p {
font-size: 1.2rem;
font-weight: 400;
a {
color: rgb(var(--primary));
font-weight: 500;
text-decoration: none;
&:hover {
text-decoration: underline;
@media (min-width: 768px) {
left: 3rem;
}
}
}
@media (min-width: 768px) {
width: 50%;
h2 {
font-size: 2.5rem;
}
}
`
@ -99,8 +79,8 @@ const AboutImage = styled.div`
export default function About() {
return (
<AboutContainer id="about">
<AboutContent>
<Container id="about">
<Content>
<h2>Hi! My name is Frank! </h2>
<p>
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
or collaborate!
</p>
</AboutContent>
</Content>
<AboutImage>
<Image
src="/borisjuk_01.png"
@ -162,6 +142,6 @@ export default function About() {
}}
/>
</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"
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 socialLinks from "@/data/socialLinks"
import Link from "next/link"
@ -28,7 +28,7 @@ const reverseRipple = keyframes`
const NavbarContainer = styled.nav`
width: 100%;
padding: 2rem;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
flex-direction: column;
@ -161,7 +161,6 @@ const SocialLinksContainer = styled.div`
justify-content: center;
gap: 0.5rem;
color: rgba(var(--white), 0.8);
transition: all 0.3s ease;
text-decoration: none;
&:hover {
@ -173,7 +172,7 @@ const SocialLinksContainer = styled.div`
display: none;
}
@media (min-width: 820px) {
@media (min-width: 980px) {
display: flex;
}
`
@ -279,65 +278,71 @@ const Hamburger = styled.div`
const Navbar = () => {
const hanburgerRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const [closing, setClosing] = useState(false)
const [visible, setVisible] = useState(false)
const [activeTag, setActiveTag] = useState("home")
const [navState, setNavState] = useState({
open: false,
closing: false,
visible: false,
activeTag: "home",
})
const handleClick = () => {
if (open) {
setOpen(false)
setClosing(true)
const handleClick = useCallback(() => {
if (navState.open) {
setNavState((prev) => ({ ...prev, open: false, closing: true }))
setTimeout(() => {
setVisible(false)
setNavState((prev) => ({ ...prev, visible: false }))
}, 200)
setTimeout(() => {
setClosing(false)
setNavState((prev) => ({ ...prev, closing: false }))
}, 500)
document.body.style.overflow = "auto"
} else {
setOpen(true)
setNavState((prev) => ({ ...prev, open: true }))
setTimeout(() => {
setVisible(true)
setNavState((prev) => ({ ...prev, visible: true }))
}, 300)
document.body.style.overflow = "hidden"
}
}
}, [navState.open])
useEffect(() => {
const handleResize = () => {
setOpen(false)
setClosing(false)
setVisible(false)
document.body.style.overflow = "auto"
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
const handleResize = useCallback(() => {
setNavState((prev) => ({
...prev,
open: false,
visible: false,
closing: false,
}))
document.body.style.overflow = "auto"
}, [])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!open) return
const handleClickOutside = useCallback(
(e: MouseEvent) => {
if (!navState.open) return
if (
hanburgerRef.current &&
!hanburgerRef.current.contains(e.target as Node)
) {
setOpen(false)
setClosing(true)
setNavState((prev) => ({ ...prev, open: false, closing: true }))
setTimeout(() => {
setVisible(false)
setNavState((prev) => ({ ...prev, visible: false }))
}, 200)
setTimeout(() => {
setClosing(false)
setNavState((prev) => ({ ...prev, closing: false }))
}, 500)
document.body.style.overflow = "auto"
}
}
},
[navState.open]
)
useEffect(() => {
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [handleResize])
useEffect(() => {
window.addEventListener("click", handleClickOutside)
return () => window.removeEventListener("click", handleClickOutside)
}, [open])
}, [navState.open, handleClickOutside])
return (
<NavbarContainer>
@ -348,19 +353,21 @@ const Navbar = () => {
<HamburgerWrapper
ref={hanburgerRef}
onClick={handleClick}
className={open ? "open" : closing ? "closing" : ""}
className={navState.open ? "open" : navState.closing ? "closing" : ""}
>
<Hamburger className={open ? "open" : ""} />
<Hamburger className={navState.open ? "open" : ""} />
</HamburgerWrapper>
</Topbar>
<LinksWrapper className={visible ? "visible" : ""}>
<LinksWrapper className={navState.visible ? "visible" : ""}>
<LinksContainer>
{links.map((link) => (
<li key={link.id}>
<Link
href={link.href}
className={activeTag === link.id ? "active" : ""}
onClick={() => setActiveTag(link.id)}
className={navState.activeTag === link.id ? "active" : ""}
onClick={() =>
setNavState((prev) => ({ ...prev, activeTag: link.id }))
}
>
{link.text}
</Link>

View file

@ -1,53 +1,12 @@
"use client"
import styled from "styled-components"
import { useState } from "react"
import { useCallback, useState } from "react"
import { CaretLeftFill, CaretRightFill, Git } from "react-bootstrap-icons"
import projects from "@/data/projects"
import Link from "next/link"
import Image from "next/image"
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;
}
}
`
import { Container, Content } from "@/styles/pageStyles"
const CarouselWrapper = styled.div`
display: flex;
@ -251,25 +210,25 @@ const Projects = () => {
const [currentIndex, setCurrentIndex] = useState(0)
const projectCount = projects.length
const handlePrev = () => {
const handlePrev = useCallback(() => {
setCurrentIndex(
(prevIndex) => (prevIndex - 1 + projectCount) % projectCount
)
}
}, [projectCount])
const handleNext = () => {
const handleNext = useCallback(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % projectCount)
}
}, [projectCount])
const getVisibleProjects = () => {
const getVisibleProjects = useCallback(() => {
const prevIndex = (currentIndex - 1 + projectCount) % projectCount
const nextIndex = (currentIndex + 1) % projectCount
return [projects[prevIndex], projects[currentIndex], projects[nextIndex]]
}
}, [currentIndex, projectCount])
return (
<ProjectsContainer id="projects">
<ProjectsContent>
<Container id="projects">
<Content>
<h2>📂 My Projects</h2>
<p>Here are some of the projects I&apos;ve worked on recently</p>
<CarouselContainer>
@ -341,8 +300,8 @@ const Projects = () => {
))}
</CarouselIndicator>
</CarouselContainer>
</ProjectsContent>
</ProjectsContainer>
</Content>
</Container>
)
}

View file

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

View file

@ -22,6 +22,8 @@ const projects: Project[] = [
"Prisma",
"PostgreSQL",
"JWT",
"Nodemailer",
"Node.js",
],
href: "https://ephemr.net",
source: "https://github.com/HangerThem/ephemr",
@ -46,6 +48,16 @@ const projects: Project[] = [
source: "https://github.com/HangerThem/linkboard",
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",
description:

View file

@ -1,4 +1,4 @@
import { Github } from "react-bootstrap-icons"
import { Github, JournalAlbum } from "react-bootstrap-icons"
type SocialLink = {
id: string
@ -14,6 +14,12 @@ const socialLink: SocialLink[] = [
text: "GitHub",
icon: <Github />,
},
{
id: "blog",
href: "https://blog.hangerthem.com",
text: "Blog",
icon: <JournalAlbum />,
},
]
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": {
"canvas-confetti": "^1.9.3",
"express-rate-limit": "^7.4.0",
"next": "14.1.0",
"nodemailer": "^6.9.9",
"next-recaptcha-v3": "^1.4.1",
"nodemailer": "^6.9.14",
"react": "^18",
"react-bootstrap-icons": "^1.11.4",
"react-dom": "^18",
@ -20,8 +22,10 @@
"devDependencies": {
"@types/canvas-confetti": "^1.6.4",
"@types/node": "^20",
"@types/nodemailer": "^6.4.15",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-google-recaptcha": "^2.1.9",
"eslint": "^8",
"eslint-config-next": "14.1.0",
"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));
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 }