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
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.82.0 — 2026-07-24 17:45
### Added
- A recipe page now shows "Forked by N others" with links to each fork, alongside the existing "Forked from X" backlink — visible on both sides of a fork now, not just one. Private forks stay private: only shown to their own author, never to other viewers of the original.
## 0.81.0 — 2026-07-24 17:15
### Added
+1 -1
View File
@@ -17,7 +17,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Feature | Status | Description | Key files |
|---|---|---|---|
| Recipe CRUD | Exists | Create/get/list/update/delete; update snapshots the prior version first (`recipeSnapshots`) | `apps/web/app/api/v1/recipes/**` |
| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership | `apps/web/app/api/v1/recipes/[id]/fork/route.ts` |
| Fork / duplicate | Exists | Same backend action for both — UI just swaps the label based on ownership. Backlink works both directions: a forked recipe already showed "Forked from X"; the original now also shows "Forked by N others" with links to each — filtered to forks that are public/unlisted or belong to the viewer, so a private fork's existence/title never leaks to other viewers. Uses the same `recipeVariations` table as AI variations/adapt, not a separate fork-tracking mechanism. | `apps/web/app/api/v1/recipes/[id]/fork/route.ts`, `apps/web/app/(app)/recipes/[id]/page.tsx` |
| Version history | Exists | `GET /recipes/[id]/versions`, diff-able snapshots | `apps/web/app/api/v1/recipes/[id]/versions/**` |
| **Import from URL** | **Exists** | Fetches a page (SSRF-checked), strips markup, extracts a structured recipe via AI. Returns the extraction to the client — doesn't self-persist, caller POSTs it to create | `apps/web/app/api/v1/ai/import-url` |
| Import from photo | Exists | Two-stage vision→text; recognizes a photographed page/handwritten recipe, self-persists as a private recipe | `apps/web/app/api/v1/ai/import-photo`, `lib/ai/features/{recognize-photo,generate-recipe-from-recognition}.ts` |
+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 && (
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.81.0";
export const APP_VERSION = "0.82.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.82.0",
date: "2026-07-24 17:45",
added: [
"A recipe page now shows \"Forked by N others\" with links to each fork, alongside the existing \"Forked from X\" backlink — visible on both sides of a fork now, not just one. Private forks stay private: only shown to their own author, never to other viewers of the original.",
],
},
{
version: "0.81.0",
date: "2026-07-24 17:15",
+2
View File
@@ -219,6 +219,8 @@
"duplicateTooltip": "Duplicate this recipe",
"duplicated": "Recipe duplicated",
"forkedFrom": "Forked from {title}",
"forkedByCountSingular": "Forked by 1 other",
"forkedByCountPlural": "Forked by {count} others",
"pairingFindingLabel": "Finding perfect pairings…",
"pairingSuggestButton": "Suggest pairings",
"pairingRegenerate": "Regenerate",
+2
View File
@@ -220,6 +220,8 @@
"duplicateTooltip": "Dupliquer cette recette",
"duplicated": "Recette dupliquée",
"forkedFrom": "Copiée depuis {title}",
"forkedByCountSingular": "Copiée par 1 autre personne",
"forkedByCountPlural": "Copiée par {count} autres personnes",
"pairingFindingLabel": "Recherche des meilleurs accords…",
"pairingSuggestButton": "Suggérer des accompagnements",
"pairingRegenerate": "Régénérer",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.81.0",
"version": "0.82.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.81.0",
"version": "0.82.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",