feat: enhance blog category filtering and pagination

- Add blog category filter for desktop and mobile views
- Implement category-based blog post filtering
- Add pagination support for blog and category pages
- Update localization messages for blog categories
- Reduce posts per page from 9 to 6
- Add loading states for blog pages
- Create new content collections for blog categories in Chinese
This commit is contained in:
javayhu 2025-03-06 00:48:22 +08:00
parent 24981655bb
commit ce7fe5b45f
24 changed files with 854 additions and 24 deletions

View File

@ -2,5 +2,6 @@
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"messages", "messages",
"src/i18n" "src/i18n"
] ],
"i18n-ally.keystyle": "nested"
} }

View File

@ -4,7 +4,7 @@ description: IndieHub is the best all-in-one directory for indie hackers.
image: /images/blog/indiehub-og.png image: /images/blog/indiehub-og.png
date: 2024-11-24T12:00:00.000Z date: 2024-11-24T12:00:00.000Z
published: true published: true
categories: [news] categories: [news, guide]
author: indiehub author: indiehub
--- ---

View File

@ -0,0 +1,5 @@
---
slug: indiehub
name: IndieHub
avatar: /images/avatars/indiehub.png
---

View File

@ -0,0 +1,5 @@
---
slug: mkdirs
name: Mkdirs
avatar: /images/avatars/mkdirs.png
---

View File

@ -0,0 +1,5 @@
---
slug: mksaas
name: MkSaaS
avatar: /images/avatars/mksaas.png
---

View File

