mirror of
https://github.com/Dokploy/website
synced 2025-06-26 18:16:01 +00:00
feat: add blog search and tag filtering functionality
This commit is contained in:
106
apps/website/app/[locale]/blog/components/SearchAndFilter.tsx
Normal file
106
apps/website/app/[locale]/blog/components/SearchAndFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
28
apps/website/lib/hooks/use-debounce.ts
Normal file
28
apps/website/lib/hooks/use-debounce.ts
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} 的文章"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user