From 2682eba2be8b52c1f9d2288362b9a6aa7516f7b3 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 12 Jul 2026 10:53:12 +0200 Subject: [PATCH] fix: presigned upload URLs, card badge alignment, filter menu UX, floating save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Presigned upload URLs were signed against STORAGE_ENDPOINT (the internal docker hostname, e.g. http://minio:9000) instead of a browser-reachable URL — uploads/edits with photos were broken in prod. Now signed with a separate client bound to STORAGE_PUBLIC_URL, which also needed threading through as a Docker build arg (CSP headers are computed at build time) and into compose.prod.yml/ .env.production (gitignored, not committed). - recipe-form.tsx used the wrong i18n namespace for the visibility label (t("recipeForm") instead of t_recipe("recipe")), causing MISSING_MESSAGE in French. - Compact recipe-list view: batch-cook rows showed no dish count, and the date/difficulty columns shifted per-row depending on whether a difficulty badge was present — reserved a fixed-width slot for it. - All three card icon badges (batch-cook, favorite, visibility) now share the same muted color instead of the batch badge standing out. - Recipes filter dropdown: clicking a filter option closed the whole menu, and the tag text input couldn't be typed into (the menu's roving-focus/type-ahead handling was intercepting keystrokes) — menu items now stay open on click, and the tag input stops event propagation so it behaves like a normal text field. - Recipe form: added a floating save/cancel bar so long recipes don't require scrolling back down to save. Co-Authored-By: Claude Sonnet 5 --- Dockerfile | 5 ++++ apps/web/components/recipe/recipe-form.tsx | 14 +++++++++-- apps/web/components/recipe/recipes-grid.tsx | 21 ++++++++++------ apps/web/components/recipe/recipes-header.tsx | 11 ++++++++- .../lib/ai/features/generate-batch-cook.ts | 2 +- apps/web/lib/storage.ts | 24 +++++++++++++++---- apps/web/messages/en.json | 2 +- apps/web/messages/fr.json | 4 ++-- compose.prod.yml | 2 ++ 9 files changed, 66 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index 720e50b..c0da1eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,11 @@ ENV NEXT_PUBLIC_VAPID_PUBLIC_KEY=$NEXT_PUBLIC_VAPID_PUBLIC_KEY ENV NEXT_PUBLIC_GITHUB_ENABLED=$NEXT_PUBLIC_GITHUB_ENABLED ENV NEXT_PUBLIC_DISCORD_ENABLED=$NEXT_PUBLIC_DISCORD_ENABLED ENV NEXT_PUBLIC_AUTHENTIK_ENABLED=$NEXT_PUBLIC_AUTHENTIK_ENABLED +# next.config.ts reads STORAGE_PUBLIC_URL to compute the CSP connect-src/img-src +# allowlist, and next.config.ts's headers() are evaluated at build time — so this +# also needs to be a build arg, not just a runtime env var (unlike STORAGE_ENDPOINT). +ARG STORAGE_PUBLIC_URL +ENV STORAGE_PUBLIC_URL=$STORAGE_PUBLIC_URL RUN pnpm --filter web build # ---- runtime ---- diff --git a/apps/web/components/recipe/recipe-form.tsx b/apps/web/components/recipe/recipe-form.tsx index 899918e..268bd99 100644 --- a/apps/web/components/recipe/recipe-form.tsx +++ b/apps/web/components/recipe/recipe-form.tsx @@ -259,7 +259,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) { } return ( -
+ {/* Basic info */}
@@ -335,7 +335,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
- + setLocal(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") onChange(local); }} + onKeyDown={(e) => { + // Stop the dropdown menu's own keydown handling (roving focus / type-ahead + // search) from intercepting keystrokes meant for this plain text input. + e.stopPropagation(); + if (e.key === "Enter") onChange(local); + }} + onClick={(e) => e.stopPropagation()} onBlur={() => onChange(local)} placeholder={t("filterByTag")} className="w-full h-7 px-2 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring" @@ -217,6 +223,7 @@ export function RecipesHeader({ {Object.entries(VISIBILITY_KEYS).map(([value, key]) => ( pushParams({ visibility: value })} className={cn(initialVisibility === value && "font-medium text-primary")} > @@ -230,6 +237,7 @@ export function RecipesHeader({ {Object.entries(DIFFICULTY_KEYS).map(([value, key]) => ( pushParams({ difficulty: value })} className={cn(initialDifficulty === value && "font-medium text-primary")} > @@ -253,6 +261,7 @@ export function RecipesHeader({ {Object.entries(BATCH_COOK_KEYS).map(([value, key]) => ( pushParams({ batchCook: value })} className={cn(initialBatchCook === value && "font-medium text-primary")} > diff --git a/apps/web/lib/ai/features/generate-batch-cook.ts b/apps/web/lib/ai/features/generate-batch-cook.ts index c408353..a2e6173 100644 --- a/apps/web/lib/ai/features/generate-batch-cook.ts +++ b/apps/web/lib/ai/features/generate-batch-cook.ts @@ -60,7 +60,7 @@ export async function generateBatchCook( ? `Keep techniques at a ${difficulty} skill level.` : ""; - const systemPrompt = `You are a professional meal-prep chef writing ONE unified "batch cooking" recipe page — not several separate recipes. This mirrors real French batch-cooking guides (cuisine-addict.com style): one shopping list, then a single prep session structured in two phases, covering multiple meals for the days ahead. + const systemPrompt = `You are a professional meal-prep chef writing ONE unified "batch cooking" recipe page — not several separate recipes. This mirrors real French batch-cooking guides: one shopping list, then a single prep session structured in two phases, covering multiple meals for the days ahead. Structure: 1. A merged, deduplicated ingredient list across all dishes (identical rawName spelling for anything shared). diff --git a/apps/web/lib/storage.ts b/apps/web/lib/storage.ts index 5aec3d1..68c3358 100644 --- a/apps/web/lib/storage.ts +++ b/apps/web/lib/storage.ts @@ -5,19 +5,33 @@ const bucket = process.env["STORAGE_BUCKET"] ?? "epicure-uploads"; const endpoint = process.env["STORAGE_ENDPOINT"] ?? "http://localhost:9000"; const publicUrl = process.env["STORAGE_PUBLIC_URL"] ?? "http://localhost:9000"; +const credentials = { + accessKeyId: process.env["STORAGE_ACCESS_KEY"] ?? "minioadmin", + secretAccessKey: process.env["STORAGE_SECRET_KEY"] ?? "minioadmin", +}; + const s3 = new S3Client({ region: process.env["STORAGE_REGION"] ?? "us-east-1", endpoint, forcePathStyle: true, - credentials: { - accessKeyId: process.env["STORAGE_ACCESS_KEY"] ?? "minioadmin", - secretAccessKey: process.env["STORAGE_SECRET_KEY"] ?? "minioadmin", - }, + credentials, +}); + +// Presigned URLs are PUT/GET directly by the browser, so they must be signed +// against an endpoint the browser can actually resolve — not the internal +// docker hostname `s3` (and `endpoint`) use for server-to-server calls. Signing +// is pure crypto (no network call), so a second client pointed at the public +// URL is safe even though it's never used to make a real request itself. +const presignS3 = new S3Client({ + region: process.env["STORAGE_REGION"] ?? "us-east-1", + endpoint: publicUrl, + forcePathStyle: true, + credentials, }); export async function createPresignedUploadUrl(key: string, contentType: string): Promise { const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }); - return getSignedUrl(s3, command, { expiresIn: 300 }); + return getSignedUrl(presignS3, command, { expiresIn: 300 }); } export async function deleteObject(key: string): Promise { diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index e6040a9..b76aed3 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -562,7 +562,7 @@ "dietaryPrefsPlaceholder": "e.g. vegetarian, no shellfish", "anyDifficulty": "Any", "generate": "Generate", - "generating": "Generating… this can take a minute", + "generating": "Generating…", "generateSuccess": "Batch-cooking session created", "generateFailed": "Failed to generate" }, diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 4b75d2c..73e0b0d 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -976,8 +976,8 @@ "reactionFailed": "Échec de la mise à jour de la réaction", "addReaction": "Ajouter la réaction {type}", "removeReaction": "Retirer la réaction {type}", - "favoriteAdd": "Enregistrer la recette", - "favoriteRemove": "Retirer la recette des enregistrements" + "favoriteAdd": "Favoris", + "favoriteRemove": "Retirer des favoris" }, "cookingMode": { "cooking": "En cuisine", diff --git a/compose.prod.yml b/compose.prod.yml index ca1c47a..55866cd 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -74,11 +74,13 @@ services: NEXT_PUBLIC_GITHUB_ENABLED: ${NEXT_PUBLIC_GITHUB_ENABLED} NEXT_PUBLIC_DISCORD_ENABLED: ${NEXT_PUBLIC_DISCORD_ENABLED} NEXT_PUBLIC_AUTHENTIK_ENABLED: ${NEXT_PUBLIC_AUTHENTIK_ENABLED} + STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL} restart: always environment: DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 STORAGE_ENDPOINT: http://minio:9000 + STORAGE_PUBLIC_URL: ${STORAGE_PUBLIC_URL} STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER} STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD} STORAGE_BUCKET: epicure-uploads