Just a buch of stuff

This commit is contained in:
HangerThem 2026-08-02 22:39:12 +02:00
parent 231bdfc079
commit 722ace7c74
31 changed files with 642 additions and 194 deletions

View 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>
)
}

View file

@ -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>
)
}

View file

@ -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>
) )
} }

View file

@ -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, "&apos;") .replace(/'/g, "&apos;")
} }
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 },
}) })
} }

View file

@ -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 {

View file

@ -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
View 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",
}

View file

@ -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>
</>
)
}

View file

@ -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>&middot;</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>&middot;</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>
) )
} }

View file

@ -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=="],

View file

@ -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) {

View file

@ -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>
))} ))}

View file

@ -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" />

View file

@ -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 && (

View 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>&middot;</span>
<span>{readingTimeText}</span>
</div>
</div>
</article>
)
}

View 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>
);
}

View 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>
))
}

View file

@ -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>

View file

@ -1,7 +0,0 @@
const meta = {
title: "Ephemeris",
description: "A blog about me and my interests",
creator: "Frank Borisjuk",
}
export default meta

View file

@ -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

View file

@ -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.

View file

@ -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,

View file

@ -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
View 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)
}

View file

@ -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))
}

View file

@ -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,
}),
),
], ],
} }

View file

@ -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"

View file

@ -1 +1 @@
export type NoteType = 'warning' | 'tip' | 'info' export type NoteType = 'warning' | 'tip' | 'info' | 'quote'

15
types/Post.type.ts Normal file
View 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
}

View file

@ -9,4 +9,19 @@ export function slugify(text: string): string {
.trim() .trim()
.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
View 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',
});