Finished
This commit is contained in:
parent
58bd6769d0
commit
0e58ec6d11
24 changed files with 1539 additions and 269 deletions
16
app/(withNav)/layout.tsx
Normal file
16
app/(withNav)/layout.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
71
app/api/v1/email/route.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
|
|
@ -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
68
app/not-found.tsx
Normal 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't seem to find the page you'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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
156
components/contact.tsx
Normal 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
56
components/footer.tsx
Normal 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>© 2024 - All rights reserved</p>
|
||||
<p>
|
||||
Sources available on{" "}
|
||||
<Link href="https://github.com/HangerThem/hangerthem.com">
|
||||
GitHub
|
||||
</Link>
|
||||
</p>
|
||||
</FooterContent>
|
||||
</FooterContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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've worked on recently</p>
|
||||
<CarouselContainer>
|
||||
|
|
@ -341,8 +300,8 @@ const Projects = () => {
|
|||
))}
|
||||
</CarouselIndicator>
|
||||
</CarouselContainer>
|
||||
</ProjectsContent>
|
||||
</ProjectsContainer>
|
||||
</Content>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
45
helpers/apiHelper.ts
Normal 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
6
interface/IEmailData.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
interface IEmailData {
|
||||
email: string
|
||||
name: string
|
||||
message: string
|
||||
recaptchaToken: string
|
||||
}
|
||||
4
interface/IErrorResponse.ts
Normal file
4
interface/IErrorResponse.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
interface IErrorResponse {
|
||||
error: string
|
||||
status: number
|
||||
}
|
||||
730
package-lock.json
generated
730
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
BIN
public/ephemeris.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
19
services/api-services/emailServices.ts
Normal file
19
services/api-services/emailServices.ts
Normal 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)
|
||||
}
|
||||
87
services/requestHelpers.ts
Normal file
87
services/requestHelpers.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
42
styles/pageStyles.ts
Normal 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
33
utils/emailUtils.ts
Normal 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 }
|
||||
Loading…
Reference in a new issue