feat(messages): add search and sort functionality to category messages

This commit is contained in:
Fatih Kadir Akın
2026-01-02 20:51:13 +03:00
parent 48e6762fa0
commit a694646cd7
18 changed files with 253 additions and 33 deletions
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "مشتركين",
"promptCount": "{count} أوامر",
"subscriberCount": "{count} مشتركين",
"confirmDelete": "هل أنت متأكد من حذف هذا التصنيف؟"
"confirmDelete": "هل أنت متأكد من حذف هذا التصنيف؟",
"searchPlaceholder": "البحث في الأوامر...",
"sort": {
"newest": "الأحدث",
"oldest": "الأقدم",
"most_upvoted": "الأكثر تصويتاً",
"most_contributors": "الأكثر مساهمة"
}
},
"tags": {
"title": "الوسوم",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "abunəçi",
"promptCount": "{count} prompt",
"subscriberCount": "{count} abunəçi",
"confirmDelete": "Bu kateqoriyanı silmək istədiyinizə əminsiniz?"
"confirmDelete": "Bu kateqoriyanı silmək istədiyinizə əminsiniz?",
"searchPlaceholder": "Promptları axtar...",
"sort": {
"newest": "Ən yeni",
"oldest": "Ən köhnə",
"most_upvoted": "Ən çox səs alan",
"most_contributors": "Ən çox töhfə verən"
}
},
"tags": {
"title": "Etiketlər",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "Abonnenten",
"promptCount": "{count} Prompts",
"subscriberCount": "{count} Abonnenten",
"confirmDelete": "Sind Sie sicher, dass Sie diese Kategorie löschen möchten?"
"confirmDelete": "Sind Sie sicher, dass Sie diese Kategorie löschen möchten?",
"searchPlaceholder": "Prompts suchen...",
"sort": {
"newest": "Neueste",
"oldest": "Älteste",
"most_upvoted": "Meiste Stimmen",
"most_contributors": "Meiste Mitwirkende"
}
},
"tags": {
"title": "Tags",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "συνδρομητές",
"promptCount": "{count} prompts",
"subscriberCount": "{count} συνδρομητές",
"confirmDelete": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτή την κατηγορία;"
"confirmDelete": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτή την κατηγορία;",
"searchPlaceholder": "Αναζήτηση prompts...",
"sort": {
"newest": "Νεότερα",
"oldest": "Παλαιότερα",
"most_upvoted": "Περισσότερες ψήφοι",
"most_contributors": "Περισσότεροι συνεισφέροντες"
}
},
"tags": {
"title": "Ετικέτες",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "subscribers",
"promptCount": "{count} prompts",
"subscriberCount": "{count} subscribers",
"confirmDelete": "Are you sure you want to delete this category?"
"confirmDelete": "Are you sure you want to delete this category?",
"searchPlaceholder": "Search prompts...",
"sort": {
"newest": "Newest",
"oldest": "Oldest",
"most_upvoted": "Most Upvoted",
"most_contributors": "Most Contributors"
}
},
"tags": {
"title": "Tags",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "suscriptores",
"promptCount": "{count} prompts",
"subscriberCount": "{count} suscriptores",
"confirmDelete": "¿Estás seguro de que quieres eliminar esta categoría?"
"confirmDelete": "¿Estás seguro de que quieres eliminar esta categoría?",
"searchPlaceholder": "Buscar prompts...",
"sort": {
"newest": "Más recientes",
"oldest": "Más antiguos",
"most_upvoted": "Más votados",
"most_contributors": "Más contribuidores"
}
},
"tags": {
"title": "Etiquetas",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "دنبال‌کننده",
"promptCount": "{count} پرامپت",
"subscriberCount": "{count} دنبال‌کننده",
"confirmDelete": "آیا مطمئن هستید که می‌خواهید این دسته‌بندی را حذف کنید؟"
"confirmDelete": "آیا مطمئن هستید که می‌خواهید این دسته‌بندی را حذف کنید؟",
"searchPlaceholder": "جستجوی پرامپت‌ها...",
"sort": {
"newest": "جدیدترین",
"oldest": "قدیمی‌ترین",
"most_upvoted": "بیشترین رای",
"most_contributors": "بیشترین مشارکت‌کننده"
}
},
"tags": {
"title": "برچسب‌ها",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "abonnés",
"promptCount": "{count} prompts",
"subscriberCount": "{count} abonnés",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cette catégorie ?"
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cette catégorie ?",
"searchPlaceholder": "Rechercher des prompts...",
"sort": {
"newest": "Plus récents",
"oldest": "Plus anciens",
"most_upvoted": "Plus votés",
"most_contributors": "Plus de contributeurs"
}
},
"tags": {
"title": "Tags",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "מנויים",
"promptCount": "{count} פרומפטים",
"subscriberCount": "{count} מנויים",
"confirmDelete": "האם אתה בטוח שברצונך למחוק קטגוריה זו?"
"confirmDelete": "האם אתה בטוח שברצונך למחוק קטגוריה זו?",
"searchPlaceholder": "חיפוש פרומפטים...",
"sort": {
"newest": "החדשים ביותר",
"oldest": "הישנים ביותר",
"most_upvoted": "הכי מדורגים",
"most_contributors": "הכי הרבה תורמים"
}
},
"tags": {
"title": "תגיות",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "iscritti",
"promptCount": "{count} prompt",
"subscriberCount": "{count} iscritti",
"confirmDelete": "Sei sicuro di voler eliminare questa categoria?"
"confirmDelete": "Sei sicuro di voler eliminare questa categoria?",
"searchPlaceholder": "Cerca prompt...",
"sort": {
"newest": "Più recenti",
"oldest": "Più vecchi",
"most_upvoted": "Più votati",
"most_contributors": "Più contributori"
}
},
"tags": {
"title": "Tag",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "人の購読者",
"promptCount": "{count}件のプロンプト",
"subscriberCount": "{count}人の購読者",
"confirmDelete": "このカテゴリーを削除してもよろしいですか?"
"confirmDelete": "このカテゴリーを削除してもよろしいですか?",
"searchPlaceholder": "プロンプトを検索...",
"sort": {
"newest": "新しい順",
"oldest": "古い順",
"most_upvoted": "投票数順",
"most_contributors": "貢献者数順"
}
},
"tags": {
"title": "タグ",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "구독자",
"promptCount": "{count}개 프롬프트",
"subscriberCount": "{count}명 구독자",
"confirmDelete": "이 카테고리를 삭제하시겠습니까?"
"confirmDelete": "이 카테고리를 삭제하시겠습니까?",
"searchPlaceholder": "프롬프트 검색...",
"sort": {
"newest": "최신순",
"oldest": "오래된순",
"most_upvoted": "추천순",
"most_contributors": "기여자순"
}
},
"tags": {
"title": "태그",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "assinantes",
"promptCount": "{count} prompts",
"subscriberCount": "{count} assinantes",
"confirmDelete": "Tem certeza que deseja excluir esta categoria?"
"confirmDelete": "Tem certeza que deseja excluir esta categoria?",
"searchPlaceholder": "Buscar prompts...",
"sort": {
"newest": "Mais recentes",
"oldest": "Mais antigos",
"most_upvoted": "Mais votados",
"most_contributors": "Mais contribuidores"
}
},
"tags": {
"title": "Tags",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "подписчиков",
"promptCount": "{count} промптов",
"subscriberCount": "{count} подписчиков",
"confirmDelete": "Вы уверены, что хотите удалить эту категорию?"
"confirmDelete": "Вы уверены, что хотите удалить эту категорию?",
"searchPlaceholder": "Поиск промптов...",
"sort": {
"newest": "Новые",
"oldest": "Старые",
"most_upvoted": "По голосам",
"most_contributors": "По участникам"
}
},
"tags": {
"title": "Теги",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "abone",
"promptCount": "{count} prompt",
"subscriberCount": "{count} abone",
"confirmDelete": "Bu kategoriyi silmek istediğinizden emin misiniz?"
"confirmDelete": "Bu kategoriyi silmek istediğinizden emin misiniz?",
"searchPlaceholder": "Prompt ara...",
"sort": {
"newest": "En yeni",
"oldest": "En eski",
"most_upvoted": "En çok oylanan",
"most_contributors": "En çok katkıda bulunan"
}
},
"tags": {
"title": "Etiketler",
+8 -1
View File
@@ -422,7 +422,14 @@
"subscribers": "订阅者",
"promptCount": "{count} 个提示词",
"subscriberCount": "{count} 订阅者",
"confirmDelete": "确定要删除此分类吗?"
"confirmDelete": "确定要删除此分类吗?",
"searchPlaceholder": "搜索提示词...",
"sort": {
"newest": "最新",
"oldest": "最早",
"most_upvoted": "最多投票",
"most_contributors": "最多贡献者"
}
},
"tags": {
"title": "标签",
+44 -17
View File
@@ -9,11 +9,12 @@ import config from "@/../prompts.config";
import { Button } from "@/components/ui/button";
import { PromptList } from "@/components/prompts/prompt-list";
import { SubscribeButton } from "@/components/categories/subscribe-button";
import { CategoryFilters } from "@/components/categories/category-filters";
import { McpServerPopup } from "@/components/mcp/mcp-server-popup";
interface CategoryPageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ page?: string }>;
searchParams: Promise<{ page?: string; sort?: string; q?: string }>;
}
const PROMPTS_PER_PAGE = 30;
@@ -37,8 +38,9 @@ export async function generateMetadata({ params }: CategoryPageProps): Promise<M
export default async function CategoryPage({ params, searchParams }: CategoryPageProps) {
const { slug } = await params;
const { page } = await searchParams;
const { page, sort, q } = await searchParams;
const currentPage = Math.max(1, parseInt(page || "1", 10) || 1);
const sortOption = sort || "newest";
const session = await auth();
const t = await getTranslations();
@@ -67,26 +69,42 @@ export default async function CategoryPage({ params, searchParams }: CategoryPag
})
: null;
// Build where clause with optional search
const whereClause = {
categoryId: category.id,
isPrivate: false,
isUnlisted: false,
deletedAt: null,
...(q && {
OR: [
{ title: { contains: q, mode: "insensitive" as const } },
{ content: { contains: q, mode: "insensitive" as const } },
],
}),
};
// Build orderBy based on sort option
const getOrderBy = () => {
switch (sortOption) {
case "oldest":
return { createdAt: "asc" as const };
case "most_upvoted":
return { votes: { _count: "desc" as const } };
case "most_contributors":
return { contributors: { _count: "desc" as const } };
default:
return { createdAt: "desc" as const };
}
};
// Count total prompts for pagination
const totalPrompts = await db.prompt.count({
where: {
categoryId: category.id,
isPrivate: false,
isUnlisted: false,
deletedAt: null,
},
});
const totalPrompts = await db.prompt.count({ where: whereClause });
const totalPages = Math.ceil(totalPrompts / PROMPTS_PER_PAGE);
// Fetch prompts in this category
const promptsRaw = await db.prompt.findMany({
where: {
categoryId: category.id,
isPrivate: false,
isUnlisted: false,
deletedAt: null,
},
orderBy: { createdAt: "desc" },
where: whereClause,
orderBy: getOrderBy(),
skip: (currentPage - 1) * PROMPTS_PER_PAGE,
take: PROMPTS_PER_PAGE,
include: {
@@ -158,6 +176,15 @@ export default async function CategoryPage({ params, searchParams }: CategoryPag
<span>{t("categories.subscriberCount", { count: category._count.subscribers })}</span>
</div>
</div>
<div className="hidden md:flex items-center gap-2">
<CategoryFilters categorySlug={slug} />
{config.features.mcp !== false && <McpServerPopup initialCategories={[slug]} showOfficialBranding={!config.homepage?.useCloneBranding} />}
</div>
</div>
{/* Mobile filters */}
<div className="flex md:hidden items-center gap-2 mt-4">
<CategoryFilters categorySlug={slug} />
{config.features.mcp !== false && <McpServerPopup initialCategories={[slug]} showOfficialBranding={!config.homepage?.useCloneBranding} />}
</div>
</div>
@@ -0,0 +1,81 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const SORT_OPTIONS = ["newest", "oldest", "most_upvoted", "most_contributors"] as const;
type SortOption = (typeof SORT_OPTIONS)[number];
interface CategoryFiltersProps {
categorySlug: string;
}
export function CategoryFilters({ categorySlug }: CategoryFiltersProps) {
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations("categories");
const currentSort = (searchParams?.get("sort") as SortOption) || "newest";
const currentSearch = searchParams?.get("q") || "";
const updateParams = (key: string, value: string) => {
const params = new URLSearchParams(searchParams?.toString() || "");
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
// Reset to page 1 when filters change
params.delete("page");
router.push(`/categories/${categorySlug}?${params.toString()}`);
};
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
updateParams("q", e.currentTarget.value);
}
};
const handleSearchBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (e.currentTarget.value !== currentSearch) {
updateParams("q", e.currentTarget.value);
}
};
return (
<div className="flex items-center gap-2 flex-1 md:flex-none">
<div className="relative flex-1 md:flex-none">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder={t("searchPlaceholder")}
defaultValue={currentSearch}
onKeyDown={handleSearchKeyDown}
onBlur={handleSearchBlur}
className="pl-8 h-8 w-full md:w-[180px] text-sm"
/>
</div>
<Select value={currentSort} onValueChange={(value) => updateParams("sort", value)}>
<SelectTrigger size="sm" className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{SORT_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`sort.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}