Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 2 additions & 26 deletions app/BlogListing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { formatDate } from "@/lib/utils";
import {
Briefcase,
CircleHelp,
Expand Down Expand Up @@ -55,27 +56,6 @@ interface BlogListingProps {
initialQuery?: string;
}

const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const;

function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
}

/**
* Client-side blog listing - hero, search, type/category filters, and a
* sticky featured rail. Filter state is derived from props (URL) + local state.
Expand All @@ -95,10 +75,6 @@ export default function BlogListing({
PostCategory | undefined
>(initialCategory);

useEffect(() => {
setQuery(initialQuery ?? "");
}, [initialQuery]);

const types = useMemo(
() => Array.from(new Set(posts.map((p) => p.type))),
[posts],
Expand Down
41 changes: 13 additions & 28 deletions app/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
TYPE_LABELS,
CATEGORY_LABELS,
} from "@/lib/content";
import { formatDate } from "@/lib/utils";
import { APP_URL, APP_CONFIG, SOURCE_EDIT_BASE } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { SECTION_CONTAINER_CLASS } from "@/components/ui/Container";
Expand Down Expand Up @@ -91,28 +92,6 @@ export async function generateMetadata({
};
}

const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const;

/** Locale-independent so SSR and client HTML match. */
function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
}

/** Full article page - cover, prose body, tags, share, and related posts. */
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
Expand Down Expand Up @@ -272,12 +251,18 @@ export default async function BlogPostPage({ params }: PageProps) {
</div>
)}
<div className="flex flex-col">
<Link
href={post.authorUrl!}
className="text-[14px] font-semibold text-neutral-900 transition-colors hover:text-neutral-700 hover:underline dark:text-neutral-200 dark:hover:text-neutral-100"
>
{post.author}
</Link>
{post.authorUrl ? (
<Link
href={post.authorUrl}
className="text-[14px] font-semibold text-neutral-900 transition-colors hover:text-neutral-700 hover:underline dark:text-neutral-200 dark:hover:text-neutral-100"
>
{post.author}
</Link>
) : (
<span className="text-[14px] font-semibold text-neutral-900 dark:text-neutral-200">
{post.author}
</span>
)}
<span className="flex items-center gap-2 text-[12.5px] text-neutral-500">
<time dateTime={post.publishedAt}>
{formatDate(post.publishedAt)}
Expand Down
18 changes: 11 additions & 7 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import type { Metadata } from "next";
import {
getPostMetaList,
type PostType,
type PostCategory,
} from "@/lib/content";
import { getPostMetaList, isPostType, isPostCategory } from "@/lib/content";
import type { PostType, PostCategory } from "@/lib/content";
import { APP_URL } from "@/lib/constants";
import BlogListing from "./BlogListing";