@ -0,0 +1,211 @@
---
title: IndieHub 是什么?
description: IndieHub是一站式的独立开发者导航站。
image: /images/blog/indiehub-og.png
date: 2024-11-24T12:00:00.000Z
published: true
categories: [news, guide]
author: indiehub
locale: zh
---
到目前为止,尝试使用 Tailwind 来设计文章、文档或博客文章的样式一直是一项繁琐的任务,需要对排版有敏锐的眼光,并且需要大量复杂的自定义 CSS。
默认情况下Tailwind 会删除段落、标题、列表等所有默认的浏览器样式。这对于构建应用程序 UI 非常有用,因为您花更少的时间撤销用户代理样式,但是当您真的只是尝试设置来自 CMS 中富文本编辑器或 markdown 文件的内容的样式时,这可能会令人惊讶和不直观。
我们实际上收到了很多关于它的投诉,人们经常问我们这样的问题:
> 为什么 Tailwind 删除了我的 `h1` 元素上的默认样式?我如何禁用这个?你说我也会失去所有其他基本样式是什么意思?
> 我们听到了您的声音,但我们并不确信简单地禁用我们的基本样式就是您真正想要的。您不希望每次在仪表板 UI 的一部分中使用 `p` 元素时都必须删除烦人的边距。而且我怀疑您真的希望您的博客文章使用用户代理样式——您希望它们看起来很棒,而不是糟糕。
`@tailwindcss/typography` 插件是我们尝试给您真正想要的东西,而不会有做一些愚蠢的事情(比如禁用我们的基本样式)的任何缺点。
它添加了一个新的 `prose` 类,您可以将其应用于任何普通 HTML 内容块,并将其转变为一个美丽、格式良好的文档:
```html
<article class="prose">
<h1>大蒜面包配奶酪:科学告诉我们什么</h1>
<p>
多年来,父母一直向他们的孩子宣扬吃大蒜面包配奶酪的健康益处,这种食物在我们的文化中获得了如此标志性的地位,以至于孩子们经常在万圣节装扮成温暖、奶酪味的面包。
</p>
<p>
但最近的一项研究表明,这种受欢迎的开胃菜可能与全国各地出现的一系列狂犬病病例有关。
</p>
</article>
```
有关如何使用该插件及其包含的功能的更多信息,[阅读文档](https://github.com/tailwindcss/typography/blob/master/README.md)。
---
## 从现在开始期待什么
从这里开始的是我写的一堆绝对无意义的内容,用来测试插件本身。它包括我能想到的每一个合理的排版元素,如**粗体文本**、无序列表、有序列表、代码块、块引用_甚至斜体_。
涵盖所有这些用例很重要,原因如下:
1. 我们希望一切开箱即用看起来都很好。
2. 实际上只是第一个原因,这是插件的全部意义。
3. 这里有第三个假装的原因,尽管一个有三个项目的列表看起来比一个有两个项目的列表更真实。
现在我们将尝试另一种标题样式。
### 排版应该很简单
所以这是给你的一个标题——如果我们做得正确,那应该看起来相当合理。
一位智者曾经告诉我关于排版的一件事是:
> 如果你不希望你的东西看起来像垃圾,排版是非常重要的。做好它,那么它就不会糟糕。
默认情况下,图片在这里看起来也应该不错:
{/* <Image
src="/images/blog/mksaas-og.png"
width="718"
height="404"
alt="图片"
/> */}
与普遍的看法相反Lorem Ipsum 并不是简单的随机文本。它起源于公元前 45 年的一段古典拉丁文学,使其有超过 2000 年的历史。
现在我将向您展示一个无序列表的例子,以确保它看起来也不错:
- 所以这是这个列表中的第一项。
- 在这个例子中,我们保持项目简短。
- 稍后,我们将使用更长、更复杂的列表项。
这就是本节的结尾。
## 如果我们堆叠标题怎么办?
### 我们也应该确保这看起来不错。
有时候你有直接堆叠在一起的标题。在这些情况下,你通常必须取消第二个标题上的顶部边距,因为标题彼此靠得更近通常看起来比段落后面跟着标题要好。
### 当标题在段落之后出现时……
当标题在段落之后出现时,我们需要更多的空间,就像我上面已经提到的那样。现在让我们看看一个更复杂的列表会是什么样子。
- **我经常做这种事,列表项有标题。**
由于某种原因,我认为这看起来很酷,这很不幸,因为要让样式正确是相当烦人的。
我在这些列表项中通常也有两到三个段落,所以困难的部分是让段落之间的间距、列表项标题和单独的列表项都有意义。老实说,这很困难,你可以提出一个强有力的论点,认为你根本不应该这样写。
- **由于这是一个列表,我至少需要两个项目。**
我已经在前面的列表项中解释了我在做什么,但是如果一个列表只有一个项目,那就不是一个列表,我们真的希望这看起来真实。这就是为什么我添加了这第二个列表项,所以我在写样式时实际上有东西可以看。
- **添加第三项也不是一个坏主意。**
我认为只使用两个项目可能已经足够了,但三个肯定不会更糟,而且由于我似乎在编造任意的东西时没有遇到麻烦,我不妨包括它。
在这种列表之后,我通常会有一个结束语或段落,因为直接跳到标题看起来有点奇怪。
## 代码默认应该看起来不错。
我认为大多数人如果想要设置他们的代码块的样式,会使用 [highlight.js](https://highlightjs.org/) 或 [Prism](https://prismjs.com/) 或其他东西但是让它们开箱即用看起来_还不错_即使没有语法高亮也不会有害。
以下是撰写本文时默认的 `tailwind.config.js` 文件的样子:
```js
module.exports = {
purge: [],
theme: {
extend: {},
},
variants: {},
plugins: [],
};
```
希望这对你来说看起来足够好。
### 嵌套列表怎么办?
嵌套列表基本上总是看起来很糟糕,这就是为什么像 Medium 这样的编辑器甚至不让你这样做,但我猜既然你们中的一些傻瓜要这样做,我们至少要承担让它工作的负担。
1. **嵌套列表很少是一个好主意。**
- 你可能觉得你真的很"有组织"或者什么的,但你只是在屏幕上创建一个难以阅读的粗糙形状。
- UI 中的嵌套导航也是一个坏主意,尽可能保持扁平。
- 在源代码中嵌套大量文件夹也没有帮助。
2. **既然我们需要有更多的项目,这里有另一个。**
- 我不确定我们是否会费心设置超过两级深度的样式。
- 两级已经太多了,三级肯定是一个坏主意。
- 如果你嵌套四级深度,你应该进监狱。
3. **两个项目并不是真正的列表,三个项目就好了。**
- 再次请不要嵌套列表,如果你希望人们真正阅读你的内容。
- 没有人想看这个。
- 我很不高兴我们甚至必须费心设置这个样式。
Markdown 中列表最烦人的事情是,除非列表项中有多个段落,否则 `<li>` 元素不会被赋予子 `<p>` 标签。这意味着我也必须担心设置那种烦人情况的样式。
- **例如,这里是另一个嵌套列表。**
但这次有第二段。
- 这些列表项不会有 `<p>` 标签
- 因为它们每个只有一行
- **但在这第二个顶级列表项中,它们会有。**
这特别烦人,因为这段话的间距。
- 正如你在这里看到的,因为我添加了第二行,这个列表项现在有一个 `<p>` 标签。
顺便说一下,这是我说的第二行。
- 最后这里有另一个列表项,所以它更像一个列表。
- 一个结束列表项,但没有嵌套列表,为什么不呢?
最后一句话结束这一节。
## 还有其他我们需要设置样式的元素
我几乎忘了提到链接,比如[这个链接到 Tailwind CSS 网站](https://tailwindcss.com)。我们几乎把它们变成蓝色,但那是昨天的事了,所以我们选择了深灰色,感觉更前卫。
我们甚至包括了表格样式,看看:
| 摔跤手 | 出生地 | 终结技 |
| ----------------------- | ------------- | ------------------- |
| Bret "The Hitman" Hart | Calgary, AB | Sharpshooter |
| Stone Cold Steve Austin | Austin, TX | Stone Cold Stunner |
| Randy Savage | Sarasota, FL | Elbow Drop |
| Vader | Boulder, CO | Vader Bomb |
| Razor Ramon | Chuluota, FL | Razor's Edge |
我们还需要确保内联代码看起来不错,比如如果我想谈论 `<span>` 元素或者告诉你关于 `@tailwindcss/typography` 的好消息。
### 有时我甚至在标题中使用 `code`
尽管这可能是一个坏主意而且历史上我一直很难让它看起来不错。不过这个_"将代码块包裹在反引号中"_的技巧效果相当不错。
我过去做过的另一件事是在链接中放置一个 `code` 标签,比如如果我想告诉你关于 [`tailwindcss/docs`](https://github.com/tailwindcss/docs) 仓库的事情。我不喜欢反引号下面有下划线,但为了避免它而导致的疯狂绝对不值得。
#### 我们还没有使用 `h4`
但现在我们有了。请不要在你的内容中使用 `h5` 或 `h6`Medium 只支持两个标题级别是有原因的,你们这些动物。我老实说考虑过使用 `before` 伪元素,如果你使用 `h5` 或 `h6` 就对你大喊大叫。
我们根本不会为它们设置样式,因为 `h4` 元素已经很小,与正文大小相同。我们应该怎么处理 `h5`让它比正文更_小_谢谢。
### 不过我们仍然需要考虑堆叠的标题。
#### 让我们确保我们也不会用 `h4` 元素搞砸这个。
呼,运气好的话,我们已经设置了上面这段文字的标题样式,它们看起来相当不错。
让我们在这里添加一个结束段落,这样事情就会以一个相当大小的文本块结束。我无法解释为什么我希望事情以这种方式结束,但我必须假设这是因为我认为如果文档末尾太靠近标题,事情会看起来奇怪或不平衡。
我在这里写的可能已经足够长了,但添加这最后一句话不会有害。
## GitHub 风格的 Markdown
我还添加了对使用 `remark-gfm` 的 GitHub 风格 Markdown 的支持。
使用 `remark-gfm`,我们在 markdown 中获得了一些额外的功能。例如:自动链接文字。
像 www.example.com 或 https://example.com 这样的链接会自动转换为 `a` 标签。
这对电子邮件链接也有效contact@example.com。

View File

@ -0,0 +1,211 @@
---
title: Mkdirs 是什么?
description: Mkdirs 是构建导航站的最佳代码模板。
image: /images/blog/mkdirs-og.png
date: 2024-11-25T12:00:00.000Z
published: true
categories: [news, guide]
author: mkdirs
locale: zh
---
到目前为止,尝试使用 Tailwind 来设计文章、文档或博客文章的样式一直是一项繁琐的任务,需要对排版有敏锐的眼光,并且需要大量复杂的自定义 CSS。
默认情况下Tailwind 会删除段落、标题、列表等所有默认的浏览器样式。这对于构建应用程序 UI 非常有用,因为您花更少的时间撤销用户代理样式,但是当您真的只是尝试设置来自 CMS 中富文本编辑器或 markdown 文件的内容的样式时,这可能会令人惊讶和不直观。
我们实际上收到了很多关于它的投诉,人们经常问我们这样的问题:
> 为什么 Tailwind 删除了我的 `h1` 元素上的默认样式?我如何禁用这个?你说我也会失去所有其他基本样式是什么意思?
> 我们听到了您的声音,但我们并不确信简单地禁用我们的基本样式就是您真正想要的。您不希望每次在仪表板 UI 的一部分中使用 `p` 元素时都必须删除烦人的边距。而且我怀疑您真的希望您的博客文章使用用户代理样式——您希望它们看起来很棒,而不是糟糕。
`@tailwindcss/typography` 插件是我们尝试给您真正想要的东西,而不会有做一些愚蠢的事情(比如禁用我们的基本样式)的任何缺点。
它添加了一个新的 `prose` 类,您可以将其应用于任何普通 HTML 内容块,并将其转变为一个美丽、格式良好的文档:
```html
<article class="prose">
<h1>大蒜面包配奶酪:科学告诉我们什么</h1>
<p>
多年来,父母一直向他们的孩子宣扬吃大蒜面包配奶酪的健康益处,这种食物在我们的文化中获得了如此标志性的地位,以至于孩子们经常在万圣节装扮成温暖、奶酪味的面包。
</p>
<p>
但最近的一项研究表明,这种受欢迎的开胃菜可能与全国各地出现的一系列狂犬病病例有关。
</p>
</article>
```
有关如何使用该插件及其包含的功能的更多信息,[阅读文档](https://github.com/tailwindcss/typography/blob/master/README.md)。
---
## 从现在开始期待什么
从这里开始的是我写的一堆绝对无意义的内容,用来测试插件本身。它包括我能想到的每一个合理的排版元素,如**粗体文本**、无序列表、有序列表、代码块、块引用_甚至斜体_。
涵盖所有这些用例很重要,原因如下:
1. 我们希望一切开箱即用看起来都很好。
2. 实际上只是第一个原因,这是插件的全部意义。
3. 这里有第三个假装的原因,尽管一个有三个项目的列表看起来比一个有两个项目的列表更真实。
现在我们将尝试另一种标题样式。
### 排版应该很简单
所以这是给你的一个标题——如果我们做得正确,那应该看起来相当合理。
一位智者曾经告诉我关于排版的一件事是:
> 如果你不希望你的东西看起来像垃圾,排版是非常重要的。做好它,那么它就不会糟糕。
默认情况下,图片在这里看起来也应该不错:
{/* <Image
src="/images/blog/mksaas-og.png"
width="718"
height="404"
alt="图片"
/> */}
与普遍的看法相反Lorem Ipsum 并不是简单的随机文本。它起源于公元前 45 年的一段古典拉丁文学,使其有超过 2000 年的历史。
现在我将向您展示一个无序列表的例子,以确保它看起来也不错:
- 所以这是这个列表中的第一项。
- 在这个例子中,我们保持项目简短。
- 稍后,我们将使用更长、更复杂的列表项。
这就是本节的结尾。
## 如果我们堆叠标题怎么办?
### 我们也应该确保这看起来不错。
有时候你有直接堆叠在一起的标题。在这些情况下,你通常必须取消第二个标题上的顶部边距,因为标题彼此靠得更近通常看起来比段落后面跟着标题要好。
### 当标题在段落之后出现时……
当标题在段落之后出现时,我们需要更多的空间,就像我上面已经提到的那样。现在让我们看看一个更复杂的列表会是什么样子。
- **我经常做这种事,列表项有标题。**
由于某种原因,我认为这看起来很酷,这很不幸,因为要让样式正确是相当烦人的。
我在这些列表项中通常也有两到三个段落,所以困难的部分是让段落之间的间距、列表项标题和单独的列表项都有意义。老实说,这很困难,你可以提出一个强有力的论点,认为你根本不应该这样写。
- **由于这是一个列表,我至少需要两个项目。**
我已经在前面的列表项中解释了我在做什么,但是如果一个列表只有一个项目,那就不是一个列表,我们真的希望这看起来真实。这就是为什么我添加了这第二个列表项,所以我在写样式时实际上有东西可以看。
- **添加第三项也不是一个坏主意。**
我认为只使用两个项目可能已经足够了,但三个肯定不会更糟,而且由于我似乎在编造任意的东西时没有遇到麻烦,我不妨包括它。
在这种列表之后,我通常会有一个结束语或段落,因为直接跳到标题看起来有点奇怪。
## 代码默认应该看起来不错。
我认为大多数人如果想要设置他们的代码块的样式,会使用 [highlight.js](https://highlightjs.org/) 或 [Prism](https://prismjs.com/) 或其他东西但是让它们开箱即用看起来_还不错_即使没有语法高亮也不会有害。
以下是撰写本文时默认的 `tailwind.config.js` 文件的样子:
```js
module.exports = {
purge: [],
theme: {
extend: {},
},
variants: {},
plugins: [],
};
```
希望这对你来说看起来足够好。
### 嵌套列表怎么办?
嵌套列表基本上总是看起来很糟糕,这就是为什么像 Medium 这样的编辑器甚至不让你这样做,但我猜既然你们中的一些傻瓜要这样做,我们至少要承担让它工作的负担。
1. **嵌套列表很少是一个好主意。**
- 你可能觉得你真的很"有组织"或者什么的,但你只是在屏幕上创建一个难以阅读的粗糙形状。
- UI 中的嵌套导航也是一个坏主意,尽可能保持扁平。
- 在源代码中嵌套大量文件夹也没有帮助。
2. **既然我们需要有更多的项目,这里有另一个。**
- 我不确定我们是否会费心设置超过两级深度的样式。
- 两级已经太多了,三级肯定是一个坏主意。
- 如果你嵌套四级深度,你应该进监狱。
3. **两个项目并不是真正的列表,三个项目就好了。**
- 再次请不要嵌套列表,如果你希望人们真正阅读你的内容。
- 没有人想看这个。
- 我很不高兴我们甚至必须费心设置这个样式。
Markdown 中列表最烦人的事情是,除非列表项中有多个段落,否则 `<li>` 元素不会被赋予子 `<p>` 标签。这意味着我也必须担心设置那种烦人情况的样式。
- **例如,这里是另一个嵌套列表。**
但这次有第二段。
- 这些列表项不会有 `<p>` 标签
- 因为它们每个只有一行
- **但在这第二个顶级列表项中,它们会有。**
这特别烦人,因为这段话的间距。
- 正如你在这里看到的,因为我添加了第二行,这个列表项现在有一个 `<p>` 标签。
顺便说一下,这是我说的第二行。
- 最后这里有另一个列表项,所以它更像一个列表。
- 一个结束列表项,但没有嵌套列表,为什么不呢?
最后一句话结束这一节。
## 还有其他我们需要设置样式的元素
我几乎忘了提到链接,比如[这个链接到 Tailwind CSS 网站](https://tailwindcss.com)。我们几乎把它们变成蓝色,但那是昨天的事了,所以我们选择了深灰色,感觉更前卫。
我们甚至包括了表格样式,看看:
| 摔跤手 | 出生地 | 终结技 |
| ----------------------- | ------------- | ------------------- |
| Bret "The Hitman" Hart | Calgary, AB | Sharpshooter |
| Stone Cold Steve Austin | Austin, TX | Stone Cold Stunner |
| Randy Savage | Sarasota, FL | Elbow Drop |
| Vader | Boulder, CO | Vader Bomb |
| Razor Ramon | Chuluota, FL | Razor's Edge |
我们还需要确保内联代码看起来不错,比如如果我想谈论 `<span>` 元素或者告诉你关于 `@tailwindcss/typography` 的好消息。
### 有时我甚至在标题中使用 `code`
尽管这可能是一个坏主意而且历史上我一直很难让它看起来不错。不过这个_"将代码块包裹在反引号中"_的技巧效果相当不错。
我过去做过的另一件事是在链接中放置一个 `code` 标签,比如如果我想告诉你关于 [`tailwindcss/docs`](https://github.com/tailwindcss/docs) 仓库的事情。我不喜欢反引号下面有下划线,但为了避免它而导致的疯狂绝对不值得。
#### 我们还没有使用 `h4`
但现在我们有了。请不要在你的内容中使用 `h5` 或 `h6`Medium 只支持两个标题级别是有原因的,你们这些动物。我老实说考虑过使用 `before` 伪元素,如果你使用 `h5` 或 `h6` 就对你大喊大叫。
我们根本不会为它们设置样式,因为 `h4` 元素已经很小,与正文大小相同。我们应该怎么处理 `h5`让它比正文更_小_谢谢。
### 不过我们仍然需要考虑堆叠的标题。
#### 让我们确保我们也不会用 `h4` 元素搞砸这个。
呼,运气好的话,我们已经设置了上面这段文字的标题样式,它们看起来相当不错。
让我们在这里添加一个结束段落,这样事情就会以一个相当大小的文本块结束。我无法解释为什么我希望事情以这种方式结束,但我必须假设这是因为我认为如果文档末尾太靠近标题,事情会看起来奇怪或不平衡。
我在这里写的可能已经足够长了,但添加这最后一句话不会有害。
## GitHub 风格的 Markdown
我还添加了对使用 `remark-gfm` 的 GitHub 风格 Markdown 的支持。
使用 `remark-gfm`,我们在 markdown 中获得了一些额外的功能。例如:自动链接文字。
像 www.example.com 或 https://example.com 这样的链接会自动转换为 `a` 标签。
这对电子邮件链接也有效contact@example.com。

View File

@ -1,6 +1,6 @@
--- ---
title: MkSaaS 是什么? title: MkSaaS 是什么?
description: MkSaaS 是构建 AI SaaS 网站的最佳板。 description: MkSaaS 是构建 AI SaaS 网站的最佳代码模板。
image: /images/blog/mksaas-og.png image: /images/blog/mksaas-og.png
date: 2024-11-26T12:00:00.000Z date: 2024-11-26T12:00:00.000Z
published: true published: true

View File

@ -0,0 +1,6 @@
---
slug: company
name: 公司
description: 公司新闻
locale: zh
---

View File

@ -0,0 +1,6 @@
---
slug: guide
name: 指南
description: 使用指南
locale: zh
---

View File

@ -0,0 +1,6 @@
---
slug: news
name: 新闻
description: 最新新闻
locale: zh
---

View File

@ -57,5 +57,14 @@
"backToLogin": "Back to login", "backToLogin": "Back to login",
"checkEmail": "Please check your email inbox" "checkEmail": "Please check your email inbox"
} }
},
"BlogPage": {
"title": "Blog",
"categories": {
"all": "All",
"news": "News",
"guide": "Guide",
"company": "Company"
}
} }
} }

View File

@ -57,5 +57,14 @@
"backToLogin": "返回登录", "backToLogin": "返回登录",
"checkEmail": "请检查您的邮箱" "checkEmail": "请检查您的邮箱"
} }
} },
} "BlogPage": {
"title": "博客",
"categories": {
"all": "全部",
"news": "新闻",
"guide": "指南",
"company": "公司"
}
}
}

