feat: add trending/leaderboard for collections

New collection_favorites table lets users star public collections.
/collections/explore mirrors the recipe explore page: trending (star
count in last 7 days) and recently-added public collections. Linked
from the "Discover" button on the collections page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-09 15:33:40 +02:00
parent cd444d4d23
commit b4b964aafb
11 changed files with 4618 additions and 2 deletions
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function CollectionStarButton({
collectionId,
initialStarred = false,
starCount,
}: {
collectionId: string;
initialStarred?: boolean;
starCount: number;
}) {
const [starred, setStarred] = useState(initialStarred);
const [count, setCount] = useState(starCount);
const [loading, setLoading] = useState(false);
async function toggle(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setLoading(true);
try {
const res = await fetch(`/api/v1/collections/${collectionId}/favorite`, {
method: starred ? "DELETE" : "POST",
});
if (!res.ok) return;
setStarred(!starred);
setCount((c) => (starred ? c - 1 : c + 1));
} finally {
setLoading(false);
}
}
return (
<Button variant="ghost" size="sm" onClick={toggle} disabled={loading} className="gap-1.5 px-2">
<Star className={cn("h-4 w-4", starred && "fill-yellow-400 text-yellow-400")} />
<span className="text-xs tabular-nums">{count}</span>
</Button>
);
}
@@ -1,9 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { FolderOpen } from "lucide-react";
import { FolderOpen, Flame } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { NewCollectionButton } from "@/components/social/new-collection-button";
import { cn } from "@/lib/utils";
type Collection = {
id: string;
@@ -27,7 +29,13 @@ export function CollectionsPageContent({ collections }: Props) {
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
<NewCollectionButton />
<div className="flex items-center gap-2">
<Link href="/collections/explore" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
<Flame className="h-4 w-4 text-orange-500" />
{t("discoverTitle")}
</Link>
<NewCollectionButton />
</div>
</div>
{collections.length === 0 ? (
@@ -0,0 +1,99 @@
"use client";
import Link from "next/link";
import { Flame, Clock, FolderOpen, ChefHat } from "lucide-react";
import { useTranslations } from "next-intl";
import { CollectionStarButton } from "@/components/collections/collection-star-button";
import type { CollectionResult } from "@/app/(app)/collections/explore/page";
function CollectionCard({ collection }: { collection: CollectionResult }) {
const t = useTranslations("collections");
return (
<div className="group relative rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md">
<Link href={`/collections/${collection.id}`} className="block">
<h3 className="font-semibold text-card-foreground group-hover:text-primary line-clamp-1 pr-10">
{collection.name}
</h3>
{collection.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{collection.description}</p>
)}
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FolderOpen className="h-3.5 w-3.5" />
{collection.recipeCount !== 1
? t("recipeCountPlural", { count: collection.recipeCount })
: t("recipeCount", { count: collection.recipeCount })}
</span>
{collection.authorName && (
<span className="flex items-center gap-1">
<ChefHat className="h-3.5 w-3.5" />
{collection.authorName}
</span>
)}
</div>
</Link>
<div className="absolute top-3 right-3">
<CollectionStarButton collectionId={collection.id} initialStarred={collection.starred} starCount={collection.starCount} />
</div>
</div>
);
}
function HorizontalScroll({ children }: { children: React.ReactNode }) {
return <div className="flex gap-4 overflow-x-auto pb-2 -mx-4 px-4 snap-x snap-mandatory">{children}</div>;
}
type Props = {
trending: CollectionResult[];
recent: CollectionResult[];
};
export function ExploreCollectionsContent({ trending, recent }: Props) {
const t = useTranslations("collections");
return (
<div className="max-w-5xl mx-auto space-y-10">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{t("discoverTitle")}</h1>
<p className="text-muted-foreground mt-1">{t("discoverSubtitle")}</p>
</div>
<Link href="/collections" className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4">
{t("myCollectionsLink")}
</Link>
</div>
<section>
<div className="flex items-center gap-2 mb-4">
<Flame className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-semibold">{t("trendingThisWeek")}</h2>
</div>
{trending.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">{t("noTrending")}</p>
) : (
<HorizontalScroll>
{trending.map((c) => (
<div key={c.id} className="snap-start shrink-0 w-64">
<CollectionCard collection={c} />
</div>
))}
</HorizontalScroll>
)}
</section>
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold">{t("recentlyAdded")}</h2>
</div>
{recent.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">{t("noRecent")}</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{recent.map((c) => <CollectionCard key={c.id} collection={c} />)}
</div>
)}
</section>
</div>
);
}