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 type { Post } from "@/lib/ghost";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { SearchAndFilter } from "./components/SearchAndFilter";
|
||||||
|
|
||||||
|
interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Blog | Dokploy",
|
title: "Blog | Dokploy",
|
||||||
@@ -14,25 +22,59 @@ export const revalidate = 3600; // Revalidate the data at most every hour
|
|||||||
|
|
||||||
export default async function BlogPage({
|
export default async function BlogPage({
|
||||||
params: { locale },
|
params: { locale },
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: { locale: string };
|
params: { locale: string };
|
||||||
|
searchParams: { [key: string]: string | string[] | undefined };
|
||||||
}) {
|
}) {
|
||||||
const t = await getTranslations({ locale, namespace: "blog" });
|
const t = await getTranslations({ locale, namespace: "blog" });
|
||||||
const posts = await getPosts();
|
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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-12">
|
<div className="container mx-auto px-4 py-12 flex flex-col gap-4">
|
||||||
<h1 className="text-4xl font-bold mb-8">{t("title")}</h1>
|
<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 ? (
|
<SearchAndFilter
|
||||||
<div className="text-center py-12">
|
tags={tags}
|
||||||
<p className="text-xl text-gray-600 dark:text-gray-400">
|
initialSearch={search}
|
||||||
{t("noPosts")}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<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} />
|
<BlogPostCard key={post.id} post={post} locale={locale} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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": {
|
"blog": {
|
||||||
"title": "Blog",
|
"title": "Blog",
|
||||||
"description": "Latest news, updates, and articles from Dokploy",
|
"description": "Latest news, updates, and articles from Dokploy",
|
||||||
"noPosts": "No posts found. Check back later for new content.",
|
"noPosts": "No posts available",
|
||||||
"readMore": "Read more",
|
"noResults": "No posts found matching your criteria",
|
||||||
"backToBlog": "Back to Blog",
|
"searchPlaceholder": "Search posts...",
|
||||||
"tags": "Tags",
|
"allTags": "All Tags",
|
||||||
"postsTaggedWith": "Posts tagged with",
|
"relatedPosts": "Related Posts",
|
||||||
|
"tagDescription": "Posts tagged with",
|
||||||
"foundPosts": "{count, plural, =0 {No posts found} one {# post found} other {# posts found}}",
|
"foundPosts": "{count, plural, =0 {No posts found} one {# post found} other {# posts found}}",
|
||||||
"tagTitle": "Posts tagged with {tag}",
|
"tagTitle": "Posts tagged with {tag}",
|
||||||
"tagDescription": "Browse all blog posts tagged with {tag}",
|
"backToBlog": "Back to Blog",
|
||||||
"relatedPosts": "Related Posts"
|
"tags": "Tags",
|
||||||
|
"postsTaggedWith": "Posts tagged with"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,14 +198,11 @@
|
|||||||
"blog": {
|
"blog": {
|
||||||
"title": "Blog",
|
"title": "Blog",
|
||||||
"description": "Dernières nouvelles, mises à jour et articles de Dokploy",
|
"description": "Dernières nouvelles, mises à jour et articles de Dokploy",
|
||||||
"noPosts": "Aucun article trouvé. Revenez plus tard pour du nouveau contenu.",
|
"noPosts": "Aucun article disponible",
|
||||||
"readMore": "Lire la suite",
|
"noResults": "Aucun article ne correspond à vos critères",
|
||||||
"backToBlog": "Retour au Blog",
|
"searchPlaceholder": "Rechercher des articles...",
|
||||||
"tags": "Tags",
|
"allTags": "Tous les tags",
|
||||||
"postsTaggedWith": "Articles tagués avec",
|
"relatedPosts": "Articles similaires",
|
||||||
"foundPosts": "{count, plural, =0 {Aucun article trouvé} one {# article trouvé} other {# articles trouvés}}",
|
"tagDescription": "Articles tagués avec"
|
||||||
"tagTitle": "Articles tagués avec {tag}",
|
|
||||||
"tagDescription": "Parcourir tous les articles de blog tagués avec {tag}",
|
|
||||||
"relatedPosts": "Articles similaires"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,14 +203,17 @@
|
|||||||
"blog": {
|
"blog": {
|
||||||
"title": "博客",
|
"title": "博客",
|
||||||
"description": "Dokploy 的最新消息、更新和文章",
|
"description": "Dokploy 的最新消息、更新和文章",
|
||||||
"noPosts": "未找到文章。稍后再来查看新内容。",
|
"noPosts": "暂无文章",
|
||||||
|
"noResults": "没有找到符合条件的文章",
|
||||||
|
"searchPlaceholder": "搜索文章...",
|
||||||
|
"allTags": "所有标签",
|
||||||
|
"relatedPosts": "相关文章",
|
||||||
|
"tagDescription": "标签文章",
|
||||||
"readMore": "阅读更多",
|
"readMore": "阅读更多",
|
||||||
"backToBlog": "返回博客",
|
"backToBlog": "返回博客",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
"postsTaggedWith": "标签为",
|
"postsTaggedWith": "标签为",
|
||||||
"foundPosts": "{count, plural, =0 {未找到文章} other {找到 # 篇文章}}",
|
"foundPosts": "{count, plural, =0 {未找到文章} other {找到 # 篇文章}}",
|
||||||
"tagTitle": "标签为 {tag} 的文章",
|
"tagTitle": "标签为 {tag} 的文章"
|
||||||
"tagDescription": "浏览所有标签为 {tag} 的博客文章",
|
|
||||||
"relatedPosts": "相关文章"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user