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:
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.81.0",
|
||||
"version": "0.82.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.81.0",
|
||||
"version": "0.82.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user