refactor: enhance content collections with multilingual support
- Update content collections to support locale-based content filtering - Modify blog post, author, and category schemas to include optional locale - Implement dynamic locale detection from file path or fallback to default locale - Update blog components to filter posts by current locale - Remove sample blog posts and categories from default content
This commit is contained in:
parent
9236c15245
commit
43d9c798b5
@ -8,6 +8,7 @@ import remarkGfm from 'remark-gfm';
|
||||
import { createHighlighter } from 'shiki';
|
||||
import path from "path";
|
||||
import { getBaseUrl } from "@/lib/urls/get-base-url";
|
||||
import { LOCALES, DEFAULT_LOCALE } from "@/i18n/routing";
|
||||
|
||||
/**
|
||||
* Content Collections documentation
|
||||
@ -26,11 +27,18 @@ export const authors = defineCollection({
|
||||
schema: (z) => ({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
avatar: z.string()
|
||||
avatar: z.string(),
|
||||
locale: z.enum(LOCALES as [string, ...string[]]).optional()
|
||||
}),
|
||||
transform: async (data, context) => {
|
||||
// Determine the locale from the file path or use the provided locale
|
||||
const pathParts = data._meta.path.split(path.sep);
|
||||
const localeFromPath = LOCALES.includes(pathParts[0]) ? pathParts[0] : null;
|
||||
const locale = data.locale || localeFromPath || DEFAULT_LOCALE;
|
||||
|
||||
return {
|
||||
...data,
|
||||
locale,
|
||||
avatar: getBaseUrl() + data.avatar
|
||||
};
|
||||
}
|
||||
@ -46,25 +54,44 @@ export const categories = defineCollection({
|
||||
schema: (z) => ({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string()
|
||||
})
|
||||
description: z.string(),
|
||||
locale: z.enum(LOCALES as [string, ...string[]]).optional()
|
||||
}),
|
||||
transform: async (data, context) => {
|
||||
// Determine the locale from the file path or use the provided locale
|
||||
const pathParts = data._meta.path.split(path.sep);
|
||||
const localeFromPath = LOCALES.includes(pathParts[0]) ? pathParts[0] : null;
|
||||
const locale = data.locale || localeFromPath || DEFAULT_LOCALE;
|
||||
|
||||
return {
|
||||
...data,
|
||||
locale
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Blog Post collection
|
||||
*
|
||||
* 1. For a blog post file at content/blog/2023/year-review.mdx:
|
||||
* 1. For a blog post file at content/en/blog/2023/year-review.mdx:
|
||||
* locale: en
|
||||
* slug: /blog/2023/year-review
|
||||
* slugAsParams: 2023/year-review
|
||||
*
|
||||
* 2. For a blog post at content/blog/first-post.mdx:
|
||||
* 2. For a blog post at content/en/blog/first-post.mdx:
|
||||
* locale: en
|
||||
* slug: /blog/first-post
|
||||
* slugAsParams: first-post
|
||||
*
|
||||
* 3. For a blog post at content/zh/blog/first-post.mdx:
|
||||
* locale: zh
|
||||
* slug: /blog/first-post
|
||||
* slugAsParams: first-post
|
||||
*/
|
||||
export const posts = defineCollection({
|
||||
name: 'post',
|
||||
directory: 'content',
|
||||
include: '**/blog/*.mdx',
|
||||
include: '**/blog/**/*.mdx',
|
||||
schema: (z) => ({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
@ -72,7 +99,8 @@ export const posts = defineCollection({
|
||||
date: z.string().datetime(),
|
||||
published: z.boolean().default(true),
|
||||
categories: z.array(z.string()),
|
||||
author: z.string()
|
||||
author: z.string(),
|
||||
locale: z.enum(LOCALES as [string, ...string[]]).optional()
|
||||
}),
|
||||
transform: async (data, context) => {
|
||||
const body = await compileMDX(context, data, {
|
||||
@ -92,13 +120,32 @@ export const posts = defineCollection({
|
||||
const blogCategories = context
|
||||
.documents(categories)
|
||||
.filter((c) => data.categories.includes(c.slug));
|
||||
|
||||
// Determine the locale from the file path or use the provided locale
|
||||
const pathParts = data._meta.path.split(path.sep);
|
||||
const localeFromPath = LOCALES.includes(pathParts[0]) ? pathParts[0] : null;
|
||||
const locale = data.locale || localeFromPath || DEFAULT_LOCALE;
|
||||
|
||||
// Create a slug without the locale in the path
|
||||
let slugPath = data._meta.path;
|
||||
if (localeFromPath) {
|
||||
// Remove the locale from the path for the slug
|
||||
const pathWithoutLocale = pathParts.slice(1).join(path.sep);
|
||||
slugPath = pathWithoutLocale;
|
||||
}
|
||||
|
||||
// Create slugAsParams without the locale
|
||||
const slugParamsParts = slugPath.split(path.sep).slice(1);
|
||||
const slugAsParams = slugParamsParts.join('/');
|
||||
|
||||
return {
|
||||
...data,
|
||||
locale,
|
||||
author: blogAuthor,
|
||||
categories: blogCategories,
|
||||
image: getBaseUrl() + data.image,
|
||||
slug: `/${data._meta.path}`,
|
||||
slugAsParams: data._meta.path.split(path.sep).slice(1).join('/'),
|
||||
slug: `/${slugPath}`,
|
||||
slugAsParams,
|
||||
body: {
|
||||
raw: data.content,
|
||||
code: body
|
||||
|
@ -63,12 +63,12 @@ Something a wise person once told me about typography is:
|
||||
|
||||
It's probably important that images look okay here by default as well:
|
||||
|
||||
<Image
|
||||
{/* <Image
|
||||
src="/images/blog/indiehub-og.png"
|
||||
width="718"
|
||||
height="404"
|
||||
alt="Image"
|
||||
/>
|
||||
/> */}
|
||||
|
||||
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old.
|
||||
|
@ -63,12 +63,12 @@ Something a wise person once told me about typography is:
|
||||
|
||||
It's probably important that images look okay here by default as well:
|
||||
|
||||
<Image
|
||||
{/* <Image
|
||||
src="/images/blog/mkdirs-og.png"
|
||||
width="718"
|
||||
height="404"
|
||||
alt="Image"
|
||||
/>
|
||||
/> */}
|
||||
|
||||
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old.
|
||||
|
@ -6,6 +6,7 @@ date: 2024-11-24T12:00:00.000Z
|
||||
published: true
|
||||
categories: [news, guide]
|
||||
author: mksaas
|
||||
locale: en
|
||||
---
|
||||
|
||||
Until now, trying to style an article, document, or blog post with Tailwind has been a tedious task that required a keen eye for typography and a lot of complex custom CSS.
|
||||
@ -63,12 +64,12 @@ Something a wise person once told me about typography is:
|
||||
|
||||
It's probably important that images look okay here by default as well:
|
||||
|
||||
<Image
|
||||
{/* <Image
|
||||
src="/images/blog/mksaas-og.png"
|
||||
width="718"
|
||||
height="404"
|
||||
alt="Image"
|
||||
/>
|
||||
/> */}
|
||||
|
||||
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old.
|
||||
|
211
content/zh/blog/what-is-mksaas.mdx
Normal file
211
content/zh/blog/what-is-mksaas.mdx
Normal file
@ -0,0 +1,211 @@
|
||||
---
|
||||
title: MkSaaS 是什么?
|
||||
description: MkSaaS 是构建 AI SaaS 网站的最佳样板。
|
||||
image: /images/blog/mksaas-og.png
|
||||
date: 2024-11-24T12:00:00.000Z
|
||||
published: true
|
||||
categories: [news, guide]
|
||||
author: mksaas
|
||||
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。
|
Binary file not shown.
Before Width: | Height: | Size: 646 KiB After Width: | Height: | Size: 176 KiB |
5
src/app/[locale]/(marketing)/blog/(blog)/layout.tsx
Normal file
5
src/app/[locale]/(marketing)/blog/(blog)/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
export default function BlogLayout({ children }: PropsWithChildren) {
|
||||
return children;
|
||||
}
|
55
src/app/[locale]/(marketing)/blog/(blog)/page.tsx
Normal file
55
src/app/[locale]/(marketing)/blog/(blog)/page.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { allPosts } from 'content-collections';
|
||||
import { Metadata } from 'next';
|
||||
import BlogGrid from '@/components/blog/blog-grid';
|
||||
import Container from '@/components/container';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: 'Blog',
|
||||
description: 'Latest news and updates from our team',
|
||||
};
|
||||
}
|
||||
|
||||
interface BlogPageProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function BlogPage({ params }: BlogPageProps) {
|
||||
const { locale } = await params;
|
||||
|
||||
// Filter posts by locale
|
||||
const localePosts = allPosts.filter(
|
||||
(post) => post.locale === locale && post.published
|
||||
);
|
||||
|
||||
// If no posts found for the current locale, show all published posts
|
||||
const posts = localePosts.length > 0
|
||||
? localePosts
|
||||
: allPosts.filter((post) => post.published);
|
||||
|
||||
// Sort posts by date (newest first)
|
||||
const sortedPosts = [...posts].sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<Container className="py-8 md:py-12">
|
||||
<div className="">
|
||||
<div className="flex flex-col items-start gap-4 md:flex-row md:justify-between md:gap-8">
|
||||
<div className="flex-1 space-y-4">
|
||||
<h1 className="inline-block text-4xl font-bold tracking-tight lg:text-5xl">
|
||||
Blog
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Latest news and updates from our team
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<BlogGrid posts={sortedPosts} />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
13
src/app/[locale]/(marketing)/blog/[...slug]/layout.tsx
Normal file
13
src/app/[locale]/(marketing)/blog/[...slug]/layout.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import Container from '@/components/container';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
export default function BlogPostLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Container className="py-8 md:py-12">
|
||||
<div className="mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -8,8 +8,9 @@ import type { NextPageProps } from '@/types/next-page-props';
|
||||
import { allPosts } from 'content-collections';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import '@/styles/mdx.css';
|
||||
|
||||
@ -19,30 +20,43 @@ import '@/styles/mdx.css';
|
||||
* @returns The blog post
|
||||
*
|
||||
* How it works:
|
||||
* 1. /blog/first-post:
|
||||
* 1. /[locale]/blog/first-post:
|
||||
* params.slug = ["first-post"]
|
||||
* slug becomes "first-post" after join('/')
|
||||
* Matches post where slugAsParams === "first-post"
|
||||
* Matches post where slugAsParams === "first-post" AND locale === params.locale
|
||||
*
|
||||
* 2. /blog/2023/year-review:
|
||||
* 2. /[locale]/blog/2023/year-review:
|
||||
* params.slug = ["2023", "year-review"]
|
||||
* slug becomes "2023/year-review" after join('/')
|
||||
* Matches post where slugAsParams === "2023/year-review"
|
||||
* Matches post where slugAsParams === "2023/year-review" AND locale === params.locale
|
||||
*/
|
||||
async function getBlogPostFromParams(props: NextPageProps) {
|
||||
const params = await props.params;
|
||||
if (!params) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const locale = params.locale as string;
|
||||
const slug =
|
||||
(Array.isArray(params.slug) ? params.slug?.join('/') : params.slug) || '';
|
||||
|
||||
// Find post with matching slug and locale
|
||||
const post = allPosts.find(
|
||||
(post) =>
|
||||
post.slugAsParams === slug || (!slug && post.slugAsParams === 'index')
|
||||
(post) =>
|
||||
(post.slugAsParams === slug || (!slug && post.slugAsParams === 'index')) &&
|
||||
post.locale === locale
|
||||
);
|
||||
|
||||
if (!post) {
|
||||
return null;
|
||||
// If no post found with the current locale, try to find one with the default locale
|
||||
const defaultPost = allPosts.find(
|
||||
(post) =>
|
||||
(post.slugAsParams === slug || (!slug && post.slugAsParams === 'index'))
|
||||
);
|
||||
|
||||
return defaultPost;
|
||||
}
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
@ -66,27 +80,14 @@ export async function generateMetadata(
|
||||
};
|
||||
}
|
||||
|
||||
// export async function generateStaticParams() {
|
||||
// return allPosts.map((post) => ({
|
||||
// slug: post.slugAsParams.split('/')
|
||||
// }));
|
||||
// }
|
||||
|
||||
export default async function BlogPostPage(props: NextPageProps) {
|
||||
const post = await getBlogPostFromParams(props);
|
||||
if (!post) {
|
||||
return notFound();
|
||||
}
|
||||
// return <BlogPost post={post} />;
|
||||
|
||||
// console.log("PostPage, post", post);
|
||||
// const imageProps = post?.image ? urlForImage(post?.image) : null;
|
||||
// const imageBlurDataURL = post?.image?.blurDataURL || null;
|
||||
const publishDate = post.date;
|
||||
const date = getLocaleDate(publishDate);
|
||||
// const markdownContent = portableTextToMarkdown(post.body);
|
||||
// console.log("markdownContent", markdownContent);
|
||||
|
||||
const toc = await getTableOfContents(post.content);
|
||||
|
||||
return (
|
||||
@ -121,7 +122,6 @@ export default async function BlogPostPage(props: NextPageProps) {
|
||||
{/* blog post content */}
|
||||
<div className="mt-4 font-serif">
|
||||
<Mdx code={post.body.code} />
|
||||
{/* {markdownContent && <BlogCustomMdx source={markdownContent} />} */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start mt-16">
|
||||
@ -161,7 +161,10 @@ export default async function BlogPostPage(props: NextPageProps) {
|
||||
{post.categories?.map((category) => (
|
||||
<li key={category.slug}>
|
||||
<Link
|
||||
href={`/blog/category/${category.slug}`}
|
||||
href={{
|
||||
pathname: "/blog/category/[slug]",
|
||||
params: { slug: category.slug }
|
||||
}}
|
||||
className="text-sm link-underline"
|
||||
>
|
||||
{category.name}
|
||||
@ -181,20 +184,6 @@ export default async function BlogPostPage(props: NextPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer section shows related posts */}
|
||||
{/* {post.relatedPosts && post.relatedPosts.length > 0 && (
|
||||
<div className="flex flex-col gap-8 mt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileTextIcon className="w-4 h-4 text-indigo-500" />
|
||||
<h2 className="text-lg tracking-wider font-semibold text-gradient_indigo-purple">
|
||||
More Posts
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<BlogGrid posts={post.relatedPosts} />
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
|
||||
|
||||
export default function Loading() {
|
||||
return <BlogGridSkeleton />;
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import Container from "@/components/container";
|
||||
import { HeaderSection } from "@/components/shared/header-section";
|
||||
|
||||
export default async function BlogListLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-16">
|
||||
<div className="mt-8 w-full flex flex-col items-center justify-center gap-8">
|
||||
<HeaderSection
|
||||
labelAs="h1"
|
||||
label="Blog"
|
||||
titleAs="h2"
|
||||
title="Read our latest blog posts"
|
||||
subtitle="Working in progress, will write more helpful posts later"
|
||||
/>
|
||||
|
||||
{/* <BlogCategoryFilter /> */}
|
||||
</div>
|
||||
|
||||
<Container className="mt-8">{children}</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
|
||||
|
||||
export default function Loading() {
|
||||
return <BlogGridSkeleton />;
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
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 { NextPageProps } from "@/types/next-page-props";
|
||||
import { allPosts } from "content-collections";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Blog",
|
||||
description: "Read our latest blog posts",
|
||||
canonicalUrl: `${siteConfig.url}/blog`,
|
||||
});
|
||||
|
||||
export default async function BlogIndexPage(props: NextPageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
console.log("BlogIndexPage, searchParams", searchParams);
|
||||
const page = typeof searchParams?.page === 'string' ? Number(searchParams.page) : 1;
|
||||
const posts = await Promise.all(
|
||||
allPosts
|
||||
.filter((post) => post.published)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.slice( (page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE, )
|
||||
// .map(async (post) => ({
|
||||
// ...post,
|
||||
// blurDataURL: await getBlurDataURL(post.image),
|
||||
// })),
|
||||
);
|
||||
// const posts = allPosts.slice(
|
||||
// (currentPage - 1) * POSTS_PER_PAGE,
|
||||
// currentPage * POSTS_PER_PAGE,
|
||||
// );
|
||||
const totalCount = allPosts.length;
|
||||
const totalPages = Math.ceil(totalCount / POSTS_PER_PAGE);
|
||||
|
||||
console.log(
|
||||
"BlogIndexPage, totalCount",
|
||||
totalCount,
|
||||
", totalPages",
|
||||
totalPages,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* when no posts are found */}
|
||||
{posts?.length === 0 && <EmptyGrid />}
|
||||
|
||||
{/* when posts are found */}
|
||||
{posts && posts?.length > 0 && (
|
||||
<div>
|
||||
<BlogGrid posts={posts} />
|
||||
|
||||
<div className="mt-8 flex items-center justify-center">
|
||||
<CustomPagination routePreix="/blog" totalPages={totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
import Container from "@/components/container";
|
||||
import { NewsletterCard } from "@/components/newsletter/newsletter-card";
|
||||
import type React from "react";
|
||||
|
||||
export default function BlogPostLayout({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-16">
|
||||
<Container className="mt-8">{children}</Container>
|
||||
|
||||
<Container className="mt-16">
|
||||
<NewsletterCard />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Content section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left column */}
|
||||
<div className="lg:col-span-2 flex flex-col">
|
||||
{/* Basic information */}
|
||||
<div className="space-y-8">
|
||||
{/* blog post image */}
|
||||
<Skeleton className="w-full aspect-[16/9] rounded-lg" />
|
||||
|
||||
{/* blog post title */}
|
||||
<Skeleton className="h-12 w-1/2" />
|
||||
|
||||
{/* blog post description */}
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
|
||||
{/* blog post content */}
|
||||
<div className="mt-8 space-y-4">
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start mt-16">
|
||||
{/* <AllPostsButton /> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column (sidebar) */}
|
||||
<div>
|
||||
<div className="space-y-4 lg:sticky lg:top-24">
|
||||
{/* author info */}
|
||||
<div className="bg-muted/50 rounded-lg p-6">
|
||||
<Skeleton className="h-8 w-24 mb-4" />
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div>
|
||||
<Skeleton className="h-8 w-32 mb-2" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* categories */}
|
||||
<div className="bg-muted/50 rounded-lg p-6">
|
||||
<Skeleton className="h-8 w-24 mb-4" />
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<Skeleton key={index} className="h-6 w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* table of contents */}
|
||||
<div className="bg-muted/50 rounded-lg p-6 hidden lg:block">
|
||||
<Skeleton className="h-8 w-40 mb-4" />
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<Skeleton key={index} className="h-6 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer section shows related posts */}
|
||||
<div className="flex flex-col gap-8 mt-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-6" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
|
||||
<BlogGridSkeleton count={3} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
export default function AllPostsButton() {
|
||||
return (
|
||||
@ -12,7 +12,7 @@ export default function AllPostsButton() {
|
||||
className="inline-flex items-center gap-2 group"
|
||||
asChild
|
||||
>
|
||||
<Link href="/blog" prefetch={false}>
|
||||
<Link href="/blog">
|
||||
<ArrowLeftIcon
|
||||
className="w-5 h-5
|
||||
transition-transform duration-200 group-hover:-translate-x-1"
|
||||
|
@ -5,9 +5,9 @@ import { Post } from "content-collections";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
type BlogCardProps = {
|
||||
interface BlogCardProps {
|
||||
post: Post;
|
||||
};
|
||||
}
|
||||
|
||||
export default function BlogCard({ post }: BlogCardProps) {
|
||||
const publishDate = post.date;
|
||||
|
@ -9,8 +9,17 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { FillRemainingSpace } from '@/components/fill-remaining-space';
|
||||
import { getBaseUrl } from '@/lib/urls/get-base-url';
|
||||
import { getInitials } from '@/lib/utils';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
export function BlogPosts(): React.JSX.Element {
|
||||
const locale = useLocale();
|
||||
|
||||
// Filter posts by current locale
|
||||
const localizedPosts = allPosts
|
||||
.filter(post => post.published && post.locale === locale)
|
||||
.slice()
|
||||
.sort((a, b) => (isBefore(a.date, b.date) ? 1 : -1));
|
||||
|
||||
return (
|
||||
<GridSection>
|
||||
<div className="container space-y-20 py-20">
|
||||
@ -20,11 +29,7 @@ export function BlogPosts(): React.JSX.Element {
|
||||
description="Learn more about our products and the latest news."
|
||||
/>
|
||||
<div className="grid gap-x-12 gap-y-6 divide-y md:grid-cols-2 md:gap-x-6 md:divide-none xl:grid-cols-3">
|
||||
{allPosts
|
||||
.filter((post) => post.published)
|
||||
.slice()
|
||||
.sort((a, b) => (isBefore(a.date, b.date) ? 1 : -1))
|
||||
.map((post, index) => (
|
||||
{localizedPosts.map((post, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={`${getBaseUrl()}${post.slug}`}
|
||||
|
Loading…
Reference in New Issue
Block a user