mirror of
https://github.com/f/awesome-chatgpt-prompts.git
synced 2026-03-03 02:37:02 +00:00
feat(categories): add pinned categories feature and UI updates
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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); }}>
|
||||
|
||||
79
src/components/categories/pinned-categories.tsx
Normal file
79
src/components/categories/pinned-categories.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user