diff --git a/CHANGELOG.md b/CHANGELOG.md index be4996e..61c91c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index 1d4bab5..bb1efe6 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -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` | diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 6c58d75..0acc398 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -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(); 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 })} )} + {visibleForks.length > 0 && ( +
+

+ {visibleForks.length === 1 + ? m.recipe.forkedByCountSingular + : formatMessage(m.recipe.forkedByCountPlural, { count: visibleForks.length })} +

+
+ {visibleForks.map((f) => ( + + {f.child.title} + + ))} +
+
+ )}
{recipe.steps.length > 0 && ( diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 5953f6b..7fa3808 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -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", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index efbfe2d..6fd8b4e 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 1950b68..474a2ef 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index 4f40bff..da1a2e4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.81.0", + "version": "0.82.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 61325f8..9ae9787 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.81.0", + "version": "0.82.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",