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.
|
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
|
## 0.81.0 — 2026-07-24 17:15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
|
|||||||
| Feature | Status | Description | Key files |
|
| 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/**` |
|
| 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/**` |
|
| 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 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` |
|
| 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 DIETARY_LABELS = m.recipe.dietary;
|
||||||
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
|
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({
|
db.query.recipes.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(recipes.id, id),
|
eq(recipes.id, id),
|
||||||
@@ -95,6 +95,13 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
where: eq(recipeVariations.childRecipeId, id),
|
where: eq(recipeVariations.childRecipeId, id),
|
||||||
with: { parent: { columns: { id: true, title: true } } },
|
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({
|
db.query.recipeNotes.findFirst({
|
||||||
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)),
|
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)),
|
||||||
columns: { content: true },
|
columns: { content: true },
|
||||||
@@ -133,6 +140,13 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
|
|
||||||
const isOwner = recipe.authorId === session.user.id;
|
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>();
|
const dishCookedAtMap = new Map<string, string>();
|
||||||
for (const log of dishCookLog) {
|
for (const log of dishCookLog) {
|
||||||
if (log.batchDishId && !dishCookedAtMap.has(log.batchDishId)) {
|
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 })}
|
{formatMessage(m.recipe.forkedFrom, { title: forkedFrom.parent.title })}
|
||||||
</Link>
|
</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>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
<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 && (
|
{recipe.steps.length > 0 && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.81.0",
|
||||||
date: "2026-07-24 17:15",
|
date: "2026-07-24 17:15",
|
||||||
|
|||||||
@@ -219,6 +219,8 @@
|
|||||||
"duplicateTooltip": "Duplicate this recipe",
|
"duplicateTooltip": "Duplicate this recipe",
|
||||||
"duplicated": "Recipe duplicated",
|
"duplicated": "Recipe duplicated",
|
||||||
"forkedFrom": "Forked from {title}",
|
"forkedFrom": "Forked from {title}",
|
||||||
|
"forkedByCountSingular": "Forked by 1 other",
|
||||||
|
"forkedByCountPlural": "Forked by {count} others",
|
||||||
"pairingFindingLabel": "Finding perfect pairings…",
|
"pairingFindingLabel": "Finding perfect pairings…",
|
||||||
"pairingSuggestButton": "Suggest pairings",
|
"pairingSuggestButton": "Suggest pairings",
|
||||||
"pairingRegenerate": "Regenerate",
|
"pairingRegenerate": "Regenerate",
|
||||||
|
|||||||
@@ -220,6 +220,8 @@
|
|||||||
"duplicateTooltip": "Dupliquer cette recette",
|
"duplicateTooltip": "Dupliquer cette recette",
|
||||||
"duplicated": "Recette dupliquée",
|
"duplicated": "Recette dupliquée",
|
||||||
"forkedFrom": "Copiée depuis {title}",
|
"forkedFrom": "Copiée depuis {title}",
|
||||||
|
"forkedByCountSingular": "Copiée par 1 autre personne",
|
||||||
|
"forkedByCountPlural": "Copiée par {count} autres personnes",
|
||||||
"pairingFindingLabel": "Recherche des meilleurs accords…",
|
"pairingFindingLabel": "Recherche des meilleurs accords…",
|
||||||
"pairingSuggestButton": "Suggérer des accompagnements",
|
"pairingSuggestButton": "Suggérer des accompagnements",
|
||||||
"pairingRegenerate": "Régénérer",
|
"pairingRegenerate": "Régénérer",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.81.0",
|
"version": "0.82.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.81.0",
|
"version": "0.82.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user