diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0d5f9..4e22838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ 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.53.1 — 2026-07-19 16:10 + +### Fixed +- Drag-reorder in a collection now animates live as you drag instead of only updating on drop, and the grip handle is now visible instead of an invisible hover target. +- "Edit" was untranslated everywhere it's used (missing from both languages, not just French). +- Recipes generated via "Generate meal", weekly meal plan, or "Adapt recipe" never had their language recorded — this made the Translate button appear even though the recipe was already in your language. Fixed at the source for all three. +- The Translate dialog itself was hardcoded in English regardless of app language — now fully translated. + +### Added +- Recipe tags now show on the recipe detail page. +- Collection header actions are now icon-only with tooltips, matching the recipe page. +- Searching collections now also matches recipes inside them. +- Explore links to "Discover collections" next to its tabs. + ## 0.53.0 — 2026-07-19 15:20 ### Added diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index 83424dc..383a48d 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -14,6 +14,7 @@ import { EditCollectionDialog } from "@/components/collections/edit-collection-d import { DeleteCollectionDialog } from "@/components/collections/delete-collection-dialog"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; import { EmptyState } from "@/components/shared/empty-state"; @@ -71,40 +72,46 @@ export default async function CollectionPage({ params }: Params) { )} -
- {recipeList.length > 0 && ( - <> - - - {m.collections.exportPdf} - - +
+ {recipeList.length > 0 && ( + <> + + + + + } /> + {m.collections.exportPdf} + + + + )} + {isOwner && } + {isOwner && } + {isOwner && ( + - - )} - {isOwner && } - {isOwner && } - {isOwner && ( - - )} - {isOwner && } - {!isOwner && col.isPublic && ( - - )} -
+ )} + {isOwner && } + {!isOwner && col.isPublic && ( + + )} +
+ {recipeList.length === 0 ? ( diff --git a/apps/web/app/(app)/collections/page.tsx b/apps/web/app/(app)/collections/page.tsx index 3248239..afdc3f5 100644 --- a/apps/web/app/(app)/collections/page.tsx +++ b/apps/web/app/(app)/collections/page.tsx @@ -19,7 +19,18 @@ export default async function CollectionsPage({ const query = q?.trim(); const where = query - ? and(eq(collections.userId, session.user.id), or(ilike(collections.name, `%${query}%`), ilike(collections.description, `%${query}%`))) + ? and( + eq(collections.userId, session.user.id), + or( + ilike(collections.name, `%${query}%`), + ilike(collections.description, `%${query}%`), + sql`exists ( + select 1 from collection_recipes cr + inner join recipes r on r.id = cr.recipe_id + where cr.collection_id = ${collections.id} and r.title ilike ${`%${query}%`} + )` + ) + ) : eq(collections.userId, session.user.id); const [userCollections, countRows] = await Promise.all([ diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index bdc8479..a8d59e5 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -353,6 +353,14 @@ export default async function RecipePage({ params }: Params) { ))} )} + + {recipe.tags.length > 0 && ( +
+ {recipe.tags.map((tag) => ( + {tag} + ))} +
+ )} {/* Cover photo */} diff --git a/apps/web/app/api/v1/ai/adapt/[id]/route.ts b/apps/web/app/api/v1/ai/adapt/[id]/route.ts index 34d393d..51904b1 100644 --- a/apps/web/app/api/v1/ai/adapt/[id]/route.ts +++ b/apps/web/app/api/v1/ai/adapt/[id]/route.ts @@ -52,6 +52,8 @@ export async function POST(req: NextRequest, { params }: Params) { if (!configResult.ok) return configResult.response; const aiConfig = configResult.data; + const locale = (session!.user as { locale?: string }).locale ?? "en"; + const result = await withAiQuota(userId, session!.user.tier as "free" | "pro" | "family", () => adaptRecipe( { @@ -66,7 +68,7 @@ export async function POST(req: NextRequest, { params }: Params) { extraConstraints: parsed.data.extraConstraints, }, { ...aiConfig, userContext: privateBio ?? undefined }, - (session!.user as { locale?: string }).locale ?? "en" + locale ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; @@ -100,6 +102,7 @@ export async function POST(req: NextRequest, { params }: Params) { description: adapted.description, baseServings: adapted.baseServings, visibility: "private", + language: locale, difficulty: adapted.difficulty, prepMins: adapted.prepMins, cookMins: adapted.cookMins, diff --git a/apps/web/app/api/v1/ai/generate-meal/route.ts b/apps/web/app/api/v1/ai/generate-meal/route.ts index 15b968a..4090e00 100644 --- a/apps/web/app/api/v1/ai/generate-meal/route.ts +++ b/apps/web/app/api/v1/ai/generate-meal/route.ts @@ -8,6 +8,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { checkAndIncrementTierLimit, incrementUsage, TierLimitError } from "@/lib/tiers"; import { generateMeal, MEAL_COURSES } from "@/lib/ai/features/generate-meal"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; +import { getMessages } from "@/lib/i18n/server"; const Schema = z.object({ collectionId: z.string().min(1), @@ -89,12 +90,13 @@ export async function POST(req: NextRequest) { baseServings: recipe.baseServings, recipeType: recipe.recipeType, visibility: "private", + language: locale, aiGenerated: true, difficulty: recipe.difficulty ?? null, prepMins: recipe.prepMins ?? null, cookMins: recipe.cookMins ?? null, dietaryTags: recipe.dietaryTags ?? {}, - tags: [recipe.course], + tags: [getMessages(locale).collections.course[recipe.course]], }); if (recipe.ingredients.length > 0) { diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index dc343ed..48613b3 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -134,6 +134,7 @@ export async function POST(req: NextRequest) { description: entry.recipe.description, baseServings: entry.servings, visibility: "private", + language: locale, aiGenerated: true, difficulty: entry.recipe.difficulty ?? null, prepMins: entry.recipe.prepMins ?? null, diff --git a/apps/web/components/collections/collection-recipes-grid.tsx b/apps/web/components/collections/collection-recipes-grid.tsx index fc4537c..cb2ca7e 100644 --- a/apps/web/components/collections/collection-recipes-grid.tsx +++ b/apps/web/components/collections/collection-recipes-grid.tsx @@ -16,7 +16,7 @@ import { import { SortableContext, useSortable, - verticalListSortingStrategy, + rectSortingStrategy, arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -49,6 +49,7 @@ function SortableRecipeCard({ onToggle: () => void; dragDisabled: boolean; }) { + const t = useTranslations("collections"); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: recipe.id, disabled: dragDisabled, @@ -60,7 +61,7 @@ function SortableRecipeCard({
{selectMode && ( @@ -74,18 +75,20 @@ function SortableRecipeCard({
)} {!selectMode && !dragDisabled && ( + // Always at least partly visible (not hover-only) — a fully hidden-until-hover + // handle is exactly what made dragging undiscoverable before. )} -
+
{selectMode ? ( ) : ( @@ -207,7 +210,7 @@ export function CollectionRecipesGrid({ collectionId, recipes: initialRecipes }:

{t("noRecipeSearchResults")}

) : ( - r.id)} strategy={verticalListSortingStrategy}> + r.id)} strategy={rectSortingStrategy}>
{filtered.map((recipe) => ( - + + + setOpen(true)} aria-label={tCommon("delete")}> + + + } /> + {tCommon("delete")} + + diff --git a/apps/web/components/collections/edit-collection-dialog.tsx b/apps/web/components/collections/edit-collection-dialog.tsx index a339dd8..14f0d74 100644 --- a/apps/web/components/collections/edit-collection-dialog.tsx +++ b/apps/web/components/collections/edit-collection-dialog.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { Pencil, Tag, X } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; @@ -88,10 +89,16 @@ export function EditCollectionDialog({ return ( <> - + + + setOpen(true)} aria-label={tCommon("edit")}> + + + } /> + {tCommon("edit")} + + diff --git a/apps/web/components/collections/fork-collection-button.tsx b/apps/web/components/collections/fork-collection-button.tsx index be22f9e..4784304 100644 --- a/apps/web/components/collections/fork-collection-button.tsx +++ b/apps/web/components/collections/fork-collection-button.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { GitFork } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { toast } from "sonner"; export function ForkCollectionButton({ collectionId }: { collectionId: string }) { @@ -31,9 +32,15 @@ export function ForkCollectionButton({ collectionId }: { collectionId: string }) } return ( - + + + { void handleFork(); }} disabled={forking} aria-label={t("forkCollection")}> + + + } /> + {forking ? t("forking") : t("forkCollection")} + + ); } diff --git a/apps/web/components/collections/generate-meal-dialog.tsx b/apps/web/components/collections/generate-meal-dialog.tsx index fc58dba..cd24ef3 100644 --- a/apps/web/components/collections/generate-meal-dialog.tsx +++ b/apps/web/components/collections/generate-meal-dialog.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { ChefHat, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -67,10 +68,16 @@ export function GenerateMealDialog({ collectionId }: { collectionId: string }) { return ( <> - + + + setOpen(true)} aria-label={t("generateMeal")}> + + + } /> + {t("generateMeal")} + + diff --git a/apps/web/components/collections/share-collection-button.tsx b/apps/web/components/collections/share-collection-button.tsx index 61f5acf..140a5e3 100644 --- a/apps/web/components/collections/share-collection-button.tsx +++ b/apps/web/components/collections/share-collection-button.tsx @@ -12,6 +12,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Input } from "@/components/ui/input"; import { Select, @@ -116,10 +117,16 @@ export function ShareCollectionButton({ collectionId }: Props) { return ( <> - + + + handleOpenChange(true)} aria-label={tCommon("share")}> + + + } /> + {tCommon("share")} + + diff --git a/apps/web/components/recipe/translate-button.tsx b/apps/web/components/recipe/translate-button.tsx index 57bb02f..53d19a8 100644 --- a/apps/web/components/recipe/translate-button.tsx +++ b/apps/web/components/recipe/translate-button.tsx @@ -17,24 +17,15 @@ import { import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -const LANGUAGES = [ - { code: "French", label: "French" }, - { code: "Spanish", label: "Spanish" }, - { code: "German", label: "German" }, - { code: "Italian", label: "Italian" }, - { code: "Portuguese", label: "Portuguese" }, - { code: "English", label: "English" }, - { code: "Japanese", label: "Japanese" }, - { code: "Chinese", label: "Chinese" }, - { code: "Arabic", label: "Arabic" }, - { code: "Dutch", label: "Dutch" }, - { code: "Polish", label: "Polish" }, - { code: "Russian", label: "Russian" }, -]; +const LANGUAGE_CODES = [ + "French", "Spanish", "German", "Italian", "Portuguese", "English", + "Japanese", "Chinese", "Arabic", "Dutch", "Polish", "Russian", +] as const; export function TranslateButton({ recipeId }: { recipeId: string }) { const t = useTranslations("ai.translate"); const tRecipe = useTranslations("recipe"); + const tCommon = useTranslations("common"); const router = useRouter(); const [open, setOpen] = useState(false); const [targetLanguage, setTargetLanguage] = useState("French"); @@ -82,46 +73,44 @@ export function TranslateButton({ recipeId }: { recipeId: string }) { - Translate Recipe + {t("title")} - - AI will translate the title, description, ingredients, and steps. Quantities and units are preserved. - + {t("description")}
- +
{translating && ( -

This may take 20–30 seconds…

+

{t("wait")}

)}
diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index 9dcc543..b3ba9c5 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight, Shuffle, Users, Rss, UserPlus } from "lucide-react"; +import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight, Shuffle, Users, Rss, UserPlus, FolderOpen } from "lucide-react"; import { useTranslations } from "next-intl"; import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page"; import { RecipeGridCard, type GridCardRecipe } from "@/components/recipe/recipe-grid-card"; @@ -184,6 +184,7 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags const tCommon = useTranslations("common"); const tRecipe = useTranslations("recipe"); const tPeople = useTranslations("people"); + const tCollections = useTranslations("collections"); const [inputValue, setInputValue] = useState(initialQuery); const [query, setQuery] = useState(initialQuery); @@ -512,7 +513,8 @@ export function ExplorePageContent({ trending, recent, initialQuery, popularTags {/* Explore tabs — shown when no query */} {!hasQuery && ( <> -
+
+
+ + + {tCollections("exploreLink")} + +
{tab === "discover" && ( <> diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 59af969..1947a5a 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.53.0"; +export const APP_VERSION = "0.53.1"; export type ChangelogEntry = { version: string; @@ -11,6 +11,22 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.53.1", + date: "2026-07-19 16:10", + fixed: [ + "Drag-reorder in a collection now animates live as you drag instead of only updating on drop (was using a list-only sorting strategy on a multi-column grid), and the grip handle is now visible instead of an invisible hover target.", + "\"Edit\" was untranslated everywhere it's used (missing from both languages, not just French).", + "Recipes generated via \"Generate meal\" or a weekly meal plan, or produced by \"Adapt recipe\", never had their language recorded — this made the Translate button appear even though the recipe was already in your language. Fixed at the source for all three.", + "The Translate dialog itself was hardcoded in English regardless of app language — title, description, language list, and buttons are now fully translated.", + ], + added: [ + "Recipe tags now show on the recipe detail page (previously only visible on grid cards).", + "Collection header actions (print, share, edit, delete, generate meal, fork) are now icon-only with tooltips, matching the recipe page.", + "Searching collections now also matches recipes inside them, not just the collection's own name/description.", + "Explore now links to \"Discover collections\" next to its tabs.", + ], + }, { version: "0.53.0", date: "2026-07-19 15:20", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 1e3a473..757f52a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -387,7 +387,21 @@ "success": "Translation saved as new draft recipe.", "error": "Failed to translate recipe", "saveError": "Failed to save translated recipe", - "wait": "This may take 20–30 seconds" + "wait": "This may take 20–30 seconds", + "languages": { + "French": "French", + "Spanish": "Spanish", + "German": "German", + "Italian": "Italian", + "Portuguese": "Portuguese", + "English": "English", + "Japanese": "Japanese", + "Chinese": "Chinese", + "Arabic": "Arabic", + "Dutch": "Dutch", + "Polish": "Polish", + "Russian": "Russian" + } } }, "settings": { @@ -702,6 +716,7 @@ "deleted": "Deleted", "cancel": "Cancel", "delete": "Delete", + "edit": "Edit", "confirm": "Confirm", "back": "Back", "difficulty": "Difficulty", @@ -1184,6 +1199,7 @@ "recipeCount": "{count} recipe", "recipeCountPlural": "{count} recipes", "discoverTitle": "Discover", + "exploreLink": "Discover collections", "discoverSubtitle": "Trending and recently shared public collections", "myCollectionsLink": "My collections", "trendingThisWeek": "Trending this week", @@ -1214,6 +1230,7 @@ "removeFromCollectionConfirmTitle": "{count, plural, one {Remove 1 recipe from this collection?} other {Remove {count} recipes from this collection?}}", "removeFromCollectionConfirmDescription": "The recipe itself won't be deleted, just removed from this collection.", "reorderFailed": "Failed to save the new order", + "dragToReorder": "Drag to reorder", "editTitle": "Edit collection", "editSuccess": "Collection updated", "editFailed": "Failed to update collection", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 932ce27..2790c15 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -387,7 +387,21 @@ "success": "Traduction enregistrée comme nouveau brouillon.", "error": "Échec de la traduction de la recette", "saveError": "Échec de l'enregistrement de la recette traduite", - "wait": "Cela peut prendre 20 à 30 secondes" + "wait": "Cela peut prendre 20 à 30 secondes", + "languages": { + "French": "Français", + "Spanish": "Espagnol", + "German": "Allemand", + "Italian": "Italien", + "Portuguese": "Portugais", + "English": "Anglais", + "Japanese": "Japonais", + "Chinese": "Chinois", + "Arabic": "Arabe", + "Dutch": "Néerlandais", + "Polish": "Polonais", + "Russian": "Russe" + } } }, "settings": { @@ -702,6 +716,7 @@ "deleted": "Supprimé", "cancel": "Annuler", "delete": "Supprimer", + "edit": "Modifier", "confirm": "Confirmer", "back": "Retour", "difficulty": "Difficulté", @@ -1175,6 +1190,7 @@ "recipeCount": "{count} recette", "recipeCountPlural": "{count} recettes", "discoverTitle": "Découvrir", + "exploreLink": "Découvrir des collections", "discoverSubtitle": "Collections publiques tendances et récemment partagées", "myCollectionsLink": "Mes collections", "trendingThisWeek": "Tendance cette semaine", @@ -1205,6 +1221,7 @@ "removeFromCollectionConfirmTitle": "{count, plural, one {Retirer 1 recette de cette collection ?} other {Retirer {count} recettes de cette collection ?}}", "removeFromCollectionConfirmDescription": "La recette elle-même ne sera pas supprimée, seulement retirée de cette collection.", "reorderFailed": "Échec de l'enregistrement du nouvel ordre", + "dragToReorder": "Glisser pour réorganiser", "editTitle": "Modifier la collection", "editSuccess": "Collection mise à jour", "editFailed": "Échec de la mise à jour de la collection", diff --git a/apps/web/package.json b/apps/web/package.json index 5794220..d9528eb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.53.0", + "version": "0.53.1", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 951fba6..45fb8b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.53.0", + "version": "0.53.1", "private": true, "scripts": { "dev": "pnpm --filter web dev",