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
19 changes: 19 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,29 +180,29 @@ works; user preference persists; code blocks look correct in all themes.

---

## Phase 4: Content Discovery & Organization
## Phase 4: Content Discovery & Organization

Deepen exactly the content loop RxDev Man already has — write, organize,
discover — without adding a second product.

- [ ] **RSS feed** — Astro has `@astrojs/rss` integration. Generate `/rss.xml`
- [x] **RSS feed** — Astro has `@astrojs/rss` integration. Generate `/rss.xml`
from all published blog posts. Include full content or summary, categories,
tags, publication date. Auto-discoverable via `<link rel="alternate">` in
the `<head>`.
- [ ] **Series/Collections** — a frontmatter field `series` (string) that
- [x] **Series/Collections** — a frontmatter field `series` (string) that
groups related posts. A `/blog/series/[series]` page that lists posts in
order. The series name appears on the blog post card and at the top of the
post with navigation (previous/next in series). Natural groupings: "Rust
from Scratch", "HOSxP Development", "Git Mastery".
- [ ] **Reading time on blog cards** — already calculated in `src/utils/reading-time.ts`,
- [x] **Reading time on blog cards** — already calculated in `src/utils/reading-time.ts`,
but verify it's displayed on BlogPostCard. If not, add it.
- [ ] **"Recently Published" section on homepage** — the homepage already has
a hero. Add a section below showing the 3 most recent posts with
BlogPostCard, giving returning visitors an immediate entry point.
- [ ] **Category page improvements** — the `/blog/categories/[category]` page
- [x] **Category page improvements** — the `/blog/categories/[category]` page
exists but may need a better layout: category description, post count, sorted
by date.
- [ ] **Tag cloud / tag index** — a `/blog/tags` page that lists all tags with
- [x] **Tag cloud / tag index** — a `/blog/tags` page that lists all tags with
post counts, linking to `/blog/tags/[tag]`. Helps readers discover content
breadth.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dependencies": {
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^7.0.0",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/vercel": "^11.0.0",
"@iconify-json/fa6-brands": "^1.2.6",
Expand Down
53 changes: 53 additions & 0 deletions src/components/blog/BlogNav.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
type NavItem = { href: string; label: string; active?: boolean };

type Props = {
items: NavItem[];
label?: string;
};

const { items, label = 'Blog navigation' } = Astro.props;
---

<nav class="blog-nav" aria-label={label}>
{items.map(item => (
<a
href={item.href}
class:list={['blog-nav-link', { active: item.active }]}
>{item.label}</a>
))}
</nav>

<style>
.blog-nav {
display: flex;
justify-content: center;
gap: var(--space-1);
margin-bottom: var(--space-8);
padding: var(--space-1);
background: var(--surface-accent);
border-radius: var(--radius-full);
width: fit-content;
margin-left: auto;
margin-right: auto;
}

.blog-nav-link {
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: 600;
color: var(--text-muted);
text-decoration: none;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-full);
transition: color var(--duration-150) var(--ease),
background var(--duration-150) var(--ease);
}

.blog-nav-link:hover { color: var(--text-primary); }

.blog-nav-link.active {
color: var(--text-heading);
background: var(--surface-card);
}
</style>
2 changes: 1 addition & 1 deletion src/components/blog/ShareButtons.astro
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ const platforms = [
}
}