Expand Down Expand Up @@ -68,8 +65,14 @@ interface BlogPageProps {
/** Blog listing page - feeds all post metadata to the client-side listing. */
export default async function BlogPage({ searchParams }: BlogPageProps) {
const params = await searchParams;
const typeFilter = params.type as PostType | undefined;
const categoryFilter = params.category as PostCategory | undefined;
const rawType = params.type;
const rawCategory = params.category;
const typeFilter: PostType | undefined =
typeof rawType === "string" && isPostType(rawType) ? rawType : undefined;
const categoryFilter: PostCategory | undefined =
typeof rawCategory === "string" && isPostCategory(rawCategory)
? rawCategory
: undefined;
const query = params.q;

const all = getPostMetaList();
Expand Down Expand Up @@ -131,6 +134,7 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
/>
<BlogListing
key={`${typeFilter ?? ""}-${categoryFilter ?? ""}-${query ?? ""}`}
posts={all}
initialType={typeFilter}
initialCategory={categoryFilter}
Expand Down
23 changes: 1 addition & 22 deletions components/blog/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,12 @@ import Link from "next/link";
import { Clock, ArrowUpRight } from "lucide-react";
import type { PostMeta } from "@/lib/content/types";
import { CATEGORY_LABELS, TYPE_LABELS } from "@/lib/content/types";
import { formatDate } from "@/lib/utils";

interface PostCardProps {
post: PostMeta;
}

const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const;

/** Locale-independent so SSR and client HTML match. */
function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
}

/** Listing card linking to a post - type/category badges, title, meta, and thumbnail. */
export default function PostCard({ post }: PostCardProps) {
const image = post.image;
Expand Down
35 changes: 25 additions & 10 deletions components/theme/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
"use client";

import { useEffect, useState } from "react";
import { useSyncExternalStore } from "react";
import { Moon, Sun } from "lucide-react";

const STORAGE_KEY = "ossium-theme";

function subscribe(callback: () => void) {
window.addEventListener("storage", callback);
return () => window.removeEventListener("storage", callback);
}

function getSnapshot() {
return localStorage.getItem(STORAGE_KEY);
}

function getServerSnapshot() {
return null;
}

/** Light/dark toggle (placed inside the navbar); persists under `ossium-theme`. */
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(true);

useEffect(() => {
if (typeof localStorage !== "undefined") {
setIsDark(localStorage.getItem("ossium-theme") !== "light");
}
}, []);
const stored = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
);
const isDark = stored !== "light";

const toggle = () => {
const next = !isDark;
setIsDark(next);
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("ossium-theme", next ? "dark" : "light");
localStorage.setItem(STORAGE_KEY, next ? "dark" : "light");
// Trigger re-render for other listeners
window.dispatchEvent(new StorageEvent("storage", { key: STORAGE_KEY }));
};

return (
Expand Down
1 change: 0 additions & 1 deletion components/ui/SearchOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export default function SearchOverlay() {

useEffect(() => {
if (!open) return;
setSelected(0);
const raf = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open]);
Expand Down
22 changes: 3 additions & 19 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,26 +1,10 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import prettier from "eslint-config-prettier";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
baseDirectory: __dirname,
});

const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...nextCoreWebVitals,
{
ignores: [
".next/**",
"node_modules/**",
"next-env.d.ts",
"content/**",
"public/**",
"pnpm-lock.yaml",
],
ignores: ["content/**", "public/**", "pnpm-lock.yaml"],
},
prettier,
];
Expand Down
9 changes: 7 additions & 2 deletions lib/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { Post, PostCategory, PostMeta, PostType } from "./types";

const CONTENT_DIR = path.join(process.cwd(), "content", "posts");

const CATEGORY_MATCH_WEIGHT = 3;
const TYPE_MATCH_WEIGHT = 1;

function listMarkdownFiles(): string[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
Expand Down Expand Up @@ -77,8 +80,8 @@ export function getRelatedPosts(slug: string, limit = 3): PostMeta[] {
.filter((p) => p.slug !== slug)
.map((p) => {
let score = 0;
if (p.category === current.category) score += 3;
if (p.type === current.type) score += 1;
if (p.category === current.category) score += CATEGORY_MATCH_WEIGHT;
if (p.type === current.type) score += TYPE_MATCH_WEIGHT;
const shared = p.tags.filter((t) => current.tags.includes(t)).length;
score += shared;
return { post: p, score };
Expand All @@ -95,4 +98,6 @@ export {
POST_CATEGORIES,
TYPE_LABELS,
CATEGORY_LABELS,
isPostType,
isPostCategory,
} from "./types";
10 changes: 10 additions & 0 deletions lib/content/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ export const POST_CATEGORIES = [

export type PostCategory = (typeof POST_CATEGORIES)[number];

export function isPostType(v: unknown): v is PostType {
return typeof v === "string" && (POST_TYPES as readonly string[]).includes(v);
}

export function isPostCategory(v: unknown): v is PostCategory {
return (
typeof v === "string" && (POST_CATEGORIES as readonly string[]).includes(v)
);
}

export interface PostFrontmatter {
title: string;
description: string;
Expand Down
22 changes: 22 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,25 @@ import { clsx, type ClassValue } from "clsx";
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}

const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const;

/** Locale-independent date format so SSR and client HTML match. */
export function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
}
3 changes: 2 additions & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
4 changes: 0 additions & 4 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,6 @@ const nextConfig: NextConfig = {
},
];
},

eslint: {
ignoreDuringBuilds: false,
},
};

export default nextConfig;
13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,23 +60,22 @@
"@vercel/analytics": "^2.0.1",
"clsx": "^2.1.1",
"lucide-react": "^0.523.0",
"next": "15.5.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "16.3.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "^16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"eslint": "^9",
"eslint-config-next": "15.5.9",
"eslint-config-next": "16.3.1",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^25",
"prettier": "^3.9.6",
Expand Down
Loading
Loading