mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-19 05:48:33 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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$/, ""));
|
||||
}
|
||||
Reference in New Issue
Block a user