diff --git a/apps/website/app/[locale]/blog/components/SearchAndFilter.tsx b/apps/website/app/[locale]/blog/components/SearchAndFilter.tsx new file mode 100644 index 0000000..9f2ddef --- /dev/null +++ b/apps/website/app/[locale]/blog/components/SearchAndFilter.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useDebounce } from "@/lib/hooks/use-debounce"; +import { Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useTransition } from "react"; + +interface Tag { + id: string; + name: string; + slug: string; +} + +interface SearchAndFilterProps { + tags: Tag[]; + initialSearch: string; + initialTag: string; + searchPlaceholder: string; + allTagsText: string; +} + +const ALL_TAGS_VALUE = "all"; + +export function SearchAndFilter({ + tags, + initialSearch, + initialTag, + searchPlaceholder, + allTagsText, +}: SearchAndFilterProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const handleTagChange = (value: string) => { + const searchParams = new URLSearchParams(window.location.search); + if (value && value !== ALL_TAGS_VALUE) { + searchParams.set("tag", value); + } else { + searchParams.delete("tag"); + } + startTransition(() => { + router.push(`?${searchParams.toString()}`); + }); + }; + + const debouncedCallback = useDebounce((value: string) => { + const searchParams = new URLSearchParams(window.location.search); + if (value) { + searchParams.set("search", value); + } else { + searchParams.delete("search"); + } + startTransition(() => { + router.push(`?${searchParams.toString()}`); + }); + }, 300); + + const handleSearch = useCallback( + (e: React.ChangeEvent) => { + debouncedCallback(e.target.value); + }, + [debouncedCallback], + ); + + return ( +
+
+
+ +
+ +
+
+ +
+
+ ); +} diff --git a/apps/website/app/[locale]/blog/page.tsx b/apps/website/app/[locale]/blog/page.tsx index 51c31b1..9a1f478 100644 --- a/apps/website/app/[locale]/blog/page.tsx +++ b/apps/website/app/[locale]/blog/page.tsx @@ -1,9 +1,17 @@ -import { getPosts } from "@/lib/ghost"; +import { getPosts, getTags } from "@/lib/ghost"; import type { Post } from "@/lib/ghost"; +import { Search } from "lucide-react"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; import Link from "next/link"; +import { SearchAndFilter } from "./components/SearchAndFilter"; + +interface Tag { + id: string; + name: string; + slug: string; +} export const metadata: Metadata = { title: "Blog | Dokploy", @@ -14,25 +22,59 @@ export const revalidate = 3600; // Revalidate the data at most every hour export default async function BlogPage({ params: { locale }, + searchParams, }: { params: { locale: string }; + searchParams: { [key: string]: string | string[] | undefined }; }) { const t = await getTranslations({ locale, namespace: "blog" }); const posts = await getPosts(); + const tags = (await getTags()) as Tag[]; + const search = + typeof searchParams.search === "string" ? searchParams.search : ""; + const selectedTag = + typeof searchParams.tag === "string" ? searchParams.tag : ""; + + const filteredPosts = posts.filter((post) => { + const matchesSearch = + search === "" || + post.title.toLowerCase().includes(search.toLowerCase()) || + post.excerpt.toLowerCase().includes(search.toLowerCase()); + + const matchesTag = + selectedTag === "" || post.tags?.some((tag) => tag.slug === selectedTag); + + return matchesSearch && matchesTag; + }); return ( -
-

{t("title")}

+
+
+

{t("title")}

+
+ + Welcome to the blog +
+
- {posts.length === 0 ? ( -
-

- {t("noPosts")} + + + {filteredPosts.length === 0 ? ( +

+

+ {search || selectedTag ? t("noResults") : t("noPosts")} +

) : (
- {posts.map((post: Post) => ( + {filteredPosts.map((post: Post) => ( ))}
diff --git a/apps/website/lib/hooks/use-debounce.ts b/apps/website/lib/hooks/use-debounce.ts new file mode 100644 index 0000000..a0f1c0c --- /dev/null +++ b/apps/website/lib/hooks/use-debounce.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; + +export function useDebounce any>( + callback: T, + delay: number, +): T { + const timeoutRef = useRef(); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return ((...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + return new Promise>((resolve) => { + timeoutRef.current = setTimeout(() => { + resolve(callback(...args)); + }, delay); + }); + }) as T; +} diff --git a/apps/website/locales/en.json b/apps/website/locales/en.json index 2f2ab1f..070374b 100644 --- a/apps/website/locales/en.json +++ b/apps/website/locales/en.json @@ -199,14 +199,16 @@ "blog": { "title": "Blog", "description": "Latest news, updates, and articles from Dokploy", - "noPosts": "No posts found. Check back later for new content.", - "readMore": "Read more", - "backToBlog": "Back to Blog", - "tags": "Tags", - "postsTaggedWith": "Posts tagged with", + "noPosts": "No posts available", + "noResults": "No posts found matching your criteria", + "searchPlaceholder": "Search posts...", + "allTags": "All Tags", + "relatedPosts": "Related Posts", + "tagDescription": "Posts tagged with", "foundPosts": "{count, plural, =0 {No posts found} one {# post found} other {# posts found}}", "tagTitle": "Posts tagged with {tag}", - "tagDescription": "Browse all blog posts tagged with {tag}", - "relatedPosts": "Related Posts" + "backToBlog": "Back to Blog", + "tags": "Tags", + "postsTaggedWith": "Posts tagged with" } } diff --git a/apps/website/locales/fr.json b/apps/website/locales/fr.json index 14274b9..bee943f 100644 --- a/apps/website/locales/fr.json +++ b/apps/website/locales/fr.json @@ -198,14 +198,11 @@ "blog": { "title": "Blog", "description": "Dernières nouvelles, mises à jour et articles de Dokploy", - "noPosts": "Aucun article trouvé. Revenez plus tard pour du nouveau contenu.", - "readMore": "Lire la suite", - "backToBlog": "Retour au Blog", - "tags": "Tags", - "postsTaggedWith": "Articles tagués avec", - "foundPosts": "{count, plural, =0 {Aucun article trouvé} one {# article trouvé} other {# articles trouvés}}", - "tagTitle": "Articles tagués avec {tag}", - "tagDescription": "Parcourir tous les articles de blog tagués avec {tag}", - "relatedPosts": "Articles similaires" + "noPosts": "Aucun article disponible", + "noResults": "Aucun article ne correspond à vos critères", + "searchPlaceholder": "Rechercher des articles...", + "allTags": "Tous les tags", + "relatedPosts": "Articles similaires", + "tagDescription": "Articles tagués avec" } } diff --git a/apps/website/locales/zh-Hans.json b/apps/website/locales/zh-Hans.json index aa201f8..0d55e2b 100644 --- a/apps/website/locales/zh-Hans.json +++ b/apps/website/locales/zh-Hans.json @@ -203,14 +203,17 @@ "blog": { "title": "博客", "description": "Dokploy 的最新消息、更新和文章", - "noPosts": "未找到文章。稍后再来查看新内容。", + "noPosts": "暂无文章", + "noResults": "没有找到符合条件的文章", + "searchPlaceholder": "搜索文章...", + "allTags": "所有标签", + "relatedPosts": "相关文章", + "tagDescription": "标签文章", "readMore": "阅读更多", "backToBlog": "返回博客", "tags": "标签", "postsTaggedWith": "标签为", "foundPosts": "{count, plural, =0 {未找到文章} other {找到 # 篇文章}}", - "tagTitle": "标签为 {tag} 的文章", - "tagDescription": "浏览所有标签为 {tag} 的博客文章", - "relatedPosts": "相关文章" + "tagTitle": "标签为 {tag} 的文章" } }