@media (prefers-color-scheme: dark) {
:global([data-theme='dark']) & {
.share-tooltip {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
Expand Down
11 changes: 10 additions & 1 deletion src/components/layout/Navbar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function isActive(href: string) {
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</button>
<span id="theme-live" class="sr-only" aria-live="polite"></span>
<button class="hamburger" id="hamburger" aria-label="Toggle navigation" aria-expanded="false" aria-controls="mobile-overlay">
<span class="bar"></span>
<span class="bar"></span>
Expand Down Expand Up @@ -141,6 +142,9 @@ function setTheme(theme: string) {
localStorage.setItem('theme', theme);
themeToggle?.setAttribute('aria-label', `Theme: ${theme}. Click to switch.`);
mobileThemeToggle?.setAttribute('aria-label', `Theme: ${theme}. Click to switch.`);
const liveRegion = document.getElementById('theme-live');
if (liveRegion)
liveRegion.textContent = `Theme changed to ${theme}`;
}

function cycleTheme() {
Expand Down Expand Up @@ -554,7 +558,7 @@ if (navbar instanceof HTMLElement && hamburger instanceof HTMLButtonElement && o
.mobile-link:hover .mobile-link-icon {
color: var(--brand-red);
border-color: var(--brand-red);
background: var(--color-white);
background: var(--surface-card);
}

.mobile-link.active .mobile-link-icon {
Expand Down Expand Up @@ -589,6 +593,11 @@ if (navbar instanceof HTMLElement && hamburger instanceof HTMLButtonElement && o
color: var(--brand-red);
}

.theme-toggle:focus-visible {
outline: 2px solid var(--brand-red);
outline-offset: 2px;
}

.theme-icon { display: none; }

:global([data-theme='dark']) .theme-icon--dark,
Expand Down
1 change: 1 addition & 0 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const blogCollection = defineCollection({
category: z.string(),
tags: z.array(z.string()),
featured: z.boolean().optional(),
series: z.string().optional(),
}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ heroImage: './frontend-roadmap-part-1-cover.png'
category: 'Frontend Frameworks'
tags: ['React.js', 'JavaScript', 'Web Development']
featured: false
series: 'Frontend Developer Roadmap'
---

# Frontend Developer Roadmap (Part 1): React.js
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ heroImage: './frontend-roadmap-part-2-cover.png'
category: 'Frontend Frameworks'
tags: ['Vue.js', 'JavaScript', 'Web Development']
featured: false
series: 'Frontend Developer Roadmap'
---

# Frontend Developer Roadmap (Part 2): Vue.js
Expand Down
1 change: 1 addition & 0 deletions src/content/blog/haskell-ep1/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ heroImage: './haskell-ep1-cover.png'
category: 'Programming Languages'
tags: ['Haskell', 'Functional Programming', 'Beginner']
featured: true
series: 'Haskell'
---

<InfoBox title="ที่มาและเครดิต" type="info">
Expand Down
1 change: 1 addition & 0 deletions src/content/blog/haskell-ep2/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ heroImage: "./haskell-ep2-cover.png"
category: "Programming Languages"
tags: ["Haskell", "Functional Programming", "GHCi", "Beginner"]
featured: false
series: "Haskell"
---

## Ready, set, go!
Expand Down
5 changes: 3 additions & 2 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const currentYear = new Date().getFullYear();
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#E5E7E0" />
<title>{`${title} | rxdevman`}</title>
<link rel="alternate" type="application/rss+xml" title="rxdevman" href="/rss.xml" />
<script is:inline>
(function () {
const stored = localStorage.getItem('theme');
Expand Down Expand Up @@ -71,8 +72,8 @@ const currentYear = new Date().getFullYear();
<div class="footer-brand">
<a href="/" class="footer-logo" aria-label="rxdevman home">
<svg width="24" height="24" viewBox="0 0 200 200" aria-hidden="true">
<path d="M40 30 V170 M40 30 H110 C145 30 160 55 160 80 C160 110 140 125 110 125 H40 M110 125 L160 170" fill="none" stroke="#F54E00" stroke-width="18" stroke-linecap="round" stroke-linejoin="round" />
<path d="M85 65 L115 95 M115 65 L85 95" fill="none" stroke="#F54E00" stroke-width="15" stroke-linecap="round" />
<path d="M40 30 V170 M40 30 H110 C145 30 160 55 160 80 C160 110 140 125 110 125 H40 M110 125 L160 170" fill="none" stroke="currentColor" stroke-width="18" stroke-linecap="round" stroke-linejoin="round" />
<path d="M85 65 L115 95 M115 65 L85 95" fill="none" stroke="currentColor" stroke-width="15" stroke-linecap="round" />
</svg>
<span class="footer-logo-text">rxdevman</span>
</a>
Expand Down
75 changes: 74 additions & 1 deletion src/layouts/BlogPostLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import Toc from '@/components/blog/Toc.astro';
import ViewCounter from '@/components/blog/ViewCounter.astro';
import { getAllBlogPosts } from '@/lib/blog';
import { getRelatedPosts } from '@/utils/post-utils';
import { slugify } from '@/utils/slugify';
import { getSeriesNav, groupPostsBySeries } from '@/utils/series';
import { getEntrySlug, slugify } from '@/utils/slugify';

import BaseLayout from './BaseLayout.astro';

Expand All @@ -36,6 +37,16 @@ const postSlug = slug || slugify(title);
const allPosts = await getAllBlogPosts();
const relatedPosts = getRelatedPosts(allPosts, postSlug, tags, category, 3);

// Compute series prev/next navigation
const currentEntrySlug = getEntrySlug(postSlug);
const seriesGroups = groupPostsBySeries(allPosts);
const currentSeries = seriesGroups.find(g =>
g.posts.some(p => getEntrySlug(p.id) === currentEntrySlug),
);
const seriesNav = currentSeries
? getSeriesNav(currentSeries.posts, currentEntrySlug)
: null;

// Pre-generate the hero image URL for <link rel="preload"> to improve LCP
const preloadHero = heroImage
? await getImage({ src: heroImage, width: 1200, quality: 85, format: 'webp' })
Expand Down Expand Up @@ -108,11 +119,73 @@ const preloadHero = heroImage
</div>
</footer>

{seriesNav && (seriesNav.prev || seriesNav.next) && (
<nav class="series-nav" aria-label={`Navigation within series: ${currentSeries?.name}`}>
{seriesNav.prev && (
<a href={`/blog/${getEntrySlug(seriesNav.prev.id)}`} class="series-nav-link series-nav-prev">
<span class="series-nav-label">← Previous in series</span>
<span class="series-nav-title">{seriesNav.prev.data.title}</span>
</a>
)}
<div class="series-nav-spacer" />
{seriesNav.next && (
<a href={`/blog/${getEntrySlug(seriesNav.next.id)}`} class="series-nav-link series-nav-next">
<span class="series-nav-label">Next in series →</span>
<span class="series-nav-title">{seriesNav.next.data.title}</span>
</a>
)}
</nav>
)}

<RelatedPosts relatedPosts={relatedPosts} />
</article>
</div>
</BaseLayout>

<style>
.post-header-category:hover { color: var(--brand-orange); }

.series-nav {
display: flex;
justify-content: space-between;
gap: var(--space-4);
margin-top: var(--space-8);
padding-top: var(--space-8);
border-top: 1px solid var(--border-default);
}

.series-nav-spacer { flex: 1; }

.series-nav-link {
display: flex;
flex-direction: column;
gap: var(--space-1);
text-decoration: none;
max-width: 45%;
transition: color var(--duration-150) var(--ease);
}

.series-nav-next {
text-align: right;
margin-left: auto;
}

.series-nav-label {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--brand-red);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}

.series-nav-title {
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: 500;
}

.series-nav-link:hover .series-nav-title {
color: var(--text-heading);
}
</style>
Loading