View File

@ -0,0 +1,96 @@
import BlogGrid from "@/components/blog/blog-grid";
import EmptyGrid from "@/components/shared/empty-grid";
import CustomPagination from "@/components/shared/pagination";
import { siteConfig } from "@/config/site";
import { POSTS_PER_PAGE } from "@/lib/constants";
import { constructMetadata } from "@/lib/metadata";
import type { Metadata } from "next";
import { allCategories, allPosts } from "content-collections";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata | undefined> {
const category = allCategories.find(
(category) => category.slug === params.slug
);
if (!category) {
console.warn(
`generateMetadata, category not found for slug: ${params.slug}`,
);
return;
}
const ogImageUrl = new URL(`${siteConfig.url}/api/og`);
ogImageUrl.searchParams.append("title", category.name);
ogImageUrl.searchParams.append("description", category.description || "");
ogImageUrl.searchParams.append("type", "Blog Category");
return constructMetadata({
title: `${category.name}`,
description: category.description,
canonicalUrl: `${siteConfig.url}/blog/category/${params.slug}`,
// image: ogImageUrl.toString(),
});
}
export default async function BlogCategoryPage({
params,
searchParams,
}: {
params: { slug: string; locale: string };
searchParams?: { [key: string]: string | string[] | undefined };
}) {
const { page } = searchParams as { [key: string]: string };
const currentPage = page ? Number(page) : 1;
const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
const endIndex = startIndex + POSTS_PER_PAGE;
// Filter posts by category and locale
const filteredPosts = allPosts.filter(
(post) =>
post.published &&
post.locale === params.locale &&
post.categories.some(category => category.slug === params.slug)
);
// Sort posts by date (newest first)
const sortedPosts = [...filteredPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Paginate posts
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
const totalCount = filteredPosts.length;
const totalPages = Math.ceil(totalCount / POSTS_PER_PAGE);
console.log(
"BlogCategoryPage, totalCount",
totalCount,
", totalPages",
totalPages,
);
return (
<div>
{/* when no posts are found */}
{paginatedPosts.length === 0 && <EmptyGrid />}
{/* when posts are found */}
{paginatedPosts.length > 0 && (
<div>
<BlogGrid posts={paginatedPosts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination
routePreix={`/blog/category/${params.slug}`}
totalPages={totalPages}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -1,10 +1,26 @@
import { BlogCategoryFilter } from '@/components/blog/blog-category-filter';
import Container from '@/components/container'; import Container from '@/components/container';
import { HeaderSection } from '@/components/shared/header-section'; import { HeaderSection } from '@/components/shared/header-section';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { allCategories } from 'content-collections';
export default async function BlogListLayout({ interface BlogListLayoutProps extends PropsWithChildren {
params: {
locale: string;
};
}
export default async function BlogListLayout({
children, children,
}: { children: React.ReactNode }) { params
}: BlogListLayoutProps) {
const { locale } = params;
// Filter categories by locale
const categoryList = allCategories.filter(
category => category.locale === locale
);
return ( return (
<div className="mb-16"> <div className="mb-16">
<div className="mt-8 w-full flex flex-col items-center justify-center gap-8"> <div className="mt-8 w-full flex flex-col items-center justify-center gap-8">
@ -14,7 +30,7 @@ export default async function BlogListLayout({
subtitle="Read our latest blog posts about MkSaaS" subtitle="Read our latest blog posts about MkSaaS"
/> />
{/* <BlogCategoryFilter /> */} <BlogCategoryFilter categoryList={categoryList} />
</div> </div>
<Container className="mt-8 px-4"> <Container className="mt-8 px-4">

View File

@ -0,0 +1,5 @@
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
export default function Loading() {
return <BlogGridSkeleton />;
}

View File

@ -1,8 +1,9 @@
import { allPosts } from 'content-collections'; import { allPosts } from 'content-collections';
import { Metadata } from 'next'; import { Metadata } from 'next';
import BlogGrid from '@/components/blog/blog-grid'; import BlogGrid from '@/components/blog/blog-grid';
import Container from '@/components/container'; import EmptyGrid from '@/components/shared/empty-grid';
import { Separator } from '@/components/ui/separator'; import CustomPagination from '@/components/shared/pagination';
import { POSTS_PER_PAGE } from '@/lib/constants';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
@ -12,13 +13,18 @@ export async function generateMetadata(): Promise<Metadata> {
} }
interface BlogPageProps { interface BlogPageProps {
params: Promise<{ params: {
locale: string; locale: string;
}>; };
searchParams?: { [key: string]: string | string[] | undefined };
} }
export default async function BlogPage({ params }: BlogPageProps) { export default async function BlogPage({ params, searchParams }: BlogPageProps) {
const { locale } = await params; const { locale } = params;
const { page } = searchParams as { [key: string]: string };
const currentPage = page ? Number(page) : 1;
const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
const endIndex = startIndex + POSTS_PER_PAGE;
// Filter posts by locale // Filter posts by locale
const localePosts = allPosts.filter( const localePosts = allPosts.filter(
@ -26,16 +32,45 @@ export default async function BlogPage({ params }: BlogPageProps) {
); );
// If no posts found for the current locale, show all published posts // If no posts found for the current locale, show all published posts
const posts = localePosts.length > 0 const filteredPosts = localePosts.length > 0
? localePosts ? localePosts
: allPosts.filter((post) => post.published); : allPosts.filter((post) => post.published);
// Sort posts by date (newest first) // Sort posts by date (newest first)
const sortedPosts = [...posts].sort( const sortedPosts = [...filteredPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
); );
// Paginate posts
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
const totalCount = filteredPosts.length;
const totalPages = Math.ceil(totalCount / POSTS_PER_PAGE);
console.log(
"BlogPage, totalCount",
totalCount,
", totalPages",
totalPages,
);
return ( return (
<BlogGrid posts={sortedPosts} /> <div>
{/* when no posts are found */}
{paginatedPosts.length === 0 && <EmptyGrid />}
{/* when posts are found */}
{paginatedPosts.length > 0 && (
<div>
<BlogGrid posts={paginatedPosts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination
routePreix="/blog"
totalPages={totalPages}
/>
</div>
</div>
)}
</div>
); );
} }

View File

@ -0,0 +1,5 @@
import { Loader2Icon } from "lucide-react";
export default function Loading() {
return <Loader2Icon className="my-32 mx-auto size-6 animate-spin" />;
}

View File

@ -3,7 +3,7 @@ import { getBaseUrl } from "@/lib/urls/get-base-url";
import { getLocaleDate } from "@/lib/utils"; import { getLocaleDate } from "@/lib/utils";
import { Post } from "content-collections"; import { Post } from "content-collections";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import { Link } from "@/i18n/navigation";
interface BlogCardProps { interface BlogCardProps {
post: Post; post: Post;
@ -12,7 +12,6 @@ interface BlogCardProps {
export default function BlogCard({ post }: BlogCardProps) { export default function BlogCard({ post }: BlogCardProps) {
const publishDate = post.date; const publishDate = post.date;
const date = getLocaleDate(publishDate); const date = getLocaleDate(publishDate);
// const postUrlPrefix = "/blog";
const postUrlPrefix = getBaseUrl(); const postUrlPrefix = getBaseUrl();
const postUrl = `${postUrlPrefix}${post.slug}`; const postUrl = `${postUrlPrefix}${post.slug}`;
@ -20,7 +19,7 @@ export default function BlogCard({ post }: BlogCardProps) {
<div className="group cursor-pointer flex flex-col border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden h-full"> <div className="group cursor-pointer flex flex-col border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden h-full">
{/* Image container - fixed aspect ratio */} {/* Image container - fixed aspect ratio */}
<div className="group overflow-hidden relative aspect-[16/9] w-full"> <div className="group overflow-hidden relative aspect-[16/9] w-full">
<Link href={postUrl}> <Link href={postUrl as any}>
{post.image && ( {post.image && (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Image <Image
@ -55,7 +54,7 @@ export default function BlogCard({ post }: BlogCardProps) {
<div> <div>
{/* Post title */} {/* Post title */}
<h3 className="text-lg line-clamp-2 font-medium"> <h3 className="text-lg line-clamp-2 font-medium">
<Link href={postUrl}> <Link href={postUrl as any}>
<span <span
className="bg-gradient-to-r from-green-200 to-green-100 className="bg-gradient-to-r from-green-200 to-green-100
bg-[length:0px_10px] bg-left-bottom bg-no-repeat bg-[length:0px_10px] bg-left-bottom bg-no-repeat
@ -74,7 +73,7 @@ export default function BlogCard({ post }: BlogCardProps) {
<div className="mt-2"> <div className="mt-2">
{post.description && ( {post.description && (
<p className="line-clamp-2 text-sm text-gray-500 dark:text-gray-400"> <p className="line-clamp-2 text-sm text-gray-500 dark:text-gray-400">
<Link href={postUrl}>{post.description}</Link> <Link href={postUrl as any}>{post.description}</Link>
</p> </p>
)} )}
</div> </div>

View File

@ -0,0 +1,24 @@
import Container from "@/components/container";
import { Category } from "content-collections";
import { BlogCategoryListDesktop } from "./blog-category-list-desktop";
import { BlogCategoryListMobile } from "./blog-category-list-mobile";
interface BlogCategoryFilterProps {
categoryList: Category[];
}
export function BlogCategoryFilter({ categoryList }: BlogCategoryFilterProps) {
return (
<section className="w-full">
{/* Desktop View, has Container */}
<Container className="hidden md:block">
<BlogCategoryListDesktop categoryList={categoryList} />
</Container>
{/* Mobile View, no Container */}
<div className="block md:hidden w-full">
<BlogCategoryListMobile categoryList={categoryList} />
</div>
</section>
);
}

View File

@ -0,0 +1,66 @@
"use client";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
import { Category } from "content-collections";
import { Link } from "@/i18n/navigation";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
export type BlogCategoryListDesktopProps = {
categoryList: Category[];
};
export function BlogCategoryListDesktop({
categoryList,
}: BlogCategoryListDesktopProps) {
const { slug } = useParams() as { slug?: string };
const t = useTranslations("BlogPage.categories");
return (
<div>
{/* Desktop View */}
<div className="flex items-center justify-center">
<ToggleGroup
size="sm"
type="single"
value={slug || "All"}
aria-label="Toggle blog category"
className="h-9 overflow-hidden rounded-full border bg-background p-1 *:h-7 *:text-muted-foreground"
>
<ToggleGroupItem
key="All"
value="All"
className={cn(
"rounded-full px-5",
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
"hover:bg-muted hover:text-muted-foreground",
)}
aria-label={"Toggle all blog categories"}
>
<Link href={"/blog"}>
<h2>{t("BlogPage.categories.all")}</h2>
</Link>
</ToggleGroupItem>
{categoryList.map((category) => (
<ToggleGroupItem
key={category.slug}
value={category.slug}
className={cn(
"rounded-full px-5",
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
"hover:bg-muted hover:text-muted-foreground",
)}
aria-label={`Toggle blog category of ${category.name}`}
>
<Link href={`/blog/category/${category.slug}` as any}>
<h2>{category.name}</h2>
</Link>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
"use client";
import { Category } from "content-collections";
import { LayoutListIcon } from "lucide-react";
import { useParams } from "next/navigation";
import { useState } from "react";
import { Drawer } from "vaul";
import FilterItemMobile from "@/components/shared/filter-item-mobile";
export type BlogCategoryListMobileProps = {
categoryList: Category[];
};
export function BlogCategoryListMobile({
categoryList,
}: BlogCategoryListMobileProps) {
const [open, setOpen] = useState(false);
const { slug } = useParams() as { slug?: string };
const selectedCategory = categoryList.find(
(category) => category.slug === slug,
);
const closeDrawer = () => {
setOpen(false);
};
return (
<Drawer.Root open={open} onClose={closeDrawer}>
<Drawer.Trigger
onClick={() => setOpen(true)}
className="flex items-center w-full p-3 border-y text-foreground/90"
>
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
<LayoutListIcon className="size-5" />
<span className="text-sm">Category</span>
</div>
<span className="text-sm">
{selectedCategory?.name ? `${selectedCategory?.name}` : "All"}
</span>
</div>
</Drawer.Trigger>
<Drawer.Overlay
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm"
onClick={closeDrawer}
/>
<Drawer.Portal>
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 overflow-hidden rounded-t-[10px] border bg-background">
<Drawer.Title className="sr-only">Category</Drawer.Title>
<div className="sticky top-0 z-20 flex w-full items-center justify-center bg-inherit">
<div className="my-3 h-1.5 w-16 rounded-full bg-muted-foreground/20" />
</div>
<ul className="mb-14 w-full p-3 text-muted-foreground">
<FilterItemMobile
title="All"
href="/blog"
active={!slug}
clickAction={closeDrawer}
/>
{categoryList.map((item) => (
<FilterItemMobile
key={item.slug}
title={item.name}
href={`/blog/category/${item.slug}`}
active={item.slug === slug}
clickAction={closeDrawer}
/>
))}
</ul>
</Drawer.Content>
<Drawer.Overlay />
</Drawer.Portal>
</Drawer.Root>
);
}

View File

@ -0,0 +1,33 @@
"use client";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/navigation";
interface FilterItemMobileProps {
title: string;
href: string;
active?: boolean;
clickAction?: () => void;
}
export default function FilterItemMobile({
title,
href,
active,
clickAction,
}: FilterItemMobileProps) {
return (
<li className="mb-1 last:mb-0">
<Link
href={href as any}
onClick={clickAction}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-muted",
active && "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{title}
</Link>
</li>
);
}

View File

@ -1 +1 @@
export const POSTS_PER_PAGE = 9; export const POSTS_PER_PAGE = 6;