Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
iv-stpn committed May 23, 2024
1 parent 554cb5e commit dd24ea4
Show file tree
Hide file tree
Showing 80 changed files with 5,653 additions and 1,046 deletions.
7 changes: 7 additions & 0 deletions .env.example
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=
21 changes: 21 additions & 0 deletions .github/workflows/preview.yaml
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 }}
21 changes: 21 additions & 0 deletions .github/workflows/production.yaml
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 }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ yarn-error.log*

# env files
.env
.env.development.local

# vercel
.vercel
Expand Down
11 changes: 11 additions & 0 deletions .prettierrc
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"]
}
43 changes: 9 additions & 34 deletions README.md
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)
25 changes: 25 additions & 0 deletions app/[locale]/_elements/DarkModeToggle.tsx
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>
);
}
35 changes: 35 additions & 0 deletions app/[locale]/_elements/LogoMarquee.tsx
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>
);
}
69 changes: 69 additions & 0 deletions app/[locale]/blog/[slug]/page.tsx
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>
);
}
52 changes: 52 additions & 0 deletions app/[locale]/blog/page.tsx
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>
);
}
85 changes: 85 additions & 0 deletions app/[locale]/blog/utils.ts
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")})`;
}
Loading

0 comments on commit dd24ea4

Please sign in to comment.