Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
58dd2b2
refactor(blog): move Read More links to the right side for mobile view
CodingWithJiro Aug 28, 2026
f5fe5a5
feat(blog): add breadcrumb to blog page
CodingWithJiro Aug 28, 2026
691c017
perf(blog): add loading eager attribute to banner images in blog page
CodingWithJiro Aug 28, 2026
e049ede
refactor(blog): use Lucide React icons for ViewAllLink and update to …
CodingWithJiro Aug 28, 2026
fe040d3
refactor(blog): use ArrowRight for Read More links and align it to ri…
CodingWithJiro Aug 28, 2026
0bfa180
refactor(blog): use ArrowRight for Read More in the blog page and hid…
CodingWithJiro Aug 28, 2026
a982325
feat(nav): make Breadcrumb font size smaller in mobile view
CodingWithJiro Aug 28, 2026
11790ae
refactor(blog): update article page Read more articles link to use Ar…
CodingWithJiro Aug 28, 2026
bcbcc89
refactor(blog): remove color transitions to Read more links
CodingWithJiro Aug 28, 2026
716c9b8
refactor(blog): make Read more links only has the first letter in upp…
CodingWithJiro Aug 28, 2026
d443711
test(breadcrumb): renders the Home link
CodingWithJiro Aug 28, 2026
e3de289
test(breadcrumb): renders all succeeding breadcrumb item links
CodingWithJiro Aug 28, 2026
88c7801
test(breadcrumb): renders an accessible breadcrumb navigation
CodingWithJiro Aug 28, 2026
3bd0962
test(breadcrumb): renders the current page with aria-current
CodingWithJiro Aug 28, 2026
2e567da
test(breadcrumb): renders without succeeding breadcrumb item links
CodingWithJiro Aug 28, 2026
6fd9355
test(article-header): renders the article title
CodingWithJiro Aug 28, 2026
8eabde8
test(article-header): renders the article publish date
CodingWithJiro Aug 28, 2026
4388085
perf(blog): add loading eager attribute to author profile picture
CodingWithJiro Aug 28, 2026
b214cf9
test(article-header): renders the article author
CodingWithJiro Aug 28, 2026
a3a0a8f
test(article-header): renders the read time
CodingWithJiro Aug 28, 2026
f7fe840
test(article-header): renders all the article tags
CodingWithJiro Aug 28, 2026
1feb2a8
test(article-header): renders the banner image with alt text
CodingWithJiro Aug 28, 2026
8f02c75
test(article-header): renders the blog not found variant
CodingWithJiro Aug 28, 2026
366cc41
test(article-header): renders the breadcrumb component
CodingWithJiro Aug 28, 2026
a951fc7
test(article-header): renders the theme toggle component
CodingWithJiro Aug 28, 2026
4cf6710
test(e2e): visitor can view the Blog page
CodingWithJiro Aug 28, 2026
c7d80ff
test(e2e): visitor can view a blog article
CodingWithJiro Aug 28, 2026
8e63d22
test(e2e): visitor can see a blog article item on the Blog page
CodingWithJiro Aug 28, 2026
7029e05
test(e2e): visitor sees the Blog not found page for an invalid article
CodingWithJiro Aug 28, 2026
b607485
test(utils): generates a URL slug from a title
CodingWithJiro Aug 28, 2026
7e2c8ac
test(utils): normalizes capitalization and whitespace for blog titles
CodingWithJiro Aug 28, 2026
75e525e
test(utils): removes punctuation from the blog title
CodingWithJiro Aug 28, 2026
108d067
test(utils): returns blog metadata for a valid slug
CodingWithJiro Aug 28, 2026
2d287e4
test(utils): returns the fallback not found blog metadata for an inva…
CodingWithJiro Aug 28, 2026
7599048
test(utils): group tests into their function of concern
CodingWithJiro Aug 28, 2026
ae45898
test(e2e): update visitor can view the Blog page when they click the …
CodingWithJiro Aug 28, 2026
188934e
test(e2e): visitor can go back to Home page when clicking the home li…
CodingWithJiro Aug 28, 2026
9f90c5d
test(e2e): visitor can go back to Home page from an article page
CodingWithJiro Aug 28, 2026
494be3a
test(e2e): visitor can go back to Blog page from an article page
CodingWithJiro Aug 28, 2026
339aa6e
test(e2e): visitor can go back to Blog page from an article page usin…
CodingWithJiro Aug 28, 2026
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
6 changes: 4 additions & 2 deletions src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BreadcrumbItem } from '@/types/breadcrumb-item';
import ArticleHeader from '@/components/sections/article-header';
import Footer from '@/components/sections/footer';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';

