feat: show recipes forked from the original on the recipe page (v0.82.0)

The "Forked from X" backlink only worked one direction. The original recipe now also shows "Forked by N others" with links to each fork — filtered to forks that are public/unlisted or belong to the viewer, so a private fork never leaks its existence or title to other viewers of the original.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 14:18:06 +02:00
parent ce7741c0b6
commit a488b544dc
8 changed files with 55 additions and 5 deletions
+35 -1
View File
@@ -74,7 +74,7 @@ export default async function RecipePage({ params }: Params) {
const DIETARY_LABELS = m.recipe.dietary;
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog, featureFlags, featurePrefs] = await Promise.all([
const [recipe, ratingData, favoriteData, myRating, forkedFrom, forkedTo, myNote, dishCookLog, featureFlags, featurePrefs] = await Promise.all([
db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
@@ -95,6 +95,13 @@ export default async function RecipePage({ params }: Params) {
where: eq(recipeVariations.childRecipeId, id),
with: { parent: { columns: { id: true, title: true } } },
}),
db.query.recipeVariations.findMany({
where: eq(recipeVariations.parentRecipeId, id),
orderBy: (t, { desc }) => desc(t.createdAt),
with: {
child: { columns: { id: true, title: true, visibility: true, authorId: true } },
},
}),
db.query.recipeNotes.findFirst({
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)),
columns: { content: true },
@@ -133,6 +140,13 @@ export default async function RecipePage({ params }: Params) {
const isOwner = recipe.authorId === session.user.id;
// Don't leak the existence/title of someone else's private fork —
// only surface forks that are themselves public/unlisted, or that the
// viewer made themselves.
const visibleForks = forkedTo.filter(
(f) => f.child.visibility !== "private" || f.child.authorId === session.user.id
);
const dishCookedAtMap = new Map<string, string>();
for (const log of dishCookLog) {
if (log.batchDishId && !dishCookedAtMap.has(log.batchDishId)) {
@@ -194,6 +208,26 @@ export default async function RecipePage({ params }: Params) {
{formatMessage(m.recipe.forkedFrom, { title: forkedFrom.parent.title })}
</Link>
)}
{visibleForks.length > 0 && (
<div className="text-sm text-muted-foreground">
<p>
{visibleForks.length === 1
? m.recipe.forkedByCountSingular
: formatMessage(m.recipe.forkedByCountPlural, { count: visibleForks.length })}
</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1">
{visibleForks.map((f) => (
<Link
key={f.child.id}
href={`/recipes/${f.child.id}`}
className="hover:text-foreground underline-offset-2 hover:underline"
>
{f.child.title}
</Link>
))}
</div>
</div>
)}
<TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{recipe.steps.length > 0 && (