Migrate blog from hardcoded TSX posts to MDX with auto-population

- Add gray-matter + next-mdx-remote for MDX rendering
- Create content/blog/ with .mdx files and frontmatter
- Add dynamic [slug] route and blog utility (src/lib/blog.ts)
- Blog index auto-populates from MDX files, sorted newest first
- Upgrade React to 19 for next-mdx-remote v6 compatibility
- Convert existing posts: website-relaunch, smirk-wallet, bauhaus-echo, crypto-checkout
This commit is contained in:
jwinterm
2026-03-10 10:46:41 -04:00
parent 3001d4bbe1
commit 2b9b1bf429
12 changed files with 1505 additions and 375 deletions
@@ -0,0 +1,34 @@
---
title: "Bauhaus Echo is Live"
date: "March 4, 2026"
excerpt: "A visual memory puzzle inspired by Bauhaus design — now on every platform"
---
Bauhaus Echo is now available on the [App Store](https://apps.apple.com/us/app/bauhaus-echo/id6759010657), [Google Play](https://play.google.com/store/apps/details?id=com.suchsoftware.bauhausecho), [itch.io](https://suchsoftware.itch.io/bauhaus-echo), and [the web](https://bauhaus.such.software). It is a visual memory game where you memorize tile arrangements, watch them shuffle, and reconstruct the original pattern.
## The Concept
The game shows you a grid of colorful geometric tiles inspired by Bauhaus design. You get a few seconds to memorize their positions. Then the tiles spin, shrink, and scatter to the bottom of the screen. Your job is to drag them back to where they belong.
## Four Game Modes
- **Classic** — The core experience. Memorize and reconstruct.
- **Speed** — Tiles appear one at a time. Place each before the next drops.
- **Endless** — Speed mode that loops forever. See how long you last.
- **Memory** — Traditional card-matching pairs.
Each mode supports 2×2 through 5×5 grids and three difficulty levels. Global leaderboards let you compete against other players across all platforms.
## 500 Bauhaus Tiles
The tile collection features 500 hand-curated geometric designs drawn from the Bauhaus aesthetic — bold shapes, primary colors, clean lines. Each tile has beveled 3D edges and the game board is styled like a wood-and-felt craft table.
## Tech Stack
Built with [libGDX](https://libgdx.com/), a cross-platform Java game framework. One codebase runs on Android, iOS (via RoboVM), desktop (LWJGL3), and web (via GWT). The tile assets are packed into a TextureAtlas for efficient GPU rendering.
## Get It
- **Mobile (free with ads)** — [App Store](https://apps.apple.com/us/app/bauhaus-echo/id6759010657) | [Google Play](https://play.google.com/store/apps/details?id=com.suchsoftware.bauhausecho)
- **Desktop ($2, no ads)** — [itch.io](https://suchsoftware.itch.io/bauhaus-echo)
- **Web (free)** — [bauhaus.such.software](https://bauhaus.such.software)
@@ -0,0 +1,52 @@
---
title: "Crypto Checkout Infrastructure for Multi-Tenant Shops"
date: "March 10, 2026"
excerpt: "Non-custodial XMR and WOW checkout, plus Grin via BTCPayServer"
---
Our multi-tenant webshops run on [MedusaJS](https://medusajs.com) with a BTCPay plugin for Bitcoin and Lightning. We wanted to add privacy coins: Monero, Wownero, and Grin.
BTCPayServer does have a built-in Monero plugin, but it is custodial (a single server-side wallet receives all payments) and only supports one wallet per instance. That does not work for multi-tenant shops where each merchant needs payments sent directly to their own wallet. So we forked [xmrcheckout](https://github.com/xmrcheckout/xmrcheckout), an existing non-custodial checkout, and built from there.
## XMR Checkout
xmrcheckout is self-hosted and non-custodial. Each merchant provides their primary address and secret view key. The system uses view-only access to derive unique subaddresses per invoice and detect payments. It never holds spend keys or moves funds.
Stack: Python/FastAPI backend, Next.js frontend, wallet-rpc for chain queries, Postgres for state, all in Docker Compose. XMR/USD rates from the Kraken public API.
Hosted at [xmrcheckout.such.software](https://xmrcheckout.such.software).
## WOW Checkout
[wowcheckout](https://github.com/Such-Software/wowcheckout) is a fork of xmrcheckout for [Wownero](https://wownero.org). File and folder structure is identical to upstream so merging fixes stays easy. The content-only differences:
- **Network bytes** — Wownero address prefixes (53, 63, 54), patched at runtime
- **Pricing** — WOW-BTC from [Nonlogs](https://api.nonlogs.io/api/markets) multiplied by BTC/USD from Kraken (no direct WOW/USD pair exists)
- **Daemon** — Wownero node, [binaries from Codeberg](https://codeberg.org/wownero/wownero/releases)
Hosted at [wowcheckout.such.software](https://wowcheckout.such.software).
## Grin via BTCPayServer
Grin's transaction protocol is interactive — sender and receiver exchange slates to build a transaction. The checkout system must hold a wallet with spend authority, making it semi-custodial by nature.
We contributed a [Grin plugin for BTCPayServer](https://github.com/ArcticMining/btcpayserver-plugin-grin) that talks to grin-wallet's Owner API to issue invoices, finalize transactions, and track confirmations. It slots in alongside Bitcoin and Lightning with no extra Medusa integration work.
## Trust Model
- **XMR and WOW** — Fully non-custodial. View-only access. Funds go directly to the merchant wallet.
- **Grin** — Semi-custodial. The server holds a Grin wallet with spend authority because the protocol requires it. Merchants trust the operator to forward funds. This is inherent to Grin, not a design choice.
## Medusa Integration
MedusaJS already has a BTCPay payment plugin that handles invoices and webhook callbacks. Because xmrcheckout and wowcheckout expose a BTCPay-compatible API, the same plugin works for all currencies with just a base URL change. One integration pattern for BTC, Lightning, XMR, WOW, and GRIN.
## Source
- [xmrcheckout](https://github.com/xmrcheckout/xmrcheckout) (upstream)
- [wowcheckout](https://github.com/Such-Software/wowcheckout) (our Wownero fork)
- [btcpayserver-plugin-grin](https://github.com/ArcticMining/btcpayserver-plugin-grin)
## Work With Us
If you want a managed webshop that accepts cryptocurrency — BTC, Lightning, XMR, WOW, GRIN, or a combination — we can set that up for you. [Get in touch](/#contact).
@@ -0,0 +1,43 @@
---
title: "Smirk Wallet v0.1.6"
date: "February 6, 2026"
excerpt: "Non-custodial social tipping for Telegram and Discord"
---
Smirk Wallet is a non-custodial browser extension that lets you tip people cryptocurrency by their Telegram or Discord username. We have shipped six releases and the extension is available now on GitHub.
## What It Does
One 12-word seed phrase gives you addresses for five currencies: Bitcoin, Litecoin, Monero, Wownero, and Grin. Link your Telegram or Discord account on smirk.cash, and anyone running Smirk can send you crypto by username.
Tips are encrypted with the recipient's public key. Only they can claim. If a tip goes unclaimed, the sender can claw it back. No funds get lost.
## Non-Custodial by Design
Your private spend keys are generated locally and never leave your browser. They are encrypted with your password. We store only public keys and view keys (for Monero/Wownero balance scanning). We cannot move your funds.
## What We Built
- Browser extension (TypeScript, Preact, Vite)
- Backend API (Rust, Axum, PostgreSQL)
- Light wallet servers for XMR and WOW
- Grin wallet with client-side WASM
- Telegram and Discord bots for tip notifications
- User dashboard at smirk.cash
## Chrome Web Store Status
Our initial submission was rejected for requesting an unused permission (activeTab). We had included it for a planned feature that was not yet implemented. The fix is already in v0.1.6. We have removed the permission and resubmitted.
In the meantime, you can install manually from [GitHub Releases](https://github.com/Such-Software/smirk-extension/releases). Firefox support is also available.
## Open Source
The extension and WASM libraries are open source on GitHub:
- [smirk-extension](https://github.com/Such-Software/smirk-extension)
- [monero-oxide](https://github.com/Such-Software/monero-oxide) (Monero WASM)
## Get Started
Visit [smirk.cash](https://smirk.cash) to learn more, or join the community on [Telegram](https://t.me/smirkwallet) or [Discord](https://discord.gg/7EnsaWTm6C).
@@ -0,0 +1,34 @@
---
title: "Website Relaunch"
date: "February 6, 2026"
excerpt: "New design, new infrastructure, same focus on quality"
---
We rebuilt such.software. New design, new infrastructure, same focus on performance and accessibility.
## Stack
- **Framework:** Next.js 15 (App Router)
- **Language:** TypeScript
- **Styling:** Tailwind CSS with CSS variables for theming
- **Animation:** Framer Motion
- **Monorepo:** Turborepo + pnpm workspaces
- **Testing:** Playwright with axe-core for accessibility
- **Bot Protection:** Cloudflare Turnstile
- **Email:** Resend
## Procedural Graphics
The site features two custom procedural animations: NebulaField and DataGrid. Both are SVG-based, theme-aware, and respect prefers-reduced-motion. NebulaField renders animated data streams flowing toward a central icon using quadratic Bezier curves on the homepage. DataGrid draws a pulsing grid with traveling data packets on the Services page.
## Accessibility
We run 42 automated accessibility tests across Chromium, Firefox, and WebKit on every build. Tests cover WCAG 2.2 AA compliance, heading hierarchy, color contrast, keyboard navigation, and form accessibility. Lighthouse scores are 99-100 across all pages.
## Performance
The NebulaField component is lazy loaded with next/dynamic and requestIdleCallback. Canvas rendering is throttled. Fonts are loaded via next/font for zero layout shift. Images use next/image for automatic optimization.
## Structure
The site now has Products, Services, Blog, and About sections. Products showcases our standalone offerings: Webshops, Custom Websites, Smirk Wallet, and Bauhaus Echo. Services covers our partnership model and consulting work. Mobile users get a bottom navigation bar.
+6 -4
View File
@@ -16,11 +16,13 @@
"@repo/config": "workspace:*",
"@repo/ui": "workspace:*",
"framer-motion": "^11.0.0",
"gray-matter": "^4.0.3",
"lucide-react": "^0.350.0",
"next": "^15.1.3",
"next-mdx-remote": "^6.0.0",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.5.0",
"resend": "^4.0.0",
"zod": "^3.22.4"
@@ -30,8 +32,8 @@
"@playwright/test": "^1.57.0",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "^15.1.3",
+56
View File
@@ -0,0 +1,56 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { getAllSlugs, getPost } from "@/lib/blog";
type Props = { params: Promise<{ slug: string }> };
export async function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
return {
title: `${post.title} | Such Software Blog`,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<article id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/blog" className="text-sm text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 mb-8 inline-flex items-center gap-2 transition-colors">
← Back to Blog
</Link>
<div className="glass-card mt-6">
<header className="mb-8 pb-6 border-b border-border">
<time className="text-sm text-muted-foreground">{post.date}</time>
<h1 className="text-3xl md:text-4xl font-bold mt-2">{post.title}</h1>
{post.excerpt && (
<p className="text-muted-foreground mt-3">{post.excerpt}</p>
)}
</header>
<div className="prose dark:prose-invert max-w-none">
<MDXRemote source={post.content} />
</div>
</div>
</article>
<MobileNav />
</main>
);
}
@@ -1,97 +0,0 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Bauhaus Echo is Live | Such Software Blog",
description: "Our first mobile game is available on App Store, Google Play, itch.io, and the web. A visual memory puzzle inspired by Bauhaus design.",
alternates: { canonical: "/blog/bauhaus-echo-launch" },
};
export default function BauhausEchoLaunchPost() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<article id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/blog" className="text-sm text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 mb-8 inline-flex items-center gap-2 transition-colors">
← Back to Blog
</Link>
<div className="glass-card mt-6">
<header className="mb-8 pb-6 border-b border-border">
<time className="text-sm text-muted-foreground">March 4, 2026</time>
<h1 className="text-3xl md:text-4xl font-bold mt-2">Bauhaus Echo is Live</h1>
<p className="text-muted-foreground mt-3">A visual memory puzzle inspired by Bauhaus design — now on every platform</p>
</header>
<div className="prose dark:prose-invert max-w-none">
<p>
Bauhaus Echo is now available on the{" "}
<a href="https://apps.apple.com/us/app/bauhaus-echo/id6759010657" target="_blank" rel="noopener noreferrer">App Store</a>,{" "}
<a href="https://play.google.com/store/apps/details?id=com.suchsoftware.bauhausecho" target="_blank" rel="noopener noreferrer">Google Play</a>,{" "}
<a href="https://suchsoftware.itch.io/bauhaus-echo" target="_blank" rel="noopener noreferrer">itch.io</a>, and{" "}
<a href="https://bauhaus.such.software" target="_blank" rel="noopener noreferrer">the web</a>.
It is a visual memory game where you memorize tile arrangements, watch them shuffle, and
reconstruct the original pattern.
</p>
<h2>The Concept</h2>
<p>
The game shows you a grid of colorful geometric tiles inspired by Bauhaus design. You get a
few seconds to memorize their positions. Then the tiles spin, shrink, and scatter to the
bottom of the screen. Your job is to drag them back to where they belong.
</p>
<h2>Four Game Modes</h2>
<ul>
<li><strong>Classic</strong> — The core experience. Memorize and reconstruct.</li>
<li><strong>Speed</strong> — Tiles appear one at a time. Place each before the next drops.</li>
<li><strong>Endless</strong> — Speed mode that loops forever. See how long you last.</li>
<li><strong>Memory</strong> — Traditional card-matching pairs.</li>
</ul>
<p>
Each mode supports 2×2 through 5×5 grids and three difficulty levels. Global leaderboards
let you compete against other players across all platforms.
</p>
<h2>500 Bauhaus Tiles</h2>
<p>
The tile collection features 500 hand-curated geometric designs drawn from the Bauhaus
aesthetic — bold shapes, primary colors, clean lines. Each tile has beveled 3D edges and
the game board is styled like a wood-and-felt craft table.
</p>
<h2>Tech Stack</h2>
<p>
Built with{" "}
<a href="https://libgdx.com/" target="_blank" rel="noopener noreferrer">libGDX</a>,
a cross-platform Java game framework. One codebase runs on Android, iOS (via RoboVM),
desktop (LWJGL3), and web (via GWT). The tile assets are packed into a TextureAtlas
for efficient GPU rendering.
</p>
<h2>Get It</h2>
<ul>
<li>
<strong>Mobile (free with ads)</strong> —{" "}
<a href="https://apps.apple.com/us/app/bauhaus-echo/id6759010657" target="_blank" rel="noopener noreferrer">App Store</a>{" | "}
<a href="https://play.google.com/store/apps/details?id=com.suchsoftware.bauhausecho" target="_blank" rel="noopener noreferrer">Google Play</a>
</li>
<li>
<strong>Desktop ($2, no ads)</strong> —{" "}
<a href="https://suchsoftware.itch.io/bauhaus-echo" target="_blank" rel="noopener noreferrer">itch.io</a>
</li>
<li>
<strong>Web (free)</strong> —{" "}
<a href="https://bauhaus.such.software" target="_blank" rel="noopener noreferrer">bauhaus.such.software</a>
</li>
</ul>
</div>
</div>
</article>
<MobileNav />
</main>
);
}
+5 -29
View File
@@ -3,6 +3,7 @@ import { MobileNav } from "@/components/layout/mobile-nav";
import { Card } from "@repo/ui/components/card";
import Link from "next/link";
import type { Metadata } from "next";
import { getAllPosts } from "@/lib/blog";
export const metadata: Metadata = {
title: "Blog | Such Software",
@@ -10,34 +11,9 @@ export const metadata: Metadata = {
alternates: { canonical: "/blog" },
};
// For now, hardcoded posts. Can migrate to MDX/CMS later.
const posts = [
{
slug: "smirk-wallet-release",
title: "Smirk Wallet v0.1.6",
date: "2026-02-06",
excerpt: "Non-custodial browser extension for social tipping with BTC, LTC, XMR, WOW, and GRIN. Now on GitHub, Chrome Web Store pending.",
draft: false,
},
{
slug: "website-relaunch-2026",
title: "Website Relaunch",
date: "2026-02-06",
excerpt: "We rebuilt such.software from the ground up. Here is what we used and why.",
draft: false,
},
{
slug: "bauhaus-echo-launch",
title: "Bauhaus Echo is Live",
date: "2026-03-04",
excerpt: "Our first mobile game is available on App Store, Google Play, itch.io, and the web. A visual memory puzzle inspired by Bauhaus design.",
draft: false,
},
];
const publishedPosts = posts.filter((p) => !p.draft);
export default function BlogPage() {
const posts = getAllPosts();
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
@@ -63,11 +39,11 @@ export default function BlogPage() {
/>
</div>
{publishedPosts.length === 0 ? (
{posts.length === 0 ? (
<p className="text-muted-foreground">No posts yet. Check back soon.</p>
) : (
<div className="space-y-6">
{publishedPosts.map((post) => (
{posts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
<Card className="glass-card hover:border-primary/30 transition-colors cursor-pointer">
<time className="text-sm text-muted-foreground">{post.date}</time>
@@ -1,117 +0,0 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Smirk Wallet v0.1.6 | Such Software Blog",
description: "Announcing Smirk Wallet, a non-custodial browser extension for social tipping with BTC, LTC, XMR, WOW, and GRIN.",
alternates: { canonical: "/blog/smirk-wallet-release" },
};
export default function SmirkWalletReleasePost() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<article id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/blog" className="text-sm text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 mb-8 inline-flex items-center gap-2 transition-colors">
← Back to Blog
</Link>
<div className="glass-card mt-6">
<header className="mb-8 pb-6 border-b border-border">
<time className="text-sm text-muted-foreground">February 6, 2026</time>
<h1 className="text-3xl md:text-4xl font-bold mt-2">Smirk Wallet v0.1.6</h1>
<p className="text-muted-foreground mt-3">Non-custodial social tipping for Telegram and Discord</p>
</header>
<div className="prose dark:prose-invert max-w-none">
<p>
Smirk Wallet is a non-custodial browser extension that lets you tip people cryptocurrency
by their Telegram or Discord username. We have shipped six releases and the extension is
available now on GitHub.
</p>
<h2>What It Does</h2>
<p>
One 12-word seed phrase gives you addresses for five currencies: Bitcoin, Litecoin, Monero,
Wownero, and Grin. Link your Telegram or Discord account on smirk.cash, and anyone running
Smirk can send you crypto by username.
</p>
<p>
Tips are encrypted with the recipient's public key. Only they can claim. If a tip goes
unclaimed, the sender can claw it back. No funds get lost.
</p>
<h2>Non-Custodial by Design</h2>
<p>
Your private spend keys are generated locally and never leave your browser. They are
encrypted with your password. We store only public keys and view keys (for Monero/Wownero
balance scanning). We cannot move your funds.
</p>
<h2>What We Built</h2>
<ul>
<li>Browser extension (TypeScript, Preact, Vite)</li>
<li>Backend API (Rust, Axum, PostgreSQL)</li>
<li>Light wallet servers for XMR and WOW</li>
<li>Grin wallet with client-side WASM</li>
<li>Telegram and Discord bots for tip notifications</li>
<li>User dashboard at smirk.cash</li>
</ul>
<h2>Chrome Web Store Status</h2>
<p>
Our initial submission was rejected for requesting an unused permission (activeTab).
We had included it for a planned feature that was not yet implemented. The fix is already
in v0.1.6. We have removed the permission and resubmitted.
</p>
<p>
In the meantime, you can install manually from{" "}
<a href="https://github.com/Such-Software/smirk-extension/releases" target="_blank" rel="noopener noreferrer">
GitHub Releases
</a>
. Firefox support is also available.
</p>
<h2>Open Source</h2>
<p>
The extension and WASM libraries are open source on GitHub:
</p>
<ul>
<li>
<a href="https://github.com/Such-Software/smirk-extension" target="_blank" rel="noopener noreferrer">
smirk-extension
</a>
</li>
<li>
<a href="https://github.com/Such-Software/monero-oxide" target="_blank" rel="noopener noreferrer">
monero-oxide
</a>{" "}
(Monero WASM)
</li>
</ul>
<h2>Get Started</h2>
<p>
Visit{" "}
<a href="https://smirk.cash" target="_blank" rel="noopener noreferrer">
smirk.cash
</a>{" "}
to learn more, or join the community on{" "}
<a href="https://t.me/smirkwallet" target="_blank" rel="noopener noreferrer">
Telegram
</a>{" "}
or{" "}
<a href="https://discord.gg/7EnsaWTm6C" target="_blank" rel="noopener noreferrer">
Discord
</a>
.
</p>
</div>
</div>
</article>
<MobileNav />
</main>
);
}
@@ -1,79 +0,0 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Website Relaunch | Such Software Blog",
description: "We rebuilt such.software from the ground up. Here is what we used and why.",
alternates: { canonical: "/blog/website-relaunch-2026" },
};
export default function WebsiteRelaunchPost() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<article id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/blog" className="text-sm text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 mb-8 inline-flex items-center gap-2 transition-colors">
← Back to Blog
</Link>
<div className="glass-card mt-6">
<header className="mb-8 pb-6 border-b border-border">
<time className="text-sm text-muted-foreground">February 6, 2026</time>
<h1 className="text-3xl md:text-4xl font-bold mt-2">Website Relaunch</h1>
<p className="text-muted-foreground mt-3">New design, new infrastructure, same focus on quality</p>
</header>
<div className="prose dark:prose-invert max-w-none">
<p>
We rebuilt such.software. New design, new infrastructure, same focus on performance and accessibility.
</p>
<h2>Stack</h2>
<ul>
<li><strong>Framework:</strong> Next.js 15 (App Router)</li>
<li><strong>Language:</strong> TypeScript</li>
<li><strong>Styling:</strong> Tailwind CSS with CSS variables for theming</li>
<li><strong>Animation:</strong> Framer Motion</li>
<li><strong>Monorepo:</strong> Turborepo + pnpm workspaces</li>
<li><strong>Testing:</strong> Playwright with axe-core for accessibility</li>
<li><strong>Bot Protection:</strong> Cloudflare Turnstile</li>
<li><strong>Email:</strong> Resend</li>
</ul>
<h2>Procedural Graphics</h2>
<p>
The site features two custom procedural animations: NebulaField and DataGrid. Both are
SVG-based, theme-aware, and respect prefers-reduced-motion. NebulaField renders animated
data streams flowing toward a central icon using quadratic Bezier curves on the homepage.
DataGrid draws a pulsing grid with traveling data packets on the Services page.
</p>
<h2>Accessibility</h2>
<p>
We run 42 automated accessibility tests across Chromium, Firefox, and WebKit on every build.
Tests cover WCAG 2.2 AA compliance, heading hierarchy, color contrast, keyboard navigation,
and form accessibility. Lighthouse scores are 99-100 across all pages.
</p>
<h2>Performance</h2>
<p>
The NebulaField component is lazy loaded with next/dynamic and requestIdleCallback. Canvas
rendering is throttled. Fonts are loaded via next/font for zero layout shift. Images use
next/image for automatic optimization.
</p>
<h2>Structure</h2>
<p>
The site now has Products, Services, Blog, and About sections. Products showcases our
standalone offerings: Webshops, Custom Websites, Smirk Wallet, and Bauhaus Echo. Services
covers our partnership model and consulting work. Mobile users get a bottom navigation bar.
</p>
</div>
</div>
</article>
<MobileNav />
</main>
);
}
+64
View File
@@ -0,0 +1,64 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const CONTENT_DIR = path.join(process.cwd(), "content", "blog");
export type PostMeta = {
slug: string;
title: string;
date: string;
excerpt: string;
draft: boolean;
};
export type Post = PostMeta & {
content: string;
};
export function getAllPosts(): PostMeta[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) => f.endsWith(".mdx"))
.map((filename) => {
const slug = filename.replace(/\.mdx$/, "");
const raw = fs.readFileSync(path.join(CONTENT_DIR, filename), "utf-8");
const { data } = matter(raw);
return {
slug,
title: data.title ?? slug,
date: data.date ?? "",
excerpt: data.excerpt ?? "",
draft: data.draft === true,
};
})
.filter((p) => !p.draft)
.sort((a, b) => (a.date > b.date ? -1 : 1));
}
export function getPost(slug: string): Post | null {
const filePath = path.join(CONTENT_DIR, `${slug}.mdx`);
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
slug,
title: data.title ?? slug,
date: data.date ?? "",
excerpt: data.excerpt ?? "",
draft: data.draft === true,
content,
};
}
export function getAllSlugs(): string[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) => f.endsWith(".mdx"))
.map((f) => f.replace(/\.mdx$/, ""));
}
+1211 -49
View File
File diff suppressed because it is too large Load Diff