type ArticlePageProps = {
params: Promise<{ slug: string }>;
Expand Down Expand Up @@ -47,9 +48,10 @@ export default async function ArticlePage({ params }: ArticlePageProps) {
<div className="flex justify-end">
<Link
href="/blog"
className="focus-visible:outline-muted-foreground w-fit rounded-sm text-[0.875rem] font-normal underline-offset-4 outline-2 outline-offset-4 outline-transparent transition-colors duration-150 ease-in-out hover:underline md:text-right"
className="focus-visible:outline-muted-foreground flex w-fit items-center gap-1 rounded-sm text-[0.875rem] font-normal underline-offset-4 outline-2 outline-offset-4 outline-transparent transition-colors duration-150 ease-in-out hover:underline md:text-right"
>
Read more articles →
<span>Read more articles</span>
<ArrowRight aria-hidden="true" size={16} />
</Link>
</div>

Expand Down
27 changes: 16 additions & 11 deletions src/app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Link from 'next/link';
import Footer from '@/components/sections/footer';
import ThemeToggle from '@/components/theme/theme-toggle';
import { BLOGS } from '@/data/blog-list';
import Breadcrumb from '@/components/sections/breadcrumb';
import { ArrowRight } from 'lucide-react';

const metaDescription =
'Blog posts on full stack development and tech from Elmar Chavez.';
Expand Down Expand Up @@ -34,17 +36,18 @@ const blogsLatestOrder = BLOGS.slice().reverse();
export default function BlogPage() {
return (
<main id="main" className="mx-auto max-w-130 px-4 py-8 md:max-w-2xl">
<header className="flex justify-between">
<div>
<h1 className="mb-1 text-lg font-bold md:text-3xl">Blog</h1>
<p className="mb-6 text-sm font-medium md:text-base">
Articles, thoughts, and tech stuff.
</p>
</div>

<header className="mb-8 flex items-center justify-between">
<Breadcrumb items={[]} currentLabel="Blog" />
<ThemeToggle />
</header>

<section>
<h1 className="mb-1 text-lg font-bold md:text-3xl">Blog</h1>
<p className="mb-6 text-sm font-medium md:text-base">
Articles, thoughts, and tech stuff.
</p>
</section>

<ul className="flex flex-col gap-4">
{blogsLatestOrder.map(
({
Expand All @@ -69,6 +72,7 @@ export default function BlogPage() {
alt={imgAlt}
width={800}
height={400}
loading="eager"
className="aspect-video w-full object-cover transition-transform duration-150 hover:scale-105"
/>
</div>
Expand All @@ -87,18 +91,19 @@ export default function BlogPage() {
</p>

<div className="flex flex-col gap-4 pr-4 min-[425px]:flex-row min-[425px]:items-center min-[425px]:justify-between">
<ul className="flex gap-2 text-[0.65rem] font-light">
<ul className="hidden gap-2 text-[0.65rem] font-light min-[425px]:flex">
{tags.map((tag) => {
return <li key={tag}>{tag}</li>;
})}
</ul>

<Link
className="focus-visible:outline-muted-foreground w-fit rounded-sm text-[0.75rem] font-normal underline-offset-4 outline-2 outline-offset-4 outline-transparent hover:underline md:text-right"
className="focus-visible:outline-muted-foreground flex w-fit items-center justify-center gap-1 self-end rounded-sm text-[0.75rem] font-normal underline-offset-4 outline-2 outline-offset-4 outline-transparent hover:underline min-[425px]:self-auto md:text-right"
href={blogUrl}
aria-label={`Read more about ${title}`}
>
Read More →
<span>Read more</span>
<ArrowRight aria-hidden="true" size={12} />
</Link>
</div>
</article>
Expand Down
103 changes: 103 additions & 0 deletions src/components/sections/article-header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { render, screen } from '@testing-library/react';
import ArticleHeader from './article-header';
import { BreadcrumbItem } from '@/types/breadcrumb-item';
import { getSlug } from '@/lib/utils';

describe('Article Header', () => {
const breadcrumbItems: BreadcrumbItem[] = [{ label: 'Blog', href: '/blog' }];
const blogMeta = {
id: 4,
title: 'How I Contributed to a Laravel Application Without Knowing Laravel',
description:
'Lessons from contributing to a Laravel application, where I discovered that recognizing familiar patterns is often more valuable than starting over.',
publishDate: 'August 2026',
readTime: '4 min read',
tags: ['#laravel', '#learning', '#webdev'],
blogUrl: `/blog/${getSlug('How I Contributed to a Laravel Application Without Knowing Laravel')}`,
imgUrl: '/img/blog-4.jpg',
imgAlt: 'Keyboard caps laid down on a white table.',
};

test('renders the article title', () => {
render(
<ArticleHeader breadcrumbItems={breadcrumbItems} blogMeta={blogMeta} />,
);
const title = screen.getByRole('heading', { name: blogMeta.title });
expect(title).toBeInTheDocument();
});
test('renders the article publish date', () => {
render(
<ArticleHeader breadcrumbItems={breadcrumbItems} blogMeta={blogMeta} />,
);
const publishDate = screen.getByText(blogMeta.publishDate);
expect(publishDate).toBeInTheDocument();
});
test('renders the article author', () => {
render(
<ArticleHeader breadcrumbItems={breadcrumbItems} blogMeta={blogMeta} />,
);
const authorName = screen.getByText('Elmar Chavez');
expect(authorName).toBeInTheDocument();
});
test('renders the read time', () => {
render(
<ArticleHeader breadcrumbItems={breadcrumbItems} blogMeta={blogMeta} />,
);
const readTime = screen.getByText(blogMeta.readTime);
expect(readTime).toBeInTheDocument();
});
test('renders all the article tags', () => {
render(
<ArticleHeader breadcrumbItems={breadcrumbItems} blogMeta={blogMeta} />,
);
blogMeta.tags.forEach((tag) => {
const text = screen.getByText(tag);
expect(text).toBeInTheDocument();
});
});
test('renders the banner image with alt text', () => {
render(
<ArticleHeader breadcrumbItems={breadcrumbItems} blogMeta={blogMeta} />,
);
const bannerImage = screen.getByRole('img', { name: blogMeta.imgAlt });
expect(bannerImage).toBeInTheDocument();
});
test('renders the blog not found variant', () => {
render(
<ArticleHeader
breadcrumbItems={breadcrumbItems}
blogMeta={{
id: -99,
title: 'Blog not found',
description: "Seems like this file doesn't exist ¯\\\_(ツ)_/¯",
publishDate: 'No publish date.',
readTime: 'No read time.',
tags: [],
blogUrl: 'No blog URL.',
imgUrl: 'No image URL.',
imgAlt: 'No image alt text.',
}}
/>,
);
const title = screen.getByRole('heading', { name: /blog not found/i });
expect(title).toBeInTheDocument();
const description = screen.getByText(
/^seems like this file doesn't exist/i,
);
expect(description).toBeInTheDocument();
});
test('renders the breadcrumb component', () => {
render(
<ArticleHeader breadcrumbItems={breadcrumbItems} blogMeta={blogMeta} />,
);
const breadcrumb = screen.getByRole('navigation', { name: /breadcrumb/i });
expect(breadcrumb).toBeInTheDocument();
});
test('renders the theme toggle component', () => {
render(
<ArticleHeader breadcrumbItems={breadcrumbItems} blogMeta={blogMeta} />,
);
const themeToggle = screen.getByRole('button', { name: /toggle theme/i });
expect(themeToggle).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/components/sections/article-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const ArticleHeader = ({ breadcrumbItems, blogMeta }: ArticleHeaderProps) => {
width={683}
height={771}
className="object-cover"
loading="eager"
/>
</div>

Expand Down
15 changes: 9 additions & 6 deletions src/components/sections/blog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import Image from 'next/image';
import Link from 'next/link';
import { blogList } from '@/data/blog-list';
import ViewAllLink from '../ui/view-all-link';
import { ArrowRight } from 'lucide-react';

const Blog = () => {
return (
<section className="border-border/40 bg-card relative rounded-lg border p-4 transition-colors duration-150 ease-in-out">
<h2 className="mb-3 text-lg font-bold">Blog</h2>

<ViewAllLink href="/blog" />
<header className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold">Blog</h2>
<ViewAllLink href="/blog" />
</header>

<ul className="flex flex-col gap-4">
{blogList.map(
Expand Down Expand Up @@ -42,18 +44,19 @@ const Blog = () => {
</p>

<div className="flex flex-col gap-4 pr-4 min-[425px]:flex-row min-[425px]:items-center min-[425px]:justify-between">
<ul className="flex gap-2 text-[0.65rem] font-light">
<ul className="hidden gap-2 text-[0.65rem] font-light min-[425px]:flex">
{tags.map((tag) => {
return <li key={tag}>{tag}</li>;
})}
</ul>

<Link
className="focus-visible:outline-muted-foreground w-fit rounded-sm text-[0.75rem] font-normal underline-offset-4 outline-2 outline-offset-4 outline-transparent transition-colors duration-150 ease-in-out hover:underline md:text-right"
className="focus-visible:outline-muted-foreground flex w-fit items-center justify-center gap-1 self-end rounded-sm text-[0.75rem] font-normal underline-offset-4 outline-2 outline-offset-4 outline-transparent transition-colors duration-150 ease-in-out hover:underline min-[425px]:self-auto md:text-right"
href={blogUrl}
aria-label={`Read more about ${title}`}
>
Read More →
<span>Read more</span>
<ArrowRight aria-hidden="true" size={12} />
</Link>
</div>
</article>
Expand Down
39 changes: 39 additions & 0 deletions src/components/sections/breadcrumb.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import Breadcrumb from './breadcrumb';
import type { BreadcrumbItem } from '@/types/breadcrumb-item';

describe('Breadcrumb', () => {
const items: BreadcrumbItem[] = [
{ label: 'Blog', href: '/blog' },
{ label: 'Technology', href: '/blog/technology' },
];
test('renders the Home link', () => {
render(<Breadcrumb items={items} currentLabel="Current Article" />);
const link = screen.getByRole('link', { name: /home/i });
expect(link).toHaveAttribute('href', '/');
});
test('renders all succeeding breadcrumb item links', () => {
render(<Breadcrumb items={items} currentLabel="Current Article" />);
items.forEach(({ label, href }) => {
const link = screen.getByRole('link', { name: label });
expect(link).toHaveAttribute('href', href);
});
});
test('renders an accessible breadcrumb navigation', () => {
render(<Breadcrumb items={items} currentLabel="Current Article" />);
const nav = screen.getByRole('navigation', { name: /breadcrumb/i });
expect(nav).toBeInTheDocument();
});
test('renders the current page with aria-current', () => {
render(<Breadcrumb items={items} currentLabel="Current Article" />);
const currentPage = screen.getByText(/current article/i);
expect(currentPage).toHaveAttribute('aria-current', 'page');
});
test('renders without succeeding breadcrumb item links', () => {
render(<Breadcrumb items={[]} currentLabel="Current Article" />);
const homeLink = screen.getByRole('link', { name: /home/i });
const currentPage = screen.getByText(/current article/i);
expect(homeLink).toBeInTheDocument();
expect(currentPage).toBeInTheDocument();
});
});
18 changes: 12 additions & 6 deletions src/components/sections/breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ const Breadcrumb = ({ items, currentLabel }: BreadCrumbProps) => {
return (
<nav className="select-none" aria-label="Breadcrumb">
<ol className="flex items-center gap-1">
<li>
<li className="flex items-center">
<Link
className="text-muted-foreground hover:text-foreground focus-visible:text-foreground text-sm font-medium outline-transparent transition-colors focus-visible:underline focus-visible:underline-offset-4"
className="text-muted-foreground hover:text-foreground focus-visible:text-foreground text-[12px] font-medium outline-transparent transition-colors focus-visible:underline focus-visible:underline-offset-4 md:text-sm"
href="/"
>
Home
Expand All @@ -22,12 +22,15 @@ const Breadcrumb = ({ items, currentLabel }: BreadCrumbProps) => {
{items.map(({ label, href }) => {
return (
<li key={href} className="flex items-center gap-1">
<span className="text-muted-foreground" aria-hidden="true">
<span
className="text-muted-foreground text-[12px] md:text-sm"
aria-hidden="true"
>
/
</span>

<Link
className="text-muted-foreground hover:text-foreground focus-visible:text-foreground text-sm font-medium outline-transparent transition-colors focus-visible:underline focus-visible:underline-offset-4"
className="text-muted-foreground hover:text-foreground focus-visible:text-foreground text-[12px] font-medium outline-transparent transition-colors focus-visible:underline focus-visible:underline-offset-4 md:text-sm"
href={href}
>
{label}
Expand All @@ -37,12 +40,15 @@ const Breadcrumb = ({ items, currentLabel }: BreadCrumbProps) => {
})}

<li className="flex items-center gap-1">
<span className="text-muted-foreground" aria-hidden="true">
<span
className="text-muted-foreground text-[12px] md:text-sm"
aria-hidden="true"
>
/
</span>

<p
className="text-muted-foreground hover:text-foreground max-w-32 truncate text-sm font-medium transition-colors hover:cursor-pointer md:max-w-62"
className="text-muted-foreground hover:text-foreground max-w-32 truncate text-[12px] font-medium transition-colors hover:cursor-pointer md:max-w-62 md:text-sm"
aria-current="page"
>
{currentLabel}
Expand Down
9 changes: 6 additions & 3 deletions src/components/ui/view-all-link.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from 'next/link';
import { ArrowUpRight, ArrowRight } from 'lucide-react';

type ViewAllLinkProps = {
href: string;
Expand All @@ -7,15 +8,17 @@ type ViewAllLinkProps = {
const ViewAllLink = ({ href }: ViewAllLinkProps) => {
return (
<Link
className="group focus-visible:outline-muted-foreground absolute top-4 right-4 flex items-center gap-1 rounded-sm text-[1rem] font-normal outline-2 outline-offset-4 outline-transparent transition-all duration-150 ease-in-out"
className="group focus-visible:outline-muted-foreground flex items-center gap-1 rounded-sm text-[1rem] font-normal outline-2 outline-offset-4 outline-transparent transition-all duration-150 ease-in-out"
href={href}
>
<span className="max-w-0 overflow-hidden text-[0.75rem] whitespace-nowrap underline-offset-4 opacity-0 transition-all duration-150 group-hover:max-w-xs group-hover:underline group-hover:opacity-100 group-focus:max-w-xs group-focus:opacity-100">
View all
</span>
<span className="group-hover:hidden group-focus:hidden">↗</span>
<span className="group-hover:hidden group-focus:hidden">
<ArrowUpRight aria-hidden="true" size={16} />
</span>
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 group-hover:max-w-xs group-hover:opacity-100 group-focus:max-w-xs group-focus:opacity-100">
→
<ArrowRight aria-hidden="true" size={16} />
</span>
</Link>
);
Expand Down
Loading
Loading