diff --git a/package-lock.json b/package-lock.json index 605b7cc..87cb889 100644 --- a/package-lock.json +++ b/package-lock.json @@ -966,9 +966,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.11.16", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", - "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", + "version": "20.11.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", + "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -5364,9 +5364,9 @@ } }, "node_modules/postcss": { - "version": "8.4.33", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", - "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "dev": true, "funding": [ { diff --git a/project-words.txt b/project-words.txt index 7e37216..ed4fe85 100644 --- a/project-words.txt +++ b/project-words.txt @@ -7,4 +7,6 @@ Skauna hookform sonner Bootcamp -Udemy \ No newline at end of file +Udemy +Csvg +Cpath \ No newline at end of file diff --git a/public/fonts/Raleway-Medium.ttf b/public/fonts/Raleway-Medium.ttf new file mode 100644 index 0000000..015d810 Binary files /dev/null and b/public/fonts/Raleway-Medium.ttf differ diff --git a/public/fonts/Raleway-SemiBold.ttf b/public/fonts/Raleway-SemiBold.ttf new file mode 100644 index 0000000..85d41ed Binary files /dev/null and b/public/fonts/Raleway-SemiBold.ttf differ diff --git a/public/hero-bg.svg b/public/hero-bg.svg new file mode 100644 index 0000000..6b64794 --- /dev/null +++ b/public/hero-bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 66612bf..c450304 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -1,14 +1,16 @@ import ProjectPageChips from '@/components/project-page-chips' import Card from '@/components/ui/card' -import Chips from '@/components/ui/chips/chips' -import ChipsItem from '@/components/ui/chips/chips-item' import Content from '@/components/ui/content' import Heading from '@/components/ui/heading' import IconsList from '@/components/ui/icons-list/icons-list' import IconsListItem from '@/components/ui/icons-list/icons-list-item' import ImagePlaceholder from '@/components/ui/image-placeholder' +import { LINK } from '@/types/enums/Link' +import { SITE_URL } from '@/utils' import { getProject, getSlugs } from '@/utils/projects' import Image from 'next/image' +import Link from 'next/link' +import { BiArrowBack } from 'react-icons/bi' import { FaGithub } from 'react-icons/fa' import { TfiWorld } from 'react-icons/tfi' @@ -16,6 +18,24 @@ type ProjectPageProps = { params: { slug: string } } +export async function generateMetadata({ params: { slug } }: ProjectPageProps) { + const { body, title } = await getProject(slug) + + return { + description: body, + openGraph: { + images: { + alt: `${title || slug}`, + height: 630, + type: 'image/png', + url: `${SITE_URL}/api/og?title=${title || slug}${body && `&description=${body}`}`, + width: 1200, + }, + }, + title, + } +} + export async function generateStaticParams() { const slugs = await getSlugs() return slugs.map((slug) => ({ slug })) @@ -28,7 +48,14 @@ export default async function Project({ params: { slug } }: ProjectPageProps) { return (
-
+ + {' '} + Go Back to Projects + +
{website || github ? (
@@ -55,7 +82,7 @@ export default async function Project({ params: { slug } }: ProjectPageProps) { )} {body && (

)} diff --git a/src/app/api/og/route.tsx b/src/app/api/og/route.tsx new file mode 100644 index 0000000..031e077 --- /dev/null +++ b/src/app/api/og/route.tsx @@ -0,0 +1,1485 @@ +/* eslint-disable @next/next/no-img-element */ + +import { ImageResponse } from 'next/og' + +export const runtime = 'edge' + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + + const hasTitle = searchParams.has('title') + const title = hasTitle + ? searchParams.get('title') + : 'Anton Bochkovskyi - Front-End Developer' + + const hasDescription = searchParams.has('description') + const description = hasDescription + ? searchParams.get('description') + : 'Anton Bochkovskyi - Front-End Developer' + + const ralewaySemiBold = fetch( + new URL('../../../../public/fonts/Raleway-SemiBold.ttf', import.meta.url) + ).then((res) => res.arrayBuffer()) + + const ralewayMedium = fetch( + new URL('../../../../public/fonts/Raleway-Medium.ttf', import.meta.url) + ).then((res) => res.arrayBuffer()) + + return new ImageResponse( + ( +

+

{title}

+

{description}

+ OG Background +
+ ), + { + fonts: [ + { + data: await ralewaySemiBold, + name: 'Raleway', + style: 'normal', + weight: 600, + }, + { + data: await ralewayMedium, + name: 'Raleway', + style: 'normal', + weight: 500, + }, + ], + } + ) + } catch (error) { + if (error instanceof Error) { + return new Response('Failed to generate OG image', { status: 500 }) + } + } +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico index 718d6fe..4929e02 100644 Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3cdb825..80e90d7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,19 +1,30 @@ -// TODO: Adjust site metadata -// TODO: Add 404 page - import type { Metadata } from 'next' import Footer from '@/components/footer' import Header from '@/components/header/header' import Providers from '@/store/providers' -import { cn } from '@/utils' +import { SITE_URL, cn } from '@/utils' import { raleway } from './fonts' import './globals.css' export const metadata: Metadata = { - description: 'Generated by create next app', - title: 'Create Next App', + description: + "Hi, my name is Anton Bochkovskyi and I'm a Front-End Developer specializing in React, Next.js, and Tailwind CSS, with a keen focus on crafting responsive and visually stunning web applications.", + metadataBase: new URL(SITE_URL), + openGraph: { + images: { + alt: 'Anton Bochkovskyi - Front-End Developer', + height: 630, + type: 'image/png', + url: `${SITE_URL}/api/og?title=Anton%20Bochkovskyi%20-%20Front-End%20Developer`, + width: 1200, + }, + }, + title: { + default: 'Anton Bochkovskyi - Front-End Developer', + template: '%s | Anton Bochkovskyi - Front-End Developer', + }, } export default function RootLayout({ diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..37ed782 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,30 @@ +import { buttonVariants } from '@/components/ui/button' +import Content from '@/components/ui/content' +import Heading from '@/components/ui/heading' +import { LINK } from '@/types/enums/Link' +import { cn } from '@/utils' +import Image from 'next/image' +import Link from 'next/link' + +export default function NotFound() { + return ( +
+ Background + + Page Not Found +

+ The page you were looking for does not exists. +

+ + Return Home + +
+
+ ) +} diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..8ed81e0 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,12 @@ +import { SITE_URL } from '@/utils' +import { MetadataRoute } from 'next' + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + allow: '/', + userAgent: '*', + }, + sitemap: `${SITE_URL}/sitemap.xml`, + } +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..df4fee3 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,27 @@ +import { SITE_URL } from '@/utils' +import { getProjectsMetadata } from '@/utils/projects' +import { MetadataRoute } from 'next' + +export default async function sitemap(): Promise { + const projectsMetadata = await getProjectsMetadata() + + const sitemapData: MetadataRoute.Sitemap = [ + { + changeFrequency: 'monthly', + lastModified: new Date(), + priority: 1, + url: SITE_URL, + }, + ] + + for (const project of projectsMetadata) { + sitemapData.push({ + changeFrequency: 'weekly', + lastModified: project.lastModified, + priority: 0.8, + url: `${SITE_URL}/${project.slug}`, + }) + } + + return sitemapData +} diff --git a/src/components/header/header-navbar.tsx b/src/components/header/header-navbar.tsx index 92d3a21..94fe499 100644 --- a/src/components/header/header-navbar.tsx +++ b/src/components/header/header-navbar.tsx @@ -5,6 +5,7 @@ import MenuItem from '@/components/header/menu/menu-item' import Socials from '@/components/ui/socials' import useMediaQuery from '@/hooks/use-media-query' import HeaderContext from '@/store/header-context' +import { LINK } from '@/types/enums/Link' import { MOBILE_BREAKPOINT, cn } from '@/utils' import { FC, HTMLAttributes, useContext } from 'react' @@ -17,8 +18,8 @@ const HeaderNavbar: FC = ({ className, ...props }) => { return ( diff --git a/src/components/sections/hero/hero.tsx b/src/components/sections/hero/hero.tsx index 79b7344..dbfdd93 100644 --- a/src/components/sections/hero/hero.tsx +++ b/src/components/sections/hero/hero.tsx @@ -2,7 +2,9 @@ import { buttonVariants } from '@/components/ui/button' import Content from '@/components/ui/content' import Heading from '@/components/ui/heading' import Socials from '@/components/ui/socials' +import { LINK } from '@/types/enums/Link' import { cn } from '@/utils' +import Image from 'next/image' import Link from 'next/link' import { FC, HTMLAttributes } from 'react' @@ -17,8 +19,15 @@ const Hero: FC = ({ className, ...props }) => { )} {...props} > + Background
@@ -32,7 +41,7 @@ const Hero: FC = ({ className, ...props }) => {
My Projects diff --git a/src/components/ui/logo.tsx b/src/components/ui/logo.tsx index 099525d..3c64032 100644 --- a/src/components/ui/logo.tsx +++ b/src/components/ui/logo.tsx @@ -1,6 +1,7 @@ 'use client' import HeaderContext from '@/store/header-context' +import { LINK } from '@/types/enums/Link' import { cn } from '@/utils' import Link from 'next/link' import { AnchorHTMLAttributes, FC, useContext } from 'react' @@ -13,7 +14,7 @@ const Logo: FC = ({ className, ...props }) => { return ( diff --git a/src/types/ProjectMetadata.ts b/src/types/ProjectMetadata.ts new file mode 100644 index 0000000..ce34f63 --- /dev/null +++ b/src/types/ProjectMetadata.ts @@ -0,0 +1,6 @@ +type ProjectMetadata = { + lastModified: Date + slug: string +} + +export default ProjectMetadata diff --git a/src/types/enums/Link.ts b/src/types/enums/Link.ts new file mode 100644 index 0000000..bc1c863 --- /dev/null +++ b/src/types/enums/Link.ts @@ -0,0 +1,10 @@ +export const LINK = { + about: '/#about-me', + contact: '/#contact-me', + index: '/', + projects: '/#my-projects', +} as const + +type Link = keyof typeof LINK + +export default Link diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f51d422..8657bcb 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1 +1,2 @@ export const MOBILE_BREAKPOINT = 767.98 +export const SITE_URL = process.env.SITE_URL || 'http://localhost:3000' diff --git a/src/utils/projects.ts b/src/utils/projects.ts index 6293bd3..a5e75ed 100644 --- a/src/utils/projects.ts +++ b/src/utils/projects.ts @@ -1,4 +1,6 @@ import Project from '@/types/Project' +import ProjectMetadata from '@/types/ProjectMetadata' +import fs from 'fs' import { readFile, readdir } from 'fs/promises' import matter from 'gray-matter' import { marked } from 'marked' @@ -8,16 +10,6 @@ const PROJECTS_PATH = './content/projects' const FILE_EXTENSION = '.md' const DIR_PATH = path.join(process.cwd(), PROJECTS_PATH) -export const getSlugs = async (): Promise => { - const files = await readdir(DIR_PATH) - - const slugs = files - .filter((file) => file.endsWith(FILE_EXTENSION)) - .map((file) => file.replace(FILE_EXTENSION, '')) - - return slugs -} - export const getProjects = async (): Promise => { const slugs = await getSlugs() @@ -40,11 +32,7 @@ export const getProjects = async (): Promise => { } export const getProject = async (slug: string): Promise => { - const filePath = path.join( - process.cwd(), - PROJECTS_PATH, - `${slug}${FILE_EXTENSION}` - ) + const filePath = getProjectFilePath(slug) const text = await readFile(filePath, 'utf-8') if (!text.trim()) throw new Error('Markdown file is empty!') @@ -62,3 +50,36 @@ export const getProject = async (slug: string): Promise => { return { body, features, github, image, slug, technologies, title, website } } + +export const getProjectsMetadata = async (): Promise => { + const slugs = await getSlugs() + + let projectsMetadata: ProjectMetadata[] = [] + + if (!slugs.length) return projectsMetadata + + for (const slug of slugs) { + const projectModifiedDate = getProjectModifiedDate(slug) + projectsMetadata.push({ lastModified: projectModifiedDate, slug }) + } + + return projectsMetadata +} + +export const getSlugs = async (): Promise => { + const files = await readdir(DIR_PATH) + const slugs = files + .filter((file) => file.endsWith(FILE_EXTENSION)) + .map((file) => file.replace(FILE_EXTENSION, '')) + return slugs +} + +export const getProjectFilePath = (slug: string): string => { + return path.join(process.cwd(), PROJECTS_PATH, `${slug}${FILE_EXTENSION}`) +} + +export const getProjectModifiedDate = (slug: string): Date => { + const filePath = getProjectFilePath(slug) + const fileStats = fs.statSync(filePath) + return new Date(fileStats.mtime.toISOString()) +}