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 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
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 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
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"
|
"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'm Frank Borisjuk, a software developer from Czechia
|
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
|
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
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"
|
"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>
|
||||||
|
|
|
||||||
|
|
@ -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've worked on recently</p>
|
<p>Here are some of the projects I've worked on recently</p>
|
||||||
<CarouselContainer>
|
<CarouselContainer>
|
||||||
|
|
@ -341,8 +300,8 @@ const Projects = () => {
|
||||||
))}
|
))}
|
||||||
</CarouselIndicator>
|
</CarouselIndicator>
|
||||||
</CarouselContainer>
|
</CarouselContainer>
|
||||||
</ProjectsContent>
|
</Content>
|
||||||
</ProjectsContainer>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
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": {
|
"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
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));
|
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
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