@@ -335,7 +335,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
- {t("visibilityMenuLabel")}
+ {t_recipe("visibilityMenuLabel")}
+ {/* Floating save bar — the form above can get long, this stays reachable without scrolling back down */}
+
+
+ {saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
+
+
+ {t_common("cancel")}
+
+
+
diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx
index eb89c73..496a54e 100644
--- a/apps/web/components/recipe/recipes-grid.tsx
+++ b/apps/web/components/recipe/recipes-grid.tsx
@@ -154,7 +154,7 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
{recipe.isBatchCook && (
- } />
+ } />
{t("batchCookBadge")}
@@ -201,7 +201,7 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b
{recipe.isBatchCook && (
- } />
+ } />
{t("batchCookBadge")}
@@ -240,6 +240,7 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b
function CompactRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
const t = useTranslations("recipe");
+ const tBatch = useTranslations("batchCooking");
const { locale } = useLocale();
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
@@ -256,20 +257,26 @@ function CompactRow({ recipe, selected, selectMode }: { recipe: Recipe; selected
{recipe.title}
- {recipe.baseServings}
+ {recipe.isBatchCook && recipe.dishCount ? (
+ {tBatch("dishCount", { count: recipe.dishCount })}
+ ) : (
+ {recipe.baseServings}
+ )}
{totalMins > 0 && (
{t("total", { mins: totalMins })}
)}
- {recipe.difficulty && (
- {t(`difficulty.${recipe.difficulty}`)}
- )}
+
+ {recipe.difficulty && (
+ {t(`difficulty.${recipe.difficulty}`)}
+ )}
+
{recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })}
{recipe.isBatchCook && (
- } />
+ } />
{t("batchCookBadge")}
diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx
index ece6407..7cda847 100644
--- a/apps/web/components/recipe/recipes-header.tsx
+++ b/apps/web/components/recipe/recipes-header.tsx
@@ -27,7 +27,13 @@ function TagFilterInput({ value, onChange }: { value: string; onChange: (v: stri
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