feat(categories): add pinned categories feature and UI updates

This commit is contained in:
Fatih Kadir Akın
2025-12-27 14:06:06 +03:00
parent 9342959827
commit e454049b6e
9 changed files with 188 additions and 9 deletions

View File

@@ -602,7 +602,13 @@
"noParent": "None (Root Category)",
"parentHelp": "Leave empty to create a root category, or select a parent to create a subcategory",
"rootCategory": "Root",
"subcategories": "subcategories"
"subcategories": "subcategories",
"pin": "Pin to Prompts Page",
"unpin": "Unpin from Prompts Page",
"pinned": "Category pinned",
"unpinned": "Category unpinned",
"pinnedBadge": "Pinned",
"pinnedLabel": "Pin to prompts page (show as quick filter)"
},
"tags": {
"title": "Tag Management",

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "categories" ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "categories_pinned_idx" ON "categories"("pinned");

View File

@@ -177,6 +177,7 @@ model Category {
description String?
icon String?
order Int @default(0)
pinned Boolean @default(false)
parentId String?
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children Category[] @relation("CategoryHierarchy")
@@ -184,6 +185,7 @@ model Category {
prompts Prompt[]
@@index([parentId])
@@index([pinned])
@@map("categories")
}

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
@@ -15,7 +16,7 @@ export async function PATCH(
const { id } = await params;
const body = await request.json();
const { name, slug, description, icon, parentId } = body;
const { name, slug, description, icon, parentId, pinned } = body;
const category = await db.category.update({
where: { id },
@@ -25,9 +26,12 @@ export async function PATCH(
description: description ?? undefined,
icon: icon ?? undefined,
parentId: parentId === null ? null : (parentId || undefined),
...(typeof pinned === "boolean" && { pinned }),
},
});
revalidateTag("categories", "max");
return NextResponse.json(category);
} catch (error) {
console.error("Error updating category:", error);
@@ -52,6 +56,8 @@ export async function DELETE(
where: { id },
});
revalidateTag("categories", "max");
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting category:", error);

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
@@ -11,7 +12,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const { name, slug, description, icon, parentId } = body;
const { name, slug, description, icon, parentId, pinned } = body;
if (!name || !slug) {
return NextResponse.json({ error: "Name and slug are required" }, { status: 400 });
@@ -24,9 +25,12 @@ export async function POST(request: NextRequest) {
description: description || null,
icon: icon || null,
parentId: parentId || null,
pinned: pinned || false,
},
});
revalidateTag("categories", "max");
return NextResponse.json(category);
} catch (error) {
console.error("Error creating category:", error);

View File

@@ -2,11 +2,13 @@ import { Metadata } from "next";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { unstable_cache } from "next/cache";
import { Suspense } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"
import { InfinitePromptList } from "@/components/prompts/infinite-prompt-list";
import { PromptFilters } from "@/components/prompts/prompt-filters";
import { FilterProvider } from "@/components/prompts/filter-context";
import { PinnedCategories } from "@/components/categories/pinned-categories";
import { HFDataStudioDropdown } from "@/components/prompts/hf-data-studio-dropdown";
import { McpServerPopup } from "@/components/mcp/mcp-server-popup";
import { db } from "@/lib/db";
@@ -36,6 +38,24 @@ const getCategories = unstable_cache(
{ tags: ["categories"] }
);
// Query for pinned categories (cached)
const getPinnedCategories = unstable_cache(
async () => {
return db.category.findMany({
where: { pinned: true },
orderBy: [{ order: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
slug: true,
icon: true,
},
});
},
["pinned-categories"],
{ tags: ["categories"] }
);
// Query for tags (cached)
const getTags = unstable_cache(
async () => {
@@ -211,9 +231,10 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) {
total = result.total;
}
// Fetch categories and tags for filter
const [categories, tags] = await Promise.all([
// Fetch categories, pinned categories, and tags for filter
const [categories, pinnedCategories, tags] = await Promise.all([
getCategories(),
getPinnedCategories(),
getTags(),
]);
@@ -239,6 +260,15 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) {
</Button>
</div>
</div>
<Suspense fallback={null}>
<div className="mb-4">
<PinnedCategories
categories={pinnedCategories}
currentCategoryId={params.category}
/>
</div>
</Suspense>
<FilterProvider>
<div className="flex flex-col lg:flex-row gap-6">

View File

@@ -3,7 +3,7 @@
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { MoreHorizontal, Plus, Pencil, Trash2, ChevronRight } from "lucide-react";
import { MoreHorizontal, Plus, Pencil, Trash2, ChevronRight, Pin, PinOff } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -56,6 +56,7 @@ interface Category {
description: string | null;
icon: string | null;
order: number;
pinned: boolean;
parentId: string | null;
parent: { id: string; name: string } | null;
children?: Category[];
@@ -76,7 +77,7 @@ export function CategoriesTable({ categories }: CategoriesTableProps) {
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({ name: "", slug: "", description: "", icon: "", parentId: "" });
const [formData, setFormData] = useState({ name: "", slug: "", description: "", icon: "", parentId: "", pinned: false });
// Get only root categories (no parent) for parent selection
const rootCategories = useMemo(() =>
@@ -101,7 +102,7 @@ export function CategoriesTable({ categories }: CategoriesTableProps) {
}, [categories, rootCategories]);
const openCreateDialog = () => {
setFormData({ name: "", slug: "", description: "", icon: "", parentId: "" });
setFormData({ name: "", slug: "", description: "", icon: "", parentId: "", pinned: false });
setIsCreating(true);
};
@@ -112,10 +113,31 @@ export function CategoriesTable({ categories }: CategoriesTableProps) {
description: category.description || "",
icon: category.icon || "",
parentId: category.parentId || "",
pinned: category.pinned,
});
setEditCategory(category);
};
const handleTogglePin = async (category: Category) => {
setLoading(true);
try {
const res = await fetch(`/api/admin/categories/${category.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinned: !category.pinned }),
});
if (!res.ok) throw new Error("Failed to update");
toast.success(category.pinned ? t("unpinned") : t("pinned"));
router.refresh();
} catch {
toast.error(t("saveFailed"));
} finally {
setLoading(false);
}
};
// Filter out invalid parent options (can't be own parent or child of self)
const getValidParentOptions = () => {
if (!editCategory) return rootCategories;
@@ -224,6 +246,12 @@ export function CategoriesTable({ categories }: CategoriesTableProps) {
{category._count.children} {t("subcategories")}
</Badge>
)}
{category.pinned && (
<Badge variant="default" className="text-xs">
<Pin className="h-3 w-3 mr-1" />
{t("pinnedBadge")}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{category.slug}</TableCell>
@@ -249,6 +277,13 @@ export function CategoriesTable({ categories }: CategoriesTableProps) {
<Pencil className="h-4 w-4 mr-2" />
{t("edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleTogglePin(category)} disabled={loading}>
{category.pinned ? (
<><PinOff className="h-4 w-4 mr-2" />{t("unpin")}</>
) : (
<><Pin className="h-4 w-4 mr-2" />{t("pin")}</>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => setDeleteId(category.id)}
@@ -328,6 +363,18 @@ export function CategoriesTable({ categories }: CategoriesTableProps) {
placeholder="📁"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="pinned"
checked={formData.pinned}
onChange={(e) => setFormData({ ...formData, pinned: e.target.checked })}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="pinned" className="text-sm font-normal cursor-pointer">
{t("pinnedLabel")}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setIsCreating(false); setEditCategory(null); }}>

View File

@@ -0,0 +1,79 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
interface PinnedCategory {
id: string;
name: string;
slug: string;
icon: string | null;
}
interface PinnedCategoriesProps {
categories: PinnedCategory[];
currentCategoryId?: string;
}
export function PinnedCategories({ categories, currentCategoryId }: PinnedCategoriesProps) {
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations("categories");
if (categories.length === 0) {
return null;
}
const handleCategoryClick = (categoryId: string) => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
if (currentCategoryId === categoryId) {
params.delete("category");
} else {
params.set("category", categoryId);
}
params.delete("page");
router.push(`/prompts?${params.toString()}`);
};
const handleClearFilter = () => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
params.delete("category");
params.delete("page");
router.push(`/prompts?${params.toString()}`);
};
return (
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handleClearFilter}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border transition-colors",
!currentCategoryId
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-border"
)}
>
{t("allCategories")}
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => handleCategoryClick(category.id)}
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-full border transition-colors",
currentCategoryId === category.id
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-border"
)}
>
{category.icon && <span>{category.icon}</span>}
{category.name}
</button>
))}
</div>
);
}

View File

@@ -124,7 +124,7 @@ export function Header({ authProvider = "credentials", allowRegistration = true
/>
</>
)}
<span className="text-lg font-semibold">{branding.name}</span>
<span className="text-lg font-semibold mt-2">{branding.name}</span>
</div>
{/* Navigation */}