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:
@@ -0,0 +1,113 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import {
|
||||
db,
|
||||
collections,
|
||||
users,
|
||||
collectionFavorites,
|
||||
collectionRecipes,
|
||||
eq,
|
||||
desc,
|
||||
sql,
|
||||
and,
|
||||
gte,
|
||||
count,
|
||||
inArray,
|
||||
} from "@epicure/db";
|
||||
import { ExploreCollectionsContent } from "@/components/collections/explore-collections-content";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
|
||||
export type CollectionResult = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
authorName: string | null;
|
||||
recipeCount: number;
|
||||
starCount: number;
|
||||
starred: boolean;
|
||||
};
|
||||
|
||||
export default async function ExploreCollectionsPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const trendingRows = await db
|
||||
.select({
|
||||
id: collections.id,
|
||||
name: collections.name,
|
||||
description: collections.description,
|
||||
authorName: users.name,
|
||||
recentStars: count(collectionFavorites.collectionId),
|
||||
})
|
||||
.from(collections)
|
||||
.innerJoin(users, eq(collections.userId, users.id))
|
||||
.leftJoin(
|
||||
collectionFavorites,
|
||||
and(eq(collectionFavorites.collectionId, collections.id), gte(collectionFavorites.createdAt, sevenDaysAgo))
|
||||
)
|
||||
.where(eq(collections.isPublic, true))
|
||||
.groupBy(collections.id, users.id)
|
||||
.orderBy(desc(sql`count(${collectionFavorites.collectionId})`))
|
||||
.limit(12);
|
||||
|
||||
const recentRows = await db
|
||||
.select({
|
||||
id: collections.id,
|
||||
name: collections.name,
|
||||
description: collections.description,
|
||||
authorName: users.name,
|
||||
})
|
||||
.from(collections)
|
||||
.innerJoin(users, eq(collections.userId, users.id))
|
||||
.where(eq(collections.isPublic, true))
|
||||
.orderBy(desc(collections.createdAt))
|
||||
.limit(12);
|
||||
|
||||
const allIds = [...new Set([...trendingRows.map((r) => r.id), ...recentRows.map((r) => r.id)])];
|
||||
|
||||
const [recipeCounts, totalStars, myStars] = await Promise.all([
|
||||
allIds.length > 0
|
||||
? db.select({ collectionId: collectionRecipes.collectionId, n: count() })
|
||||
.from(collectionRecipes)
|
||||
.where(inArray(collectionRecipes.collectionId, allIds))
|
||||
.groupBy(collectionRecipes.collectionId)
|
||||
: [],
|
||||
allIds.length > 0
|
||||
? db.select({ collectionId: collectionFavorites.collectionId, n: count() })
|
||||
.from(collectionFavorites)
|
||||
.where(inArray(collectionFavorites.collectionId, allIds))
|
||||
.groupBy(collectionFavorites.collectionId)
|
||||
: [],
|
||||
allIds.length > 0
|
||||
? db.query.collectionFavorites.findMany({
|
||||
where: and(eq(collectionFavorites.userId, session.user.id), inArray(collectionFavorites.collectionId, allIds)),
|
||||
columns: { collectionId: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const recipeCountMap = new Map(recipeCounts.map((r) => [r.collectionId, r.n]));
|
||||
const starCountMap = new Map(totalStars.map((r) => [r.collectionId, r.n]));
|
||||
const starredSet = new Set(myStars.map((s) => s.collectionId));
|
||||
|
||||
function toResult(row: { id: string; name: string; description: string | null; authorName: string | null }): CollectionResult {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
authorName: row.authorName,
|
||||
recipeCount: recipeCountMap.get(row.id) ?? 0,
|
||||
starCount: starCountMap.get(row.id) ?? 0,
|
||||
starred: starredSet.has(row.id),
|
||||
};
|
||||
}
|
||||
|
||||
const trending = trendingRows.map(toResult);
|
||||
const recent = recentRows.map(toResult);
|
||||
|
||||
return <ExploreCollectionsContent trending={trending} recent={recent} />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, collections, collectionFavorites, eq, and, or } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const collection = await db.query.collections.findFirst({
|
||||
where: and(eq(collections.id, id), or(eq(collections.isPublic, true), eq(collections.userId, session!.user.id))),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
await db.insert(collectionFavorites)
|
||||
.values({ userId: session!.user.id, collectionId: id })
|
||||
.onConflictDoNothing();
|
||||
return NextResponse.json({ favorited: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
await db.delete(collectionFavorites)
|
||||
.where(and(eq(collectionFavorites.userId, session!.user.id), eq(collectionFavorites.collectionId, id)));
|
||||
return NextResponse.json({ favorited: false });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -693,6 +693,13 @@
|
||||
"public": "Public",
|
||||
"recipeCount": "{count} recipe",
|
||||
"recipeCountPlural": "{count} recipes",
|
||||
"discoverTitle": "Discover",
|
||||
"discoverSubtitle": "Trending and recently shared public collections",
|
||||
"myCollectionsLink": "My collections",
|
||||
"trendingThisWeek": "Trending this week",
|
||||
"recentlyAdded": "Recently added",
|
||||
"noTrending": "No trending collections yet",
|
||||
"noRecent": "No public collections yet",
|
||||
"exportPdf": "Export as PDF",
|
||||
"shareTitle": "Share collection",
|
||||
"shareDescription": "Invite people to view or edit this collection.",
|
||||
|
||||
@@ -681,6 +681,13 @@
|
||||
"public": "Publique",
|
||||
"recipeCount": "{count} recette",
|
||||
"recipeCountPlural": "{count} recettes",
|
||||
"discoverTitle": "Découvrir",
|
||||
"discoverSubtitle": "Collections publiques tendances et récemment partagées",
|
||||
"myCollectionsLink": "Mes collections",
|
||||
"trendingThisWeek": "Tendance cette semaine",
|
||||
"recentlyAdded": "Récemment ajoutées",
|
||||
"noTrending": "Aucune collection tendance pour l'instant",
|
||||
"noRecent": "Aucune collection publique pour l'instant",
|
||||
"exportPdf": "Exporter en PDF",
|
||||
"shareTitle": "Partager la collection",
|
||||
"shareDescription": "Invitez des personnes à voir ou modifier cette collection.",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "collection_favorites" (
|
||||
"user_id" text NOT NULL,
|
||||
"collection_id" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "collection_favorites" ADD CONSTRAINT "collection_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collection_favorites" ADD CONSTRAINT "collection_favorites_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "collection_favorites_collection_idx" ON "collection_favorites" USING btree ("collection_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "collection_favorites_user_collection_idx" ON "collection_favorites" USING btree ("user_id","collection_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,13 @@
|
||||
"when": 1783602591480,
|
||||
"tag": "0021_mysterious_madame_masque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1783603737477,
|
||||
"tag": "0022_foamy_loki",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
boolean,
|
||||
pgEnum,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { users } from "./users";
|
||||
@@ -72,6 +73,15 @@ export const collectionRecipes = pgTable("collection_recipes", {
|
||||
addedAt: timestamp("added_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const collectionFavorites = pgTable("collection_favorites", {
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("collection_favorites_collection_idx").on(t.collectionId),
|
||||
uniqueIndex("collection_favorites_user_collection_idx").on(t.userId, t.collectionId),
|
||||
]);
|
||||
|
||||
export const cookingHistory = pgTable("cooking_history", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
@@ -117,6 +127,11 @@ export const collectionRecipesRelations = relations(collectionRecipes, ({ one })
|
||||
recipe: one(recipes, { fields: [collectionRecipes.recipeId], references: [recipes.id] }),
|
||||
}));
|
||||
|
||||
export const collectionFavoritesRelations = relations(collectionFavorites, ({ one }) => ({
|
||||
collection: one(collections, { fields: [collectionFavorites.collectionId], references: [collections.id] }),
|
||||
user: one(users, { fields: [collectionFavorites.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const collectionMemberRoleEnum = pgEnum("collection_member_role", ["viewer", "editor"]);
|
||||
|
||||
export const collectionMembers = pgTable("collection_members", {
|
||||
|
||||
Reference in New Issue
Block a user