-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
80 changed files
with
5,653 additions
and
1,046 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
POSTGRES_URL= | ||
POSTGRES_URL_NON_POOLING= | ||
POSTGRES_USER= | ||
POSTGRES_HOST= | ||
POSTGRES_PASSWORD= | ||
POSTGRES_DATABASE= | ||
POSTGRES_PRISMA_URL= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
name: GitHub Actions Vercel Preview Deployment | ||
env: | ||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | ||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | ||
on: | ||
push: | ||
branches-ignore: | ||
- main | ||
jobs: | ||
Deploy-Preview: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v2 | ||
- name: Install Vercel CLI | ||
run: npm install --global vercel@canary | ||
- name: Pull Vercel Environment Information | ||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} | ||
- name: Build Project Artifacts | ||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }} | ||
- name: Deploy Project Artifacts to Vercel | ||
run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
name: GitHub Actions Vercel Production Deployment | ||
env: | ||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} | ||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} | ||
on: | ||
push: | ||
branches: | ||
- main | ||
jobs: | ||
Deploy-Production: | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v2 | ||
- name: Install Vercel CLI | ||
run: npm install --global vercel@canary | ||
- name: Pull Vercel Environment Information | ||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} | ||
- name: Build Project Artifacts | ||
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} | ||
- name: Deploy Project Artifacts to Vercel | ||
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,6 +28,7 @@ yarn-error.log* | |
|
||
# env files | ||
.env | ||
.env.development.local | ||
|
||
# vercel | ||
.vercel | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"arrowParens": "always", | ||
"bracketSpacing": true, | ||
"trailingComma": "es5", | ||
"tabWidth": 4, | ||
"semi": true, | ||
"singleQuote": false, | ||
"endOfLine": "auto", | ||
"printWidth": 120, | ||
"plugins": ["prettier-plugin-organize-imports"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,42 +1,17 @@ | ||
# Portfolio Blog Starter | ||
# ivanstepanian.com | ||
|
||
This is a porfolio site template complete with a blog. Includes: | ||
Based on [Portfolio Blog Starter](https://github.com/vercel/examples/tree/main/solutions/blog). Design elements inspired by [Denis Snellenberg](https://dennissnellenberg.com). Typeface used: [Inter](https://rsms.me/inter/). | ||
|
||
Includes: | ||
|
||
- MDX and Markdown support | ||
- Optimized for SEO (sitemap, robots, JSON-LD schema) | ||
- RSS Feed | ||
- Internationalization (i18n) with [next-intl](https://next-intl-docs.vercel.app) | ||
- Dark mode with [next-themes](https://github.com/pacocoursey/next-themes) | ||
- Contact form via a [Server Action](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) and [Vercel Postgres](https://vercel.com/docs/storage/vercel-postgres) | ||
- Dynamic OG images | ||
- Syntax highlighting | ||
- Tailwind v4 | ||
- Vercel Speed Insights / Web Analytics | ||
- Geist font | ||
|
||
## Demo | ||
|
||
https://portfolio-blog-starter.vercel.app | ||
|
||
## How to Use | ||
|
||
You can choose from one of the following two methods to use this repository: | ||
|
||
### One-Click Deploy | ||
|
||
Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples): | ||
|
||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/examples/tree/main/solutions/blog&project-name=blog&repository-name=blog) | ||
|
||
### Clone and Deploy | ||
|
||
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [pnpm](https://pnpm.io/installation) to bootstrap the example: | ||
|
||
```bash | ||
pnpm create next-app --example https://github.com/vercel/examples/tree/main/solutions/blog blog | ||
``` | ||
|
||
Then, run Next.js in development mode: | ||
|
||
```bash | ||
pnpm dev | ||
``` | ||
# Demo | ||
|
||
Deploy it to the cloud with [Vercel](https://vercel.com/templates) ([Documentation](https://nextjs.org/docs/app/building-your-application/deploying)). | ||
[ivanstepanian.com](https://ivanstepanian.com) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
"use client"; | ||
|
||
import { useTranslations } from "next-intl"; | ||
import { useTheme } from "next-themes"; | ||
|
||
export default function DarkModeToggle() { | ||
const { theme, setTheme } = useTheme(); | ||
const isDark = theme === "dark"; | ||
|
||
const t = useTranslations("DarkModeToggle"); | ||
const label = t("label"); | ||
|
||
return ( | ||
<label className="flex items-center gap-2.5 cursor-pointer" htmlFor="dark-mode-toggle"> | ||
<input | ||
id="dark-mode-toggle" | ||
type="checkbox" | ||
checked={isDark} | ||
className="relative cursor-pointer appearance-none h-5 w-10 bg-black dark:bg-white before:w-4 before:h-4 before:transition-[transform_400ms,color_200ms] before:absolute before:top-0.5 before:left-0.5 before:border before:border-dark before:rounded-full dark:before:translate-x-5 before:bg-white before:dark:bg-dark rounded-full" | ||
onChange={() => setTheme(isDark ? "light" : "dark")} | ||
/> | ||
<p className="whitespace-nowrap font-medium">{label}</p> | ||
</label> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import Marquee from "react-fast-marquee"; | ||
import ApacheSparkLogo from "../../../public/logos/apache-spark.svg"; | ||
import AzureLogo from "../../../public/logos/azure.svg"; | ||
import HadoopLogo from "../../../public/logos/hadoop.svg"; | ||
import KubernetesLogo from "../../../public/logos/kubernetes.svg"; | ||
import NestJSLogo from "../../../public/logos/nestjs.svg"; | ||
import NextJSLogo from "../../../public/logos/nextjs.svg"; | ||
import PandasLogo from "../../../public/logos/pandas.svg"; | ||
import ScalaLogo from "../../../public/logos/scala.svg"; | ||
import TypeScriptLogo from "../../../public/logos/typescript.svg"; | ||
|
||
const logos = [ | ||
ApacheSparkLogo, | ||
KubernetesLogo, | ||
NextJSLogo, | ||
NestJSLogo, | ||
PandasLogo, | ||
ScalaLogo, | ||
TypeScriptLogo, | ||
AzureLogo, | ||
HadoopLogo, | ||
]; | ||
|
||
export type LogoMarqueeProps = { className?: string }; | ||
export default function LogoMarquee({ className }: Readonly<LogoMarqueeProps>) { | ||
return ( | ||
<div className={className}> | ||
<Marquee direction="left" gradient speed={40} gradientWidth={48}> | ||
{logos.map((Svg, idx) => ( | ||
<Svg key={idx} className="h-10 px-2 mx-6" /> | ||
))} | ||
</Marquee> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import { formatDate, getBlogPosts } from "app/[locale]/blog/utils"; | ||
import { CustomMDX } from "app/components/MDX"; | ||
import { baseUrl } from "app/utils/constants"; | ||
import { notFound } from "next/navigation"; | ||
|
||
export async function generateStaticParams() { | ||
const posts = getBlogPosts(); | ||
return posts.map((post) => ({ slug: post.slug })); | ||
} | ||
|
||
type BlogProps = { params: { slug: string; locale: string } }; | ||
export function generateMetadata({ params }: BlogProps) { | ||
const post = getBlogPosts().find((post) => post.slug === params.slug); | ||
if (!post) return; | ||
|
||
const { title, publishedAt: publishedTime, summary: description, image } = post.metadata; | ||
const ogImage = image ? image : `${baseUrl}/og?title=${encodeURIComponent(title)}`; | ||
const url = `${baseUrl}/${params.locale}/blog/${post.slug}`; | ||
|
||
return { | ||
title, | ||
description, | ||
openGraph: { title, description, type: "article", publishedTime, url, images: [{ url: ogImage }] }, | ||
twitter: { card: "summary_large_image", title, description, images: [ogImage] }, | ||
}; | ||
} | ||
|
||
export default function BlogPage({ params }: Readonly<BlogProps>) { | ||
const locale = params.locale; | ||
const post = getBlogPosts().find((post) => post.slug === params.slug); | ||
|
||
if (!post) notFound(); | ||
|
||
return ( | ||
<section className="contained w-full"> | ||
<script | ||
type="application/ld+json" | ||
suppressHydrationWarning | ||
dangerouslySetInnerHTML={{ | ||
__html: JSON.stringify({ | ||
"@context": "https://schema.org", | ||
"@type": "BlogPosting", | ||
headline: post.metadata.title, | ||
datePublished: post.metadata.publishedAt, | ||
dateModified: post.metadata.publishedAt, | ||
description: post.metadata.summary, | ||
image: post.metadata.image | ||
? `${baseUrl}${post.metadata.image}` | ||
: `/og?title=${encodeURIComponent(post.metadata.title)}`, | ||
url: `${baseUrl}/blog/${post.slug}`, | ||
author: { | ||
"@type": "Person", | ||
name: "Ivan Stepanian", | ||
}, | ||
}), | ||
}} | ||
/> | ||
<div className="bg-gray-100 dark:bg-neutral-800 transition-[background-color] duration-300 max-w-4xl mx-auto pad-screen pt-24 mb-24 pb-24"> | ||
<h1 className="title">{post.metadata.title}</h1> | ||
<div className="flex justify-between items-center mt-2 mb-8 text-xl"> | ||
<p>{formatDate(locale, post.metadata.publishedAt, true)}</p> | ||
</div> | ||
<article className="prose dark:prose-invert"> | ||
<CustomMDX source={post.content} /> | ||
</article> | ||
</div> | ||
</section> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import { RiArrowRightUpLine } from "@remixicon/react"; | ||
import ScrollToTop from "app/components/utils/ScrollToTop"; | ||
import { getLocale, getTranslations } from "next-intl/server"; | ||
import Link from "next/link"; | ||
import { dateSort, formatDate, getBlogPosts } from "./utils"; | ||
|
||
type LocaleProps = { params: { locale: string } }; | ||
export async function generateMetadata({ params: { locale } }: LocaleProps) { | ||
const t = await getTranslations({ locale, namespace: "Blog" }); | ||
return { | ||
title: t("meta.title"), | ||
description: t("meta.description"), | ||
}; | ||
} | ||
|
||
export default async function Page() { | ||
const locale = await getLocale(); | ||
const t = await getTranslations("Blog"); | ||
const allBlogs = getBlogPosts(); | ||
|
||
return ( | ||
<section className="mt-12 md:mt-24 pb-24"> | ||
<ScrollToTop /> | ||
<h1 className="title contained pad-screen">{t("title")}</h1> | ||
<div className="mt-12 md:mt-16 text-xl"> | ||
{allBlogs | ||
.sort((blog1, blog2) => dateSort(blog1.metadata.publishedAt, blog2.metadata.publishedAt)) | ||
.map((post) => ( | ||
<> | ||
<hr className="border-neutral-300 dark:border-neutral-600 transition-[border-color] duration-[300ms]" /> | ||
<Link | ||
key={post.slug} | ||
className="px-[calc(max(calc(50%-var(--max-width)/2),0px)+var(--pad-screen))] flex flex-col gap-1 py-6 hover:bg-primary/65 focus:bg-primary/65" | ||
href={`/${locale}/blog/${post.slug}`} | ||
> | ||
<div className="w-full flex flex-col md:flex-row space-x-0 md:gap-2"> | ||
<p className="w-52 opacity-50 tabular-nums"> | ||
{formatDate(locale, post.metadata.publishedAt, false)} | ||
</p> | ||
<p className="tracking-tight underline underline-offset-4 opacity-95"> | ||
{post.metadata.title} | ||
<RiArrowRightUpLine className="w-6 h-6 inline ml-1.5" /> | ||
</p> | ||
</div> | ||
</Link> | ||
</> | ||
))} | ||
<hr className="border-neutral-300 dark:border-neutral-600 transition-[border-color] duration-[300ms]" /> | ||
</div> | ||
</section> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
import { formatRelativeTime } from "app/utils/date"; | ||
import fs from "fs"; | ||
import path from "path"; | ||
|
||
export type Metadata = { | ||
title: string; | ||
publishedAt: string; | ||
summary: string; | ||
image: string; | ||
}; | ||
|
||
export type PartialArticle = { | ||
metadata: Partial<Metadata>; | ||
slug: string; | ||
content: string; | ||
}; | ||
|
||
export type Article = { | ||
metadata: Metadata; | ||
slug: string; | ||
content: string; | ||
}; | ||
|
||
function parseFrontmatter(fileContent: string) { | ||
const frontmatterRegex = /---\s*([\s\S]*?)\s*---/; | ||
const match = frontmatterRegex.exec(fileContent); | ||
const frontMatterBlock = match![1]; | ||
const content = fileContent.replace(frontmatterRegex, "").trim(); | ||
const frontMatterLines = frontMatterBlock.trim().split("\n"); | ||
const metadata: Partial<Metadata> = {}; | ||
|
||
frontMatterLines.forEach((line) => { | ||
const [key, ...valueArr] = line.split(": "); | ||
const value = valueArr | ||
.join(": ") | ||
.trim() | ||
.replace(/^['"](.*)['"]$/, "$1"); // Remove quotes | ||
metadata[key.trim() as keyof Metadata] = value; | ||
}); | ||
|
||
return { metadata, content }; | ||
} | ||
|
||
function getMDXFiles(dir: fs.PathLike) { | ||
return fs.readdirSync(dir).filter((file) => path.extname(file) === ".mdx"); | ||
} | ||
|
||
function readMDXFile(filePath: fs.PathLike) { | ||
const rawContent = fs.readFileSync(filePath, "utf-8"); | ||
return parseFrontmatter(rawContent); | ||
} | ||
|
||
function isMetadataComplete(article: PartialArticle): article is Article { | ||
const metadata = article.metadata; | ||
return !!(metadata.title && metadata.publishedAt && metadata.summary && metadata.image); | ||
} | ||
|
||
function getMDXData(dir: string) { | ||
const mdxFiles = getMDXFiles(dir); | ||
return mdxFiles | ||
.map((file) => { | ||
const { metadata, content } = readMDXFile(path.join(dir, file)); | ||
const slug = path.basename(file, path.extname(file)); | ||
|
||
return { metadata, slug, content }; | ||
}) | ||
.filter(isMetadataComplete); | ||
} | ||
|
||
export function getBlogPosts() { | ||
return getMDXData(path.join(process.cwd(), "posts")); | ||
} | ||
|
||
export function dateSort(date1: string | Date, date2: string | Date) { | ||
return new Date(date2).getTime() - new Date(date1).getTime(); | ||
} | ||
|
||
export function formatDate(locale: Intl.LocalesArgument, date: string, includeRelative = false) { | ||
console.log(locale, date, includeRelative); | ||
const targetDate = new Date(date); | ||
const fullDate = targetDate.toLocaleString(locale, { month: "long", day: "numeric", year: "numeric" }); | ||
|
||
if (!includeRelative) return fullDate; | ||
return `${fullDate} (${formatRelativeTime(locale, targetDate, "long")})`; | ||
} |
Oops, something went wrong.