Just a buch of stuff
This commit is contained in:
parent
231bdfc079
commit
722ace7c74
31 changed files with 642 additions and 194 deletions
48
app/categories/[categorySlug]/page.tsx
Normal file
48
app/categories/[categorySlug]/page.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { metadata } from '@/app/metadata'
|
||||||
|
import { getCategoryBySlug, getPostsByCategory } from '@/lib/categories'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
type CategoryPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
categorySlug: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
|
||||||
|
const { categorySlug } = await params
|
||||||
|
const category = getCategoryBySlug(categorySlug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${category} | ${metadata.title!.toString()}`,
|
||||||
|
description: 'List of posts in this category',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||||
|
const { categorySlug } = await params
|
||||||
|
const posts = getPostsByCategory(categorySlug)
|
||||||
|
const category = getCategoryBySlug(categorySlug)
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Category not found</h1>
|
||||||
|
<Link href="/categories">Back to categories</Link>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Category: {category}</h1>
|
||||||
|
<ul>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<li key={post.slug}>
|
||||||
|
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { getAllPostsMeta } from "@/lib/posts"
|
|
||||||
import type { Metadata } from "next"
|
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
type CategoryPageProps = {
|
|
||||||
params: Promise<{
|
|
||||||
category: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params,
|
|
||||||
}: CategoryPageProps): Promise<Metadata> {
|
|
||||||
const { category } = await params
|
|
||||||
return { title: `Category: ${category}`, description: "List of posts in this category" }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function CategoryPage({
|
|
||||||
params,
|
|
||||||
}: CategoryPageProps) {
|
|
||||||
const { category } = await params
|
|
||||||
const posts = getAllPostsMeta()
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<h1>Category: {category}</h1>
|
|
||||||
<ul>
|
|
||||||
{posts
|
|
||||||
.filter((post) => post.categories.includes(category))
|
|
||||||
.map((post) => (
|
|
||||||
<li key={post.slug}>
|
|
||||||
<Link href={`/${post.slug}`}>{post.title}</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +1,43 @@
|
||||||
import { getAllPostsMeta } from "@/lib/posts"
|
import { getAllCategories } from '@/lib/categories'
|
||||||
import type { Metadata } from "next"
|
import { getAllPostsMeta } from '@/lib/posts'
|
||||||
import Link from "next/link"
|
import { slugify } from '@/utils/slug'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { metadata } from '@/app/metadata'
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
return { title: "Categories", description: "List of categories" }
|
return { title: `Categories | ${metadata.title!.toString()}`, description: 'List of categories' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
|
const categories = getAllCategories()
|
||||||
const posts = getAllPostsMeta()
|
const posts = getAllPostsMeta()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<div className="flex w-full">
|
||||||
<h1>Categories</h1>
|
<main className="max-w-3xl mx-auto px-4 py-8 lg:py-16 w-full">
|
||||||
<ul>
|
<h1 className="text-3xl font-bold">Categories</h1>
|
||||||
{Array.from(new Set(posts.flatMap((post) => post.categories))).map(
|
<ul>
|
||||||
(category) => (
|
{Object.keys(categories).map((category) => (
|
||||||
<li key={category}>
|
<li key={category}>
|
||||||
<h2>{category}</h2>
|
<Link href={`/categories/${slugify(category)}`}>
|
||||||
|
{category} ({categories[category]})
|
||||||
|
</Link>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{posts
|
{posts
|
||||||
.filter((post) => post.categories.includes(category))
|
.filter((post) => post.category === category)
|
||||||
.map((post) => (
|
.map((post) => (
|
||||||
<li key={post.slug}>
|
<li key={post.slug}>
|
||||||
<Link href={`/${post.slug}`}>{post.title}</Link>
|
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
),
|
))}
|
||||||
)}
|
</ul>
|
||||||
</ul>
|
</main>
|
||||||
</main>
|
<div className="w-80" />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import meta from "@/const/meta"
|
import { metadata } from "@/app/metadata"
|
||||||
import { getAllPostsMeta } from "@/lib/posts"
|
import { getAllPostsMeta } from "@/lib/posts"
|
||||||
|
|
||||||
const SITE_URL = process.env.SITE_URL || "http://localhost:3000"
|
const SITE_URL = process.env.SITE_URL || "http://localhost:3000"
|
||||||
|
|
@ -12,7 +12,7 @@ function escapeXml(str: string) {
|
||||||
.replace(/'/g, "'")
|
.replace(/'/g, "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: Request) {
|
||||||
const posts = getAllPostsMeta()
|
const posts = getAllPostsMeta()
|
||||||
|
|
||||||
const updated =
|
const updated =
|
||||||
|
|
@ -39,18 +39,24 @@ export async function GET() {
|
||||||
|
|
||||||
const feed = `<?xml version="1.0" encoding="UTF-8"?>
|
const feed = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
<title>${escapeXml(meta.title)}</title>
|
|
||||||
<id>${SITE_URL}/</id>
|
<id>${SITE_URL}/</id>
|
||||||
|
<title>${escapeXml(metadata.title!.toString())}</title>
|
||||||
<updated>${updated}</updated>
|
<updated>${updated}</updated>
|
||||||
<author>
|
<author>
|
||||||
<name>${escapeXml("d")}</name>
|
<name>${escapeXml(metadata.creator!.toString())}</name>
|
||||||
</author>
|
</author>
|
||||||
<link href="${SITE_URL}/feed" rel="self" type="application/atom+xml" />
|
<link href="${SITE_URL}/feed" rel="self" type="application/atom+xml" />
|
||||||
<link href="${SITE_URL}" rel="alternate" type="text/html" />
|
<link href="${SITE_URL}" rel="alternate" type="text/html" />
|
||||||
|
<rights> © ${new Date().getFullYear()} Frank Borisjuk </rights>
|
||||||
${entries}
|
${entries}
|
||||||
</feed>`
|
</feed>`
|
||||||
|
|
||||||
|
const accept = req.headers.get("accept") || ""
|
||||||
|
const contentType = accept.includes("application/atom+xml")
|
||||||
|
? "application/atom+xml; charset=utf-8"
|
||||||
|
: "application/xml; charset=utf-8"
|
||||||
|
|
||||||
return new Response(feed, {
|
return new Response(feed, {
|
||||||
headers: { "Content-Type": "application/atom+xml; charset=utf-8" },
|
headers: { "Content-Type": contentType },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -4,14 +4,16 @@
|
||||||
@theme {
|
@theme {
|
||||||
--color-bg: #040404;
|
--color-bg: #040404;
|
||||||
--color-fg: #e0e0e0;
|
--color-fg: #e0e0e0;
|
||||||
--color-fg-dim: #909090;
|
--color-fg-dim: #b0b0b0;
|
||||||
--color-accent: #ef1f1f;
|
--color-accent: #ef1f1f;
|
||||||
--color-border: #374151;
|
--color-border: #374151;
|
||||||
|
|
||||||
--color-warning: #facc15;
|
--color-warning: #facc15;
|
||||||
--color-tip: #22c55e;
|
--color-tip: #22c55e;
|
||||||
--color-info: #3b82f6;
|
--color-info: #3b82f6;
|
||||||
--color-note: #8b5cf6;
|
--color-quote: #f97316;
|
||||||
|
|
||||||
|
--font-sans: 'Space Grotesk', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs {
|
.hljs {
|
||||||
|
|
@ -22,19 +24,27 @@ pre code.hljs {
|
||||||
@apply p-0;
|
@apply p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply scroll-smooth;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-bg text-fg;
|
@apply bg-bg text-fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose {
|
.prose {
|
||||||
@apply space-y-4;
|
@apply space-y-4 text-fg-dim;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
@apply list-disc list-inside;
|
@apply list-disc;
|
||||||
}
|
}
|
||||||
|
|
||||||
ol {
|
ol {
|
||||||
@apply list-decimal list-inside;
|
@apply list-decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
@apply list-outside;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
|
|
@ -75,6 +85,10 @@ tbody tr:nth-child(even) {
|
||||||
@apply bg-fg/5;
|
@apply bg-fg/5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
@apply bg-fg/10;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Definition lists ─────────────────────────────── */
|
/* ── Definition lists ─────────────────────────────── */
|
||||||
|
|
||||||
dl {
|
dl {
|
||||||
|
|
@ -125,7 +139,7 @@ dd {
|
||||||
.prose h4,
|
.prose h4,
|
||||||
.prose h5,
|
.prose h5,
|
||||||
.prose h6 {
|
.prose h6 {
|
||||||
@apply font-bold mb-2;
|
@apply font-bold mb-2 text-fg;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@apply content-['#'] text-accent mr-1;
|
@apply content-['#'] text-accent mr-1;
|
||||||
|
|
@ -183,7 +197,7 @@ hr {
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
@apply border-l-4 pl-4 py-1 text-fg-dim bg-accent/20;
|
@apply border-l-4 pl-4 py-2 text-fg-dim bg-border/20;
|
||||||
|
|
||||||
&.warning {
|
&.warning {
|
||||||
@apply border-warning bg-warning/20 text-warning;
|
@apply border-warning bg-warning/20 text-warning;
|
||||||
|
|
@ -196,6 +210,10 @@ hr {
|
||||||
&.info {
|
&.info {
|
||||||
@apply border-info bg-info/20 text-info;
|
@apply border-info bg-info/20 text-info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.quote {
|
||||||
|
@apply border-quote bg-quote/20 text-quote;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-js .js-only {
|
.no-js .js-only {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,22 @@
|
||||||
import { Geist, Geist_Mono } from 'next/font/google'
|
import { Space_Grotesk } from 'next/font/google'
|
||||||
import './globals.css'
|
|
||||||
import { Metadata } from 'next'
|
|
||||||
import meta from '@/const/meta'
|
|
||||||
import Script from 'next/script'
|
import Script from 'next/script'
|
||||||
import { Sidebar } from '@/components/layout/Sidebar'
|
import { Sidebar } from '@/components/layout/Sidebar'
|
||||||
|
import { metadata } from './metadata'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
const geistSans = Geist({
|
const spaceGrotesk = Space_Grotesk({
|
||||||
variable: '--font-geist-sans',
|
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
|
variable: '--font-sans',
|
||||||
|
display: 'swap',
|
||||||
|
weight: ['400', '500', '600', '700'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: '--font-geist-mono',
|
|
||||||
subsets: ['latin'],
|
|
||||||
})
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: meta.title,
|
|
||||||
description: meta.description,
|
|
||||||
creator: meta.creator,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="en" className={`${spaceGrotesk.variable} h-full antialiased no-js`}>
|
||||||
lang="en"
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased no-js`}
|
|
||||||
>
|
|
||||||
<Script
|
<Script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `document.documentElement.classList.replace('no-js','js')`,
|
__html: `document.documentElement.classList.replace('no-js','js')`,
|
||||||
|
|
@ -43,3 +29,5 @@ export default function RootLayout({
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { metadata }
|
||||||
|
|
|
||||||
7
app/metadata.tsx
Normal file
7
app/metadata.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Ephemeris",
|
||||||
|
description: "A blog about me and my interests",
|
||||||
|
creator: "hangerthem",
|
||||||
|
}
|
||||||
74
app/page.tsx
74
app/page.tsx
|
|
@ -1,19 +1,63 @@
|
||||||
import Link from 'next/link';
|
import { getAllPostsMeta } from '@/lib/posts'
|
||||||
import { getAllPostsMeta } from '@/lib/posts';
|
import { Post } from '@/components/common/Post'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function BlogIndex() {
|
export default function BlogIndex() {
|
||||||
const posts = getAllPostsMeta();
|
const posts = getAllPostsMeta()
|
||||||
|
|
||||||
|
const rest = posts.slice(1)
|
||||||
|
const rows = []
|
||||||
|
for (let i = 0; i < rest.length; i += 2) {
|
||||||
|
rows.push(rest.slice(i, i + 2))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<>
|
||||||
<h1>Blog</h1>
|
<main className="max-w-3xl mx-auto px-4 py-8 lg:py-16">
|
||||||
<ul>
|
<section>
|
||||||
{posts.map((post) => (
|
{posts.length === 0 ? (
|
||||||
<li key={post.slug}>
|
<div className="rounded-xl border border-border p-6 text-fg-dim">
|
||||||
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
|
No published posts yet.
|
||||||
<p>{post.description}</p>
|
</div>
|
||||||
</li>
|
) : (
|
||||||
))}
|
<>
|
||||||
</ul>
|
<Post post={posts[0]} isLatest />
|
||||||
</main>
|
<div className="border-t border-border my-6" aria-hidden="true"></div>
|
||||||
);
|
<div className="flex flex-col gap-8 mt-4">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const rowHasImage = row.some((p) => p.coverImage)
|
||||||
|
return (
|
||||||
|
<div key={row[0].slug} className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{row.map((post) => (
|
||||||
|
<Post post={post} key={post.slug} showImageSlot={rowHasImage} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<aside className="w-80 hidden lg:block px-4 py-8 lg:py-16">
|
||||||
|
<div className="sticky top-8">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Recently updated</h2>
|
||||||
|
<ul className="flex flex-col gap-2 ml-2">
|
||||||
|
{posts
|
||||||
|
.toSorted(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.updated ?? b.date).getTime() - new Date(a.updated ?? a.date).getTime(),
|
||||||
|
)
|
||||||
|
.map((post) => (
|
||||||
|
<li key={post.slug} className="text-sm text-fg-dim">
|
||||||
|
<Link href={`/posts/${post.slug}`} className="hover:underline">
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import { metadata } from '@/app/metadata'
|
||||||
import { ImageWrapper } from '@/components/common/ImageWrapper'
|
import { ImageWrapper } from '@/components/common/ImageWrapper'
|
||||||
|
import { ShareLinks } from '@/components/common/ShareLinks'
|
||||||
import { TableOfContents } from '@/components/common/TableOfContents'
|
import { TableOfContents } from '@/components/common/TableOfContents'
|
||||||
import { getAllPostSlugs, getPostBySlug } from '@/lib/posts'
|
import { getAllPostSlugs, getPostBySlug } from '@/lib/posts'
|
||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
return getAllPostSlugs().map((slug) => ({ slug }))
|
return getAllPostSlugs().map((slug) => ({ slug }))
|
||||||
|
|
@ -16,7 +19,7 @@ export async function generateMetadata({
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { slug } = await params
|
||||||
const { meta } = await getPostBySlug(slug)
|
const { meta } = await getPostBySlug(slug)
|
||||||
return { title: meta.title, description: meta.description }
|
return { title: `${meta.title} | ${metadata.title!.toString()}`, description: meta.description }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
|
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
|
@ -24,31 +27,83 @@ export default async function Post({ params }: { params: Promise<{ slug: string
|
||||||
const { meta, content, tableOfContents } = await getPostBySlug(slug)
|
const { meta, content, tableOfContents } = await getPostBySlug(slug)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="flex w-full">
|
<main className="flex w-full">
|
||||||
<main className="max-w-3xl mx-auto px-4 py-8 lg:py-16">
|
<article className="max-w-3xl mx-auto px-4 py-8 lg:py-16">
|
||||||
{meta.categories.length > 0 && (
|
{meta.category && (
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<Link
|
||||||
{meta.categories.map((category) => (
|
href={`/categories/${meta.category.toLowerCase()}`}
|
||||||
<span
|
className="text-accent hover:underline font-sm mb-2 block"
|
||||||
key={category}
|
>
|
||||||
className="bg-accent/20 text-accent px-2 py-1 rounded-full text-sm"
|
{meta.category}
|
||||||
>
|
</Link>
|
||||||
{category}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<h1 className="text-5xl font-bold mb-4">{meta.title}</h1>
|
<h1 className="text-4xl font-bold mb-4">{meta.title}</h1>
|
||||||
<span>{meta.readingTimeText}</span>
|
<div className="flex items-center gap-2 text-fg-dim mb-4 border-y border-border py-2 px-4 text-sm">
|
||||||
<time>{new Date(meta.date).toLocaleDateString()}</time>
|
<div className="flex items-center gap-2">
|
||||||
<p>{meta.description}</p>
|
<span>Posted</span>
|
||||||
|
<time
|
||||||
|
dateTime={meta.date}
|
||||||
|
title={new Date(meta.date).toLocaleString()}
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{new Date(meta.date).toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<span>·</span>
|
||||||
|
{meta.updated && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Updated</span>
|
||||||
|
<time
|
||||||
|
dateTime={meta.updated}
|
||||||
|
title={new Date(meta.updated).toLocaleString()}
|
||||||
|
className="font-semibold"
|
||||||
|
>
|
||||||
|
{new Date(meta.updated).toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<span>·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>{meta.readingTimeText}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-fg text-lg mb-4">{meta.description}</p>
|
||||||
{meta.coverImage && <ImageWrapper src={meta.coverImage} alt={meta.coverImageAlt} />}
|
{meta.coverImage && <ImageWrapper src={meta.coverImage} alt={meta.coverImageAlt} />}
|
||||||
<div className="prose">{content}</div>
|
<div className="prose">{content}</div>
|
||||||
</main>
|
|
||||||
|
{meta.tags && (
|
||||||
|
<div className="mt-6 border-t border-border pt-4 flex gap-2 items-center flex-wrap">
|
||||||
|
<h2 className="text-lg font-bold">Tags</h2>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{meta.tags.map((tag) => (
|
||||||
|
<span key={tag} className="bg-accent/20 text-accent px-3 py-1 rounded-full text-xs">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 mx-auto flex">
|
||||||
|
<ShareLinks
|
||||||
|
url={process.env.SITE_URL + `/posts/${slug}`}
|
||||||
|
title={meta.title}
|
||||||
|
description={meta.description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<aside className="hidden lg:block w-80">
|
<aside className="hidden lg:block w-80">
|
||||||
<TableOfContents items={tableOfContents} />
|
<TableOfContents items={tableOfContents} />
|
||||||
</aside>
|
</aside>
|
||||||
</article>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -27,6 +27,7 @@
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.2",
|
||||||
|
"simple-icons": "^16.28.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
|
|
@ -1120,6 +1121,8 @@
|
||||||
|
|
||||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"simple-icons": ["simple-icons@16.28.0", "", {}, "sha512-sQPR5AtK/ijRjou7zw7mlLp08oB6FH7i0lOy5XJ2zp9mJs/yejgiOn7KvQoe2q4YJIx6VmgUSW5AOefebPt5kg=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { NoteType } from '@/types/Note.type'
|
import type { NoteType } from '@/types/Note.type'
|
||||||
import { Lightbulb, FileWarning, TriangleAlert } from 'lucide-react'
|
import { Lightbulb, FileWarning, TriangleAlert, Quote } from 'lucide-react'
|
||||||
|
|
||||||
type BlockquoteWrapperProps = {
|
type BlockquoteWrapperProps = {
|
||||||
type?: NoteType
|
type?: NoteType
|
||||||
|
|
@ -10,6 +10,7 @@ const noteTypeIcons: Record<NoteType, React.ReactNode> = {
|
||||||
warning: <TriangleAlert className="w-4 h-4" />,
|
warning: <TriangleAlert className="w-4 h-4" />,
|
||||||
tip: <Lightbulb className="w-4 h-4" />,
|
tip: <Lightbulb className="w-4 h-4" />,
|
||||||
info: <FileWarning className="w-4 h-4" />,
|
info: <FileWarning className="w-4 h-4" />,
|
||||||
|
quote: <Quote className="w-4 h-4" />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BlockquoteWrapper({ type, children }: BlockquoteWrapperProps) {
|
export function BlockquoteWrapper({ type, children }: BlockquoteWrapperProps) {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function CodeWrapper({ language, children }: CodeWrapperProps) {
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="select-none text-right py-2 px-3 border-r border-border text-white font-mono text-sm leading-6 min-w-6">
|
<div className="select-none text-right py-2 px-3 border-r border-border text-white font-mono text-sm leading-6 min-w-6">
|
||||||
{Array.from({ length: lineCount }, (_, i) => (
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
<span className="block" key={i}>
|
<span className="block text-fg-dim" key={i}>
|
||||||
{i + 1}
|
{i + 1}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ButtonHTMLAttributes } from 'react'
|
import { ButtonHTMLAttributes, useRef } from 'react'
|
||||||
import { Copy } from 'lucide-react'
|
import { Copy } from 'lucide-react'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
|
||||||
|
|
@ -9,14 +9,27 @@ type CopyButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CopyButton({ textToCopy, className, ...props }: CopyButtonProps) {
|
export function CopyButton({ textToCopy, className, ...props }: CopyButtonProps) {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(textToCopy)
|
navigator.clipboard.writeText(textToCopy)
|
||||||
|
const button = buttonRef.current
|
||||||
|
if (button) {
|
||||||
|
const original = button.innerHTML
|
||||||
|
button.innerHTML = 'Copied!'
|
||||||
|
button.disabled = true
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = original
|
||||||
|
button.disabled = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn('text-fg-dim hover:text-fg transition-colors js-only', className)}
|
className={cn('text-fg-dim hover:text-fg transition-colors js-only text-xs cursor-pointer disabled:cursor-default disabled:hover:text-fg-dim', className)}
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
|
ref={buttonRef}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ type ImageWrapperProps = {
|
||||||
|
|
||||||
export function ImageWrapper({ src, alt, isListing }: ImageWrapperProps) {
|
export function ImageWrapper({ src, alt, isListing }: ImageWrapperProps) {
|
||||||
return (
|
return (
|
||||||
<figure className="flex flex-col items-center gap-1 w-full">
|
<figure className={cn('flex flex-col items-center gap-1 mb-4', isListing ? 'h-60' : 'w-full')}>
|
||||||
<div className="border border-border rounded-xl w-full overflow-hidden">
|
<div className="border border-border rounded-xl w-full overflow-hidden flex-1">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-2 h-6 border-b border-border w-full bg-fg/10"
|
className="flex items-center gap-2 px-2 h-6 border-b border-border w-full bg-fg/10"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
@ -29,7 +29,7 @@ export function ImageWrapper({ src, alt, isListing }: ImageWrapperProps) {
|
||||||
height={isListing ? 240 : 400}
|
height={isListing ? 240 : 400}
|
||||||
quality={100}
|
quality={100}
|
||||||
priority={true}
|
priority={true}
|
||||||
className={cn('w-auto mx-auto object-cover', isListing ? 'h-60' : '')}
|
className="w-auto mx-auto object-cover h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isListing && alt && (
|
{!isListing && alt && (
|
||||||
|
|
|
||||||
60
components/common/Post.tsx
Normal file
60
components/common/Post.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { ImageWrapper } from '@/components/common/ImageWrapper'
|
||||||
|
import { cn } from '@/utils/cn'
|
||||||
|
import { PostMeta } from '@/types/Post.type'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { dateFormatter } from '@/utils/time'
|
||||||
|
|
||||||
|
type PostProps = {
|
||||||
|
post: PostMeta
|
||||||
|
isLatest?: boolean
|
||||||
|
showImageSlot?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Post({ post, isLatest, showImageSlot }: PostProps) {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
author,
|
||||||
|
category,
|
||||||
|
date,
|
||||||
|
coverImage,
|
||||||
|
coverImageAlt,
|
||||||
|
readingTimeText,
|
||||||
|
slug,
|
||||||
|
} = post
|
||||||
|
|
||||||
|
if (!title || !description || !author || !date) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedAt = new Date(date)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={cn('flex flex-col gap-4', isLatest && 'items-center md:flex-row')}>
|
||||||
|
{(isLatest || showImageSlot) && (
|
||||||
|
<div className={isLatest && coverImage ? 'flex-1' : 'h-60'}>
|
||||||
|
{coverImage && <ImageWrapper src={coverImage} alt={coverImageAlt ?? title} isListing />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn('flex flex-col gap-1', isLatest && 'flex-1')}>
|
||||||
|
{isLatest && <span className="text-xs font-mono text-accent">Latest post</span>}
|
||||||
|
{category && (
|
||||||
|
<div className="font-mono text-xs flex gap-1 flex-wrap text-fg-dim mt-1 categories">
|
||||||
|
<span>{category}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 className="text-2xl font-bold">
|
||||||
|
<Link href={`/posts/${slug}`} className="hover:underline">
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
<p className="text-fg-dim font-mono text-sm">{description}</p>
|
||||||
|
<div className="flex gap-2 items-center text-fg-dim text-xs font-mono mt-1">
|
||||||
|
<time dateTime={publishedAt.toISOString()}>{dateFormatter.format(publishedAt)}</time>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{readingTimeText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
components/common/ShareLinks.tsx
Normal file
50
components/common/ShareLinks.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { siBluesky, siX, siFacebook, siReddit } from 'simple-icons';
|
||||||
|
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||||
|
|
||||||
|
type ShareLinksProps = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShareLinks({ url, title, description }: ShareLinksProps) {
|
||||||
|
const shareText = `Check out this post: ${title} - ${description}\n\n${url}`;
|
||||||
|
|
||||||
|
const networks = [
|
||||||
|
{
|
||||||
|
name: 'Bluesky',
|
||||||
|
icon: siBluesky,
|
||||||
|
href: `https://bsky.app/intent/compose?text=${encodeURIComponent(shareText)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'X',
|
||||||
|
icon: siX,
|
||||||
|
href: `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Facebook',
|
||||||
|
icon: siFacebook,
|
||||||
|
href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Reddit',
|
||||||
|
icon: siReddit,
|
||||||
|
href: `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(
|
||||||
|
title,
|
||||||
|
)}&text=${encodeURIComponent(description ?? '')}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 mx-auto">
|
||||||
|
{networks.map(({ name, icon, href }) => (
|
||||||
|
<ExternalLink key={name} href={href}>
|
||||||
|
<svg
|
||||||
|
dangerouslySetInnerHTML={{ __html: icon.svg }}
|
||||||
|
className="inline-block w-6 h-6 mr-1 fill-current hover:text-accent transition-colors"
|
||||||
|
/>
|
||||||
|
</ExternalLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
components/layout/NavLinks.tsx
Normal file
59
components/layout/NavLinks.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn'
|
||||||
|
import { Folder, Home, Info, Rss, Tags } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
href: string
|
||||||
|
label: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
target?: string
|
||||||
|
rel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ href: '/', label: 'Home', icon: <Home className="w-4 h-4" /> },
|
||||||
|
{ href: '/categories', label: 'Categories', icon: <Folder className="w-4 h-4" /> },
|
||||||
|
{ href: '/about', label: 'About', icon: <Info className="w-4 h-4" /> },
|
||||||
|
{ href: '/tags', label: 'Tags', icon: <Tags className="w-4 h-4" /> },
|
||||||
|
{
|
||||||
|
href: '/feed.xml',
|
||||||
|
label: 'RSS Feed',
|
||||||
|
icon: <Rss className="w-4 h-4" />,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function NavLinks() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
if (href === '/') {
|
||||||
|
return pathname === href
|
||||||
|
}
|
||||||
|
return pathname?.startsWith(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
return navItems.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.href}
|
||||||
|
className={cn(
|
||||||
|
'transition-colors rounded-md',
|
||||||
|
isActive(item.href) ? 'text-fg bg-fg/25' : 'text-fg-dim hover:text-fg hover:bg-fg/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center gap-2 px-2 py-1"
|
||||||
|
target={item.target}
|
||||||
|
rel={item.rel}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,17 @@
|
||||||
|
import { metadata } from '@/app/metadata'
|
||||||
|
import { NotepadText } from 'lucide-react'
|
||||||
|
import { NavLinks } from '@/components/layout/NavLinks'
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
return (
|
return (
|
||||||
<aside className="hidden lg:block w-64 shrink-0">
|
<aside className="hidden lg:block w-64 shrink-0 px-4 bg-fg/5">
|
||||||
<div className="sticky top-6">
|
<div className="sticky top-6">
|
||||||
<h2 className="text-lg font-bold mb-4">Navigation</h2>
|
<h2 className="text-2xl font-bold mb-4 flex items-center gap-2">
|
||||||
|
<NotepadText className="w-6 h-6" />
|
||||||
|
<span>{metadata.title!.toString()}</span>
|
||||||
|
</h2>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li>
|
<NavLinks />
|
||||||
<a href="/" className="text-blue-500 hover:underline">
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/about" className="text-blue-500 hover:underline">
|
|
||||||
About
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/contact" className="text-blue-500 hover:underline">
|
|
||||||
Contact
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
const meta = {
|
|
||||||
title: "Ephemeris",
|
|
||||||
description: "A blog about me and my interests",
|
|
||||||
creator: "Frank Borisjuk",
|
|
||||||
}
|
|
||||||
|
|
||||||
export default meta
|
|
||||||
|
|
@ -3,7 +3,7 @@ title: First Post
|
||||||
description: Welcome to my blog! I'm Frank Borisjuk, a software developer with a passion for technology, reading, and gaming. Join me on this journey as I share my thoughts, projects, and passions with you.
|
description: Welcome to my blog! I'm Frank Borisjuk, a software developer with a passion for technology, reading, and gaming. Join me on this journey as I share my thoughts, projects, and passions with you.
|
||||||
author: hangerthem
|
author: hangerthem
|
||||||
date: 2024-08-04 15:00:00 +0200
|
date: 2024-08-04 15:00:00 +0200
|
||||||
categories: [Introduction]
|
category: Introduction
|
||||||
tags: [introduction, about, tech, books, games]
|
tags: [introduction, about, tech, books, games]
|
||||||
math: true
|
math: true
|
||||||
mermaid: true
|
mermaid: true
|
||||||
|
|
@ -3,13 +3,14 @@ title: Self-Development and Gamification of Life
|
||||||
description: In this post, I explore the concept of self-development and how gamification can be used to enhance productivity and personal growth. Learn how to turn your life into a game and level up your skills!
|
description: In this post, I explore the concept of self-development and how gamification can be used to enhance productivity and personal growth. Learn how to turn your life into a game and level up your skills!
|
||||||
author: hangerthem
|
author: hangerthem
|
||||||
date: 2024-08-05 22:00:00 +0200
|
date: 2024-08-05 22:00:00 +0200
|
||||||
categories: [Personal Development]
|
category: "Personal Development"
|
||||||
tags: [self-development, gamification, productivity, growth]
|
tags: [self-development, gamification, productivity, growth]
|
||||||
math: true
|
math: true
|
||||||
mermaid: true
|
mermaid: true
|
||||||
---
|
---
|
||||||
|
|
||||||
> "The only person you are destined to become is the person you decide to be." - Ralph Waldo Emerson
|
> [!QUOTE] "The only person you are destined to become is the person you decide to be."
|
||||||
|
> - Ralph Waldo Emerson
|
||||||
|
|
||||||
Right at the beginning of this post, I want to make it clear that I am not a self-help guru or a life coach. I am just a regular person who is passionate about personal development and enjoys exploring ways to improve myself. In this post, I want to share some thoughts on self-development and how gamification can be used to enhance productivity and personal growth.
|
Right at the beginning of this post, I want to make it clear that I am not a self-help guru or a life coach. I am just a regular person who is passionate about personal development and enjoys exploring ways to improve myself. In this post, I want to share some thoughts on self-development and how gamification can be used to enhance productivity and personal growth.
|
||||||
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
title: Standard Model of Particle Physics
|
title: Standard Model of Particle Physics
|
||||||
description: This post provides an overview of the Standard Model of particle physics, including its basic components and interactions. It covers fundamental particles, forces, and the Higgs mechanism, highlighting key concepts and limitations of the model.
|
description: This post provides an overview of the Standard Model of particle physics, including its basic components and interactions. It covers fundamental particles, forces, and the Higgs mechanism, highlighting key concepts and limitations of the model.
|
||||||
author: hangerthem
|
author: hangerthem
|
||||||
date: 2024-08-20 21:15:00 +0200
|
date: 2023-08-20 21:15:00 +0200
|
||||||
categories: [Physics]
|
updated: 2026-08-05 22:00:00 +0200
|
||||||
|
category: Physics
|
||||||
tags:
|
tags:
|
||||||
[
|
[
|
||||||
Standard Model,
|
Standard Model,
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
---
|
---
|
||||||
title: Hello, world
|
title: Hello, world
|
||||||
description: This is the first post on the gly.cz blog.
|
description: This is the first post on the gly.cz blog.
|
||||||
date: 2026-07-23
|
date: 2026-07-23 15:00:00 +0200
|
||||||
author: hangerthem
|
author: hangerthem
|
||||||
categories: ["blog", "hello world"]
|
category: Blog
|
||||||
coverImage: image.png
|
coverImage: image.png
|
||||||
coverImageAlt: A picture of the hello world text in green on a black background.
|
coverImageAlt: A picture of the hello world text in green on a black background.
|
||||||
math: true
|
math: true
|
||||||
|
|
|
||||||
43
lib/categories.ts
Normal file
43
lib/categories.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { findSlugInArray } from "@/utils/slug"
|
||||||
|
import { getAllPostsMeta } from "@/lib/posts"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all unique categories from the posts and counts the number of posts in each category.
|
||||||
|
* @returns An object where keys are category names and values are the count of posts in that category.
|
||||||
|
*/
|
||||||
|
export function getAllCategories(): Record<string, number> {
|
||||||
|
const posts = getAllPostsMeta()
|
||||||
|
const categories = {} as Record<string, number>
|
||||||
|
|
||||||
|
posts.forEach((post) => {
|
||||||
|
const category = post.category
|
||||||
|
categories[category] = (categories[category] || 0) + 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return categories
|
||||||
|
}
|
||||||
|
|
||||||
|
/** * Finds a category by its slug.
|
||||||
|
* @param categorySlug - The slug of the category to find.
|
||||||
|
* @returns The original category name if found, otherwise null.
|
||||||
|
*/
|
||||||
|
export function getCategoryBySlug(categorySlug: string): string | null {
|
||||||
|
const categories = getAllCategories()
|
||||||
|
return findSlugInArray(categorySlug, Object.keys(categories))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all posts that belong to a specific category.
|
||||||
|
* @param categorySlug - The slug of the category to filter posts by.
|
||||||
|
* @returns An array of posts that belong to the specified category.
|
||||||
|
*/
|
||||||
|
export function getPostsByCategory(categorySlug: string) {
|
||||||
|
const categories = getAllCategories()
|
||||||
|
const category = findSlugInArray(categorySlug, Object.keys(categories))
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return getAllPostsMeta().filter((post) => post.category === category)
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import type { NoteType } from '@/types/Note.type'
|
||||||
import { rehypeMark } from './rehype-mark'
|
import { rehypeMark } from './rehype-mark'
|
||||||
import { rehypeDefinition } from './rehype-definition'
|
import { rehypeDefinition } from './rehype-definition'
|
||||||
import { slugify } from '@/utils/slug'
|
import { slugify } from '@/utils/slug'
|
||||||
|
import { PostMeta } from '@/types/Post.type'
|
||||||
|
|
||||||
const postsDirectory = path.join(process.cwd(), 'content/posts')
|
const postsDirectory = path.join(process.cwd(), 'content/posts')
|
||||||
|
|
||||||
|
|
@ -38,21 +39,6 @@ function getTextContent(node: ReactNode): string {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostMeta {
|
|
||||||
slug: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
author: string
|
|
||||||
date: string
|
|
||||||
categories: string[]
|
|
||||||
tags: string[]
|
|
||||||
math: boolean
|
|
||||||
mermaid: boolean
|
|
||||||
readingTimeText: string
|
|
||||||
coverImage?: string
|
|
||||||
coverImageAlt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAllPostSlugs(): string[] {
|
export function getAllPostSlugs(): string[] {
|
||||||
return fs
|
return fs
|
||||||
.readdirSync(postsDirectory)
|
.readdirSync(postsDirectory)
|
||||||
|
|
@ -64,8 +50,12 @@ export function getAllPostsMeta(): PostMeta[] {
|
||||||
return getAllPostSlugs()
|
return getAllPostSlugs()
|
||||||
.map((slug) => {
|
.map((slug) => {
|
||||||
const fullPath = path.join(postsDirectory, `${slug}.md`)
|
const fullPath = path.join(postsDirectory, `${slug}.md`)
|
||||||
const { data } = matter(fs.readFileSync(fullPath, 'utf8'))
|
const { data, content } = matter(fs.readFileSync(fullPath, 'utf8'))
|
||||||
return Object.assign({ slug }, data as Omit<PostMeta, 'slug'>)
|
const readingTimeText = readingTime(content).text
|
||||||
|
return Object.assign({ slug }, {
|
||||||
|
...data,
|
||||||
|
readingTimeText,
|
||||||
|
} as Omit<PostMeta, 'slug'>)
|
||||||
})
|
})
|
||||||
.toSorted((a, b) => (a.date < b.date ? 1 : -1))
|
.toSorted((a, b) => (a.date < b.date ? 1 : -1))
|
||||||
}
|
}
|
||||||
|
|
@ -170,11 +160,3 @@ export async function getPostBySlug(slug: string) {
|
||||||
tableOfContents,
|
tableOfContents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPostsByCategory(category: string): PostMeta[] {
|
|
||||||
return getAllPostsMeta().filter((post) => post.categories.includes(category))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPostsByTag(tag: string): PostMeta[] {
|
|
||||||
return getAllPostsMeta().filter((post) => post.tags.includes(tag))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,56 @@ import type { Root, Element, ElementContent, Text } from 'hast'
|
||||||
import type { Plugin } from 'unified'
|
import type { Plugin } from 'unified'
|
||||||
import { visit } from 'unist-util-visit'
|
import { visit } from 'unist-util-visit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips a leading match from the first text node in the given children array.
|
||||||
|
* @param children The children array to process.
|
||||||
|
* @param regex The regex pattern to match and strip from the first text node.
|
||||||
|
* @returns The modified children array with the leading match stripped.
|
||||||
|
*/
|
||||||
|
const stripLeadingColon = (
|
||||||
|
line: ElementContent[],
|
||||||
|
): { isNewDefinition: boolean; content: ElementContent[] } => {
|
||||||
|
const [first, ...rest] = line
|
||||||
|
if (first?.type === 'text') {
|
||||||
|
const match = first.value.match(/^[ \t]*:[ \t]*/)
|
||||||
|
if (match) {
|
||||||
|
const remainder = first.value.slice(match[0].length)
|
||||||
|
return {
|
||||||
|
isNewDefinition: true,
|
||||||
|
content: remainder ? [{ type: 'text', value: remainder } as Text, ...rest] : rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { isNewDefinition: false, content: line }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits an array of ElementContent nodes into lines based on text nodes containing newlines and <br> elements.
|
||||||
|
* @param nodes The array of ElementContent nodes to split into lines.
|
||||||
|
* @returns An array of lines, where each line is an array of ElementContent nodes.
|
||||||
|
*/
|
||||||
|
const splitIntoLines = (nodes: ElementContent[]): ElementContent[][] => {
|
||||||
|
const lines: ElementContent[][] = [[]]
|
||||||
|
|
||||||
|
for (const child of nodes) {
|
||||||
|
if (child.type === 'text') {
|
||||||
|
const segments = child.value.split('\n')
|
||||||
|
segments.forEach((segment, i) => {
|
||||||
|
if (i > 0) lines.push([])
|
||||||
|
if (segment.length > 0) {
|
||||||
|
lines[lines.length - 1].push({ type: 'text', value: segment } as Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (child.type === 'element' && child.tagName === 'br') {
|
||||||
|
lines.push([])
|
||||||
|
} else {
|
||||||
|
lines[lines.length - 1].push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A rehype plugin that transforms paragraphs containing a leading "Term:" into
|
* A rehype plugin that transforms paragraphs containing a leading "Term:" into
|
||||||
* definition lists. For example, a paragraph like "Term: Definition" will be
|
* definition lists. For example, a paragraph like "Term: Definition" will be
|
||||||
|
|
@ -33,7 +83,7 @@ export const rehypeDefinition: Plugin<[], Root> = () => (tree: Root) => {
|
||||||
|
|
||||||
const splitText = children[splitIndex] as Text
|
const splitText = children[splitIndex] as Text
|
||||||
const beforeColon = splitText.value.slice(0, colonIndex)
|
const beforeColon = splitText.value.slice(0, colonIndex)
|
||||||
const afterColon = splitText.value.slice(colonIndex + 1).replace(/^\s+/, '')
|
const afterColon = splitText.value.slice(colonIndex + 1).replace(/^[ \t]+/, '')
|
||||||
|
|
||||||
const termChildren: ElementContent[] = [
|
const termChildren: ElementContent[] = [
|
||||||
...children.slice(0, splitIndex),
|
...children.slice(0, splitIndex),
|
||||||
|
|
@ -47,6 +97,26 @@ export const rehypeDefinition: Plugin<[], Root> = () => (tree: Root) => {
|
||||||
|
|
||||||
if (termChildren.length === 0 || definitionChildren.length === 0) return
|
if (termChildren.length === 0 || definitionChildren.length === 0) return
|
||||||
|
|
||||||
|
const lines = splitIntoLines(definitionChildren)
|
||||||
|
const definitions: ElementContent[][] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const { isNewDefinition, content } = stripLeadingColon(line)
|
||||||
|
|
||||||
|
if (isNewDefinition || definitions.length === 0) {
|
||||||
|
definitions.push(content)
|
||||||
|
} else {
|
||||||
|
const current = definitions[definitions.length - 1]
|
||||||
|
if (current.length > 0 && content.length > 0) {
|
||||||
|
current.push({ type: 'text', value: ' ' } as Text)
|
||||||
|
}
|
||||||
|
current.push(...content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonEmptyDefinitions = definitions.filter((def) => def.length > 0)
|
||||||
|
if (nonEmptyDefinitions.length === 0) return
|
||||||
|
|
||||||
const dlNode: Element = {
|
const dlNode: Element = {
|
||||||
type: 'element',
|
type: 'element',
|
||||||
tagName: 'dl',
|
tagName: 'dl',
|
||||||
|
|
@ -58,12 +128,14 @@ export const rehypeDefinition: Plugin<[], Root> = () => (tree: Root) => {
|
||||||
properties: {},
|
properties: {},
|
||||||
children: termChildren,
|
children: termChildren,
|
||||||
},
|
},
|
||||||
{
|
...nonEmptyDefinitions.map(
|
||||||
type: 'element',
|
(defChildren): Element => ({
|
||||||
tagName: 'dd',
|
type: 'element',
|
||||||
properties: {},
|
tagName: 'dd',
|
||||||
children: definitionChildren,
|
properties: {},
|
||||||
},
|
children: defChildren,
|
||||||
|
}),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.2",
|
"remark-rehype": "^11.1.2",
|
||||||
|
"simple-icons": "^16.28.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"unist-util-visit": "^5.0.0"
|
"unist-util-visit": "^5.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export type NoteType = 'warning' | 'tip' | 'info'
|
export type NoteType = 'warning' | 'tip' | 'info' | 'quote'
|
||||||
15
types/Post.type.ts
Normal file
15
types/Post.type.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
export interface PostMeta {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
date: string
|
||||||
|
updated?: string
|
||||||
|
category: string
|
||||||
|
tags: string[]
|
||||||
|
math: boolean
|
||||||
|
mermaid: boolean
|
||||||
|
readingTimeText: string
|
||||||
|
coverImage?: string
|
||||||
|
coverImageAlt?: string
|
||||||
|
}
|
||||||
|
|
@ -10,3 +10,18 @@ export function slugify(text: string): string {
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/[^\w-]/g, '')
|
.replace(/[^\w-]/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a slug in an array of strings, ignoring case.
|
||||||
|
* @param slug - The slug to search for.
|
||||||
|
* @param array - The array of strings to search within.
|
||||||
|
* @returns The matching string from the array if found, otherwise null.
|
||||||
|
*/
|
||||||
|
export function findSlugInArray(slug: string, array: string[]): string | null {
|
||||||
|
for (const item of array) {
|
||||||
|
if (slug === slugify(item)) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
8
utils/time.ts
Normal file
8
utils/time.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* Utility function to format a date into a human-readable string.
|
||||||
|
*/
|
||||||
|
export const dateFormatter = new Intl.DateTimeFormat('en', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue