feat: add blog search and tag filtering functionality

This commit is contained in:
Mauricio Siu
2025-02-27 23:53:45 -06:00
parent 8868d7f586
commit 120982b85e
6 changed files with 206 additions and 28 deletions

View File

@@ -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<HTMLInputElement>) => {
debouncedCallback(e.target.value);
},
[debouncedCallback],
);
return (
<div className="flex flex-col md:flex-row gap-4 mb-8">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
defaultValue={initialSearch}
onChange={handleSearch}
placeholder={searchPlaceholder}
className="w-full pl-10 pr-4 py-2 border border-border rounded-md bg-background ring-offset-background placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
<div className="w-full md:w-64">
<Select
defaultValue={initialTag || ALL_TAGS_VALUE}
onValueChange={handleTagChange}
>
<SelectTrigger>
<SelectValue placeholder={allTagsText} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_TAGS_VALUE}>{allTagsText}</SelectItem>
{tags.map((tag) => (
<SelectItem key={tag.id} value={tag.slug}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -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 (
<div className="container mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">{t("title")}</h1>
<div className="container mx-auto px-4 py-12 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-4xl font-bold">{t("title")}</h1>
<div className="flex items-center gap-2">
<Search className="size-5" />
Welcome to the blog
</div>
</div>
{posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-xl text-gray-600 dark:text-gray-400">
{t("noPosts")}
<SearchAndFilter
tags={tags}
initialSearch={search}
initialTag={selectedTag}
searchPlaceholder={t("searchPlaceholder")}
allTagsText={t("allTags")}
/>
{filteredPosts.length === 0 ? (
<div className="text-center py-12 min-h-[35vh] items-center justify-center flex">
<p className="text-xl flex items-center gap-2">
{search || selectedTag ? t("noResults") : t("noPosts")}
<Search className="size-5" />
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post: Post) => (
{filteredPosts.map((post: Post) => (
<BlogPostCard key={post.id} post={post} locale={locale} />
))}
</div>

View File

@@ -0,0 +1,28 @@
import { useEffect, useRef } from "react";
export function useDebounce<T extends (...args: any[]) => any>(
callback: T,
delay: number,
): T {
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return ((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
return new Promise<ReturnType<T>>((resolve) => {
timeoutRef.current = setTimeout(() => {
resolve(callback(...args));
}, delay);
});
}) as T;
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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} 的文章"
}
}