Commit Graph

124 Commits

Author SHA1 Message Date
Arnaud adaa837564 feat: clear chat history manually, auto-expire old history after 90 days
Manual: DELETE /api/v1/ai/chat-history (scoped by recipeId or scope=general,
matching GET's scoping) plus a trash-icon button + confirm dialog in both
chat panels' headers.

Automatic: new internal cron endpoint (chat-cleanup), same shared-secret
pattern as the existing leftover-reminders/weekly-digest crons, deletes
any chat_messages older than 90 days. Wired into cron/crontab (daily,
03:00 UTC) and the Dockerfile's cron stage.

Verified locally: cleared a real conversation through the actual UI and
confirmed it didn't come back on reopen (not just cleared client-side);
inserted a 100-day-old and a 5-day-old message directly, called the cron
endpoint with the real shared-secret check, confirmed only the old one
was deleted and the recent one survived; confirmed the endpoint 401s with
no/wrong secret.
2026-07-12 23:27:05 +02:00
Arnaud 9549684254 chore: bump version to 0.8.0, changelog entry 2026-07-12 22:38:59 +02:00
Arnaud 96ef5b8379 feat: persist AI chat history, add search across it
Both chat panels (per-recipe and the general cooking assistant) previously
kept messages in component state only — closing the sheet or reloading
the page lost everything. Both POST routes now persist question+answer
pairs to a new chat_messages table (recipeId null for the general
assistant), and both panels load their own history back on open.

New GET /api/v1/ai/chat-history (scoped by recipeId, or scope=general for
the homepage assistant, or neither to search across everything) backs a
new search control in both panels' header — debounced, shows matched
messages with date and (when searching across everything) which recipe
they belonged to.

Verified locally: asked a real question, closed and reopened the panel
and confirmed the conversation reloaded, then searched a keyword from
that conversation and confirmed both the question and answer surfaced
in the results dropdown.
2026-07-12 22:37:39 +02:00
Arnaud 0062220d8e feat: read-only API key scoping
New keys can be created as "Full access" (default, unchanged) or
"Read-only" — read-only keys can only make GET/HEAD/OPTIONS requests,
enforced once in requireSessionOrApiKey (lib/api-auth.ts) rather than in
every route, since a route has no way to know a request came from a
scoped key without that check. Existing keys default to full access —
no behavior change for anyone who doesn't opt in.

Also included in this migration: the chat_messages table for the
next commit (chat history persistence) — generated together since both
touched packages/db/src/schema/users.ts in the same pass.

Verified locally: created both a read-only and a full-access key,
confirmed GET succeeds and POST 403s on the read-only key, confirmed
POST still works on the full-access key, and checked the scope badges
render correctly in the real Settings → API Keys UI.
2026-07-12 22:37:14 +02:00
Arnaud b2f2274673 chore: bump version to 0.7.1, changelog entry 2026-07-12 21:36:32 +02:00
Arnaud adc88e63b0 fix: cooking chatbot button too high; chatbots leaked underlying model name
Moved the homepage cooking-assistant button from bottom-24 to bottom-16 —
still clears the recipe list's floating bulk-action bar, just not as far.

Both chat system prompts (cooking-chat and the per-recipe chat) now
explicitly identify as "Epicure" and are told never to name the underlying
model/provider if asked — previously they'd just answer honestly with
whatever the model calls itself.

Verified locally: screenshotted the new button position, and asked "what
model or AI are you" through the real UI — response identified as Epicure
with no model name.
2026-07-12 21:35:32 +02:00
Arnaud 8ec11e9463 chore: bump version to 0.7.0, changelog entry 2026-07-12 19:36:37 +02:00
Arnaud 2ffa05e4cf feat: general cooking-question chatbot on the recipes homepage
The existing recipe chat (RecipeChatPanel) is tightly scoped to one
recipe — recipeId is required and its system prompt embeds that recipe's
full ingredient/step context. Added a sibling, not a modification: a new
floating assistant on the recipes list page for questions that aren't
about any specific recipe (technique, substitutions, timing, food safety).

New /api/v1/ai/cooking-chat route (same quota/rate-limit/model-resolution
pattern as recipe-chat, minus the recipe lookup) and CookingAssistantPanel
component (same floating-button + Sheet UI). Positioned at bottom-24
rather than bottom-6 so it doesn't sit in the same band as the recipe
list's floating bulk-action bar during select mode.

Verified locally: asked a real cooking question through the actual UI,
got a correctly-formatted markdown answer with no recipe context leaking
in (confirmed the request payload only carries the question, no recipeId).
2026-07-12 19:35:00 +02:00
Arnaud 9a448ef34d fix: add missing backlink from webhooks documentation page to webhooks settings 2026-07-12 19:34:48 +02:00
Arnaud eed57cd10b fix: API keys always showed "never used" — middleware blocked them entirely
proxy.ts required a session cookie for every non-public /api/v1/* request,
rejecting with 401 before the request ever reached requireSessionOrApiKey
(lib/api-auth.ts) — the only place that actually verifies a Bearer API key
and updates lastUsedAt. Pure API-key clients never send a session cookie,
so every single API-key request was blocked at the middleware layer; the
lastUsedAt update code was correct but unreachable.

Now lets requests with an `Authorization: Bearer ek_...` header through to
the route, which still does the real verification (and 401s itself on an
invalid/unknown key) — middleware just stops pre-emptively rejecting valid
ones. Also added error logging to the fire-and-forget lastUsedAt update,
previously silent on failure.

Verified locally: hashed a raw key, confirmed it matched the stored hash
(so the lookup itself was never the problem), reproduced the 401 against
the unpatched middleware, then confirmed both the 200 response and
lastUsedAt populating correctly after the fix — visible in the real
Settings → API Keys UI.
2026-07-12 19:34:25 +02:00
Arnaud b69f5845c3 chore: bump version to 0.6.0, changelog entry 2026-07-12 19:09:10 +02:00
Arnaud e678e21ee8 fix: API Reference page (/docs) blank — CSP blocked the Scalar bundle
script-src had no allowance for cdn.jsdelivr.net, which is where the
Scalar API-reference viewer's JS is loaded from — the script itself
never ran. Added it to script-src/style-src/font-src/connect-src.

(User-confirmed before applying — widens what's allowed to load, so
wanted explicit sign-off rather than just doing it in response to a
console error.)
2026-07-12 19:07:43 +02:00
Arnaud 4d5269aced feat: granular per-category push notification settings
New Settings → Notifications section with a toggle per category (follow,
comment, reply, reaction, rating, mention, leftover-expiring, shared
shopping list) — previously it was all-or-nothing (browser permission only).

userNotificationPrefs (one row per user, defaults all-on so existing users
see no behavior change until they opt out of something). Gated the push
send in the three places that dispatch one: lib/notifications.ts (the 6
in-app notification types), the leftover-expiry cron, and shopping-list-notify
— in-app notification-center entries and email are unaffected, this only
gates the push itself, matching the literal ask.

Verified locally: GET/PUT round-trips correctly, disabling a category
and confirming the settings page renders all 8 toggles.
2026-07-12 19:07:32 +02:00
Arnaud e78c959c49 fix: push-notification toggle in settings always showed as off
Never checked the browser's actual push subscription on mount — state
initialized to false unconditionally, so the button looked reset to
"Enable notifications" on every page load even when notifications were
genuinely still active. Now checks navigator.serviceWorker + getSubscription()
on mount and initializes from that.
2026-07-12 19:06:54 +02:00
Arnaud 5a9e306357 fix: close SSRF/rebinding, IDOR, and stale-session authz gaps found in audit
Bump to 0.5.1. Fixes: unfollowed-redirect SSRF + DNS-rebinding in AI
url-import and webhook dispatch (new safeFetch with IP-pinned undici
dispatcher); cross-user photo deletion via unvalidated recipe/review
storage keys; comment-moderation and tier-quota checks trusting a
stale cached session role/tier instead of the DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:05:20 +02:00
Arnaud 4f36ce24b2 chore: bump version to 0.5.0, changelog entry 2026-07-12 18:37:55 +02:00
Arnaud aa6bd03c5e feat: push notification when a shared shopping list is updated
Shared shopping lists (owner + collaborator-invite members) had no
real-time coordination signal — no way to know someone else just checked
off milk or added items while you're mid-aisle. Notifies every other
member (excluding the actor) via push when an item is checked off or
items are added; no-op for solo unshared lists.

Deliberately skips the notifications table/notification-center (same
pattern as the existing leftover-expiry cron) — this is a lightweight
best-effort ping, not a persistent event, so it avoids a notification_type
enum migration and a new FK column for something transient.

Verified locally: added a real member to a shared list, registered a
push subscription, confirmed the check-off request's added latency
(945ms vs ~20ms baseline) shows the actual webpush call fired against
the recipient — individual subscription failures are swallowed by design
(Promise.allSettled in the existing sendPushNotification, unchanged);
confirmed a solo list stays fast (no recipients to notify, true no-op).
2026-07-12 18:36:20 +02:00
Arnaud 9566e19cd0 fix: weekly meal-plan nutrition compared a week's total against a daily goal
userNutritionGoals are daily targets (the nutrition diary already compares
a single day's totals against them directly) — but the weekly meal-plan
route summed an entire week's entries and compared that raw total straight
against the same daily number, so coverage read roughly 7x too high.

Now computes a daily average (across days that actually have planned
meals) and compares that against the goal instead, adds a per-day
breakdown (byDay) for a future day-view, and tracks unknownCount for
entries whose recipe has no nutrition data yet, matching the diary route.

Also fixed a real bug this surfaced: meal-plan/page.tsx and print/meal-plan
parsed the ?week= date via new Date(dateStr) (UTC midnight) then read
.getDay() (local time) — in positive-UTC-offset timezones this resolves to
the wrong Monday, and the reverse (Date -> string via toISOString()) has
the same mismatch in the other direction. Both now do local y/m/d math
throughout.

Verified locally: correct daily-average math end to end (recipe entry +
batch-dish entry, which already attributed nutrition correctly via its
required parent recipeId — no separate fix needed there), and confirmed
the meal-plan page now resolves the right week and renders real coverage
bars against actual planned entries.
2026-07-12 18:35:58 +02:00
Arnaud a9dc1b63c1 fix: batch-cook dishes never deducted pantry when marked cooked
Ingredients aren't attributable to individual batch dishes (they're one
merged/shared list for the whole prep session, unlike steps which have
`appliesTo`) — the cooked route's pantry-deduction block was unconditionally
skipped whenever batchDishId was set, so pantry was never touched for any
batch-cook recipe.

Deducts once, on the first dish marked cooked for that recipe+user (checked
via prior cookingHistory rows) — matches the fridge/freezer-day design
intent of "cook the batch once, eat dishes over several days" rather than
deducting (or trying to deduct) per individual dish.

Verified locally: first dish cooked deducted the full recipe's ingredients
from pantry; second dish cooked did not double-deduct.
2026-07-12 18:35:32 +02:00
Arnaud 1bcea2f273 chore: bump version to 0.4.0, changelog entry 2026-07-12 16:13:09 +02:00
Arnaud a1a11ff5e5 feat: share shopping lists via a public link
Mirrors the existing recipe public-share pattern (/r/[id], gated by a
visibility flag, no auth required) — shopping lists previously only
supported private per-user collaborator invites (email + viewer/editor
role), with no way to hand someone a link who isn't already a member.

- shoppingLists.isPublic (owner-only toggle, same access-control pattern
  already used for renaming/deleting the list).
- New public /s/[id] route added to PUBLIC_PATHS — read-only aisle-grouped
  view with a signup CTA for logged-out visitors, styled after /r/[id].
- The existing collaborator-invite dialog (ShareShoppingListButton) now
  also has a "Public link" toggle + copy-link button at the top, so
  there's one Share entry point for both private and public sharing.

Verified locally: toggling public via the real dialog UI persists
correctly (confirmed via DB + a fresh curl PATCH to rule out a client
race in my own test script), and the public link loads with zero auth
for a logged-out browser context.
2026-07-12 16:11:57 +02:00
Arnaud e98a9c3bb7 feat: show an "imported from" badge on recipe cards
sourceUrl was already persisted and shown on the recipe detail page, but
never surfaced on cards in the grid/list/compact views or the simpler
RecipeCard (collections) — added a small external-link icon with a
tooltip naming the source hostname, sized identically to the existing
batch-cook badge so it doesn't reintroduce the compact-view alignment
bug fixed earlier this session.
2026-07-12 16:11:26 +02:00
Arnaud d7e0d7eada feat: profile pictures — Gravatar fallback + custom upload
New users get a Gravatar-backed avatar automatically (computed from email
at signup); users can upload a custom photo instead via Settings, or
revert to the Gravatar/initials fallback. avatarUrl stays the single
resolved value (custom photo, OAuth photo, or precomputed Gravatar URL)
so every existing avatar-rendering spot across the app needs zero changes.

- users.hasCustomAvatar tracks whether avatarUrl is a real upload vs a
  computed Gravatar fallback, so "remove photo" knows what to revert to.
- New /api/v1/upload/avatar-presign route (session-scoped, generic —
  the existing recipe-photo presign route required a recipeId).
- CSP img-src needed www.gravatar.com added, or every browser blocks
  the fallback avatar outright.
- Settings page now reads avatarUrl from a fresh DB query instead of
  Better Auth's session object — the session cookie cache (5 min TTL)
  was serving a stale image right after upload, showing the initials
  fallback until the cache happened to expire.

Verified locally: signup auto-sets a Gravatar URL, upload persists
across reload, remove correctly reverts to Gravatar/initials.
2026-07-12 16:10:54 +02:00
Arnaud c54c554374 chore: rename unused recipeForm.visibility key to visibilityMenuLabel for clarity 2026-07-12 15:43:33 +02:00
Arnaud b627dda5bf fix: batch-cook recipes shift left in compact view
The dish-count/servings and time spans had no fixed width (unlike the
difficulty badge slot, which reserves w-16 even when empty) — a batch-cook
recipe's longer "N dishes"/"Nm total" text, or a missing time value,
changed that column's width row-to-row, shifting the date and icon
cluster after it. Gave both spans fixed widths so every compact row has
identical column layout regardless of content.
2026-07-12 15:37:36 +02:00
Arnaud a42fd497b0 chore: bump version to 0.3.1, changelog entry 2026-07-12 15:08:07 +02:00
Arnaud 13128df19f feat: locale-based generation, describe field for batch cooking; fix bulk-bar width and sourceUrl not saving
- AI recipe generation always used the app's own locale rather than a
  separate language picker — removed the picker, generation and the
  saved recipe's language now both follow useLocale() directly.
- Bulk-select action bar had an ungated max-w-md that capped it at 448px
  even past the sm: breakpoint where sm:w-auto was supposed to let it
  grow to content — added sm:max-w-none so desktop never scrolls.
- Batch-cooking generation had no free-text "describe" field like the
  standard recipe generator does — added one (BatchCookFields, both the
  standalone dialog and the AI dialog's batch tab), threaded through the
  API route and generateBatchCook's prompt as additional guidance.
- Recipes imported from a URL never actually got their sourceUrl
  persisted — CreateRecipeSchema didn't declare the field, so Zod
  silently stripped it before it ever reached the insert. The detail
  page's "Source: <hostname>" link already existed but was dead code
  for every real import until now.

Verified all 4 live: generate dialog has no language selector, batch
tab has a working describe textarea, bulk-select bar renders at full
content width with zero horizontal overflow on desktop, and a recipe
created with sourceUrl now round-trips and renders its source link.
2026-07-12 15:06:48 +02:00
Arnaud 5b8c50dd1e chore: bump version to 0.3.0, changelog entry for manual batch-cook editing 2026-07-12 14:46:35 +02:00
Arnaud 61014a3224 feat: manually create and edit batch-cook recipes
recipe-form.tsx previously had no batch-cook awareness at all — editing an
AI-generated batch-cook recipe and saving silently corrupted it (steps lost
their per-dish `appliesTo` tags, recipeBatchDishes rows went stale/orphaned,
since the create/update API schemas and edit-page query never touched them).

Adds a "batch-cook recipe" toggle to the form, an editable dish list (name,
description, fridge days, freezer-friendly/note, day-of instructions), and
per-step dish tagging (click a dish chip to mark which dish(es) that step
advances; empty = shared prep). Renaming a dish propagates to any step
still tagged with the old name instead of orphaning it. Wired through
Create/Update API schemas and the edit page's query/payload.

Verified locally: created a 2-dish batch recipe from scratch through the
real form, confirmed it renders identically to an AI-generated one (grouped
steps, dishes & storage cards), edited it, renamed a dish, reloaded, and
confirmed the step tag followed the rename rather than orphaning.
2026-07-12 14:45:24 +02:00
Arnaud 20e4ce3597 chore: bump version to 0.2.2, changelog entry for storage-URL fix 2026-07-12 13:54:23 +02:00
Arnaud 8df292dfee fix: photo thumbnails always used localhost:9000 in production
getPublicUrl() runs in the browser (called from client components rendering
recipe thumbnails), but read the plain STORAGE_PUBLIC_URL env var — never
inlined into the client bundle, so every browser fell back to the hardcoded
localhost:9000 default regardless of the real deployed storage domain,
tripping CSP img-src and mixed-content blocks in production. Added a
NEXT_PUBLIC_STORAGE_PUBLIC_URL build arg (Dockerfile, compose.prod.yml) wired
from the same STORAGE_PUBLIC_URL value, and getPublicUrl() now reads that.

Verified locally: building with a fake public storage domain set shows it
correctly inlined into the client JS chunk (previously only the localhost
fallback ever appeared there).
2026-07-12 13:53:15 +02:00
Arnaud b160fdc338 chore: bump version to 0.2.1, changelog entry for photo fixes 2026-07-12 13:21:07 +02:00
Arnaud 546ba98d2f fix: photos lost on save, multi-upload only kept last file, thumbnails 400
recipe-form/API routes were never sending/persisting photos on create+update.
PhotoUploader accumulated uploads via a stale closure over the photos prop, so
only the last file of a multi-select survived. Once both were fixed, thumbnails
still 400'd because Next's image optimizer blocks upstream hosts resolving to
private/loopback IPs (localhost:9000 MinIO) — skip optimization for
storage-hosted photos since they're already web-sized user uploads.
2026-07-12 13:16:37 +02:00
Arnaud 77f43ee673 fix: publish MinIO port in prod so the browser can actually reach it
minio had no ports: mapping at all in compose.prod.yml — only reachable
inside the docker network, never by the browser. No STORAGE_PUBLIC_URL
value could have fixed uploads without this: the browser's direct PUT
to a presigned URL has no route to the container regardless of what
hostname the URL uses. Published on STORAGE_PORT (default 9000),
mirroring how `web` is exposed via WEB_PORT for the Traefik LXC to
route to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:52:34 +02:00
Arnaud ccc41a2018 fix: MinIO CORS blocking browser uploads, changelog admin-only
- MinIO had no CORS config at all, so the browser's direct PUT to a
  presigned URL (cross-origin: app on :3001/:3000, storage on :9000)
  was blocked outright. Added MINIO_API_CORS_ALLOW_ORIGIN — "*" in
  dev, the app's own origin in prod. Verified end-to-end: photo
  upload now succeeds with zero console errors.
- Removed the public /changelog page and its account-menu link —
  changelog is admin-only now (/admin/changelog).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:33:53 +02:00
Arnaud 321507b570 fix: docker build crash when STORAGE_PUBLIC_URL unset
compose.prod.yml passes STORAGE_PUBLIC_URL through ${STORAGE_PUBLIC_URL}
build args/env — when unset in .env.production, docker-compose
substitutes an empty string, not an absent var. ?? only falls back on
null/undefined, so next.config.ts's `new URL("")` threw and failed the
whole `pnpm --filter web build` step. Switched to || in next.config.ts
and lib/storage.ts so empty string also falls back to the default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:16:11 +02:00
Arnaud 83d18f5ad4 feat: add changelog and versioning, surface it in admin
Bumps version to 0.2.0 (root + apps/web package.json) and adds
CHANGELOG.md as the canonical human-readable history, mirrored by
apps/web/lib/changelog.ts as the in-app data source (single
ChangelogList component renders both).

- /changelog: user-facing page, linked from the account dropdown.
- /admin/changelog: same list, plus a "Version" card on the admin
  overview dashboard linking to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:06:07 +02:00
Arnaud 2682eba2be fix: presigned upload URLs, card badge alignment, filter menu UX, floating save
- 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 <noreply@anthropic.com>
2026-07-12 10:53:12 +02:00
Arnaud 1baf5ce20e fix: recipe card badge placement, missing tooltips, untranslated label
- Batch-cook indicator moved from next to the title into the icon row
  alongside favorite/visibility (all three now get a tooltip, matching
  the existing favorite-button pattern).
- Favorite button's tooltip text was hardcoded English ("Save"/
  "Saved") instead of using the translation keys already used for its
  aria-label.
- Shortened the batch-cook generate dialog's "Generating…" label
  (fr.json edited by user) — the long text was the real cause of the
  modal overlap, not the dialog structure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 10:29:13 +02:00
Arnaud 002f14ced0 feat: batch-cook shopping list (already worked) + leftover expiry reminders
Shopping list add already worked generically for batch-cook recipes —
no code needed there.

New: mark a specific batch-cook dish as "cooked today", track its
fridge expiry (cookingHistory.batchDishId), surface a "Leftovers
expiring soon" widget on the pantry page, and send a daily push+email
reminder via a new /api/internal/cron/leftover-reminders endpoint
(mirrors the weekly-digest cron pattern; doesn't use the social
notifications table, which requires a non-null actor and isn't built
for self-reminders).

Also fixes, from user-reported bugs:
- Recipe cards showed no batch-cook badge/dish-count/prep-time in some
  views — added dishCount + prepMins/cookMins (now generated by the AI
  and persisted) to the card component and /recipes query.
- Batch-cook descriptions occasionally contained raw markdown
  (**bold**) — added explicit "plain prose only" prompt instructions
  and a stripMarkdown() defensive fallback at render time.
- Truncated/cut-off descriptions — the generateObject call had no
  maxOutputTokens set, so long structured responses could get cut off
  mid-field; now capped explicitly at 8000.
- Generate dialogs (batch-cook + the main AI dialog) could show
  buttons unreachable once the progress bar appeared mid-generation —
  restructured so the action row is pinned outside the scrollable
  content area, not affected by content height changes.
- /api/internal/* routes were being redirected to /login by middleware
  before their own CRON_SECRET check ever ran (pre-existing bug,
  affected the weekly-digest cron too) — added to PUBLIC_PATHS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 10:03:52 +02:00
Arnaud 646c97128d refactor: fold batch-cooking into the main recipes list and Generate dialog
Batch-cook recipes no longer live in a separate /batch-cooking section:
- Removed the dedicated page/route and its nav button.
- /recipes shows both kinds together, with a chef-hat badge marking
  batch sessions, and a filter to isolate/hide them.
- "Batch cooking" is now a third tab in the existing "Generate recipe
  with AI" dialog (alongside Describe/Photo) instead of a separate
  button — one AI entry point instead of three.
- Extracted the counters/difficulty/dietary form into BatchCookFields,
  shared between that dialog and the meal-planner's batch-cooking
  dialog.
- Also fixed a dialog resize issue (progress bar mounting mid-generate
  grew the dialog's height, which the positioning library didn't
  always handle) by reserving space up front and capping dialog height
  with a scroll fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 09:31:04 +02:00
Arnaud f29fa531a1 feat: add batch-cooking generation entry point to meal planner
Adds a "Batch cooking" button next to "Generate with AI" on the meal
plan page, opening the same wizard used on /batch-cooking. Closes the
loop on the original ask: batch-cooking sessions can now be started
directly from the meal planner, not just from the recipes list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 02:45:53 +02:00
Arnaud ba1614073b feat: adapt recipe surfaces for batch-cook sessions
- Recipes page action buttons reordered/restyled to push AI generation
  and batch cooking ahead of manual recipe creation.
- Recipe detail page hides meal/drink pairing (doesn't make sense for
  a multi-dish batch session).
- Print pages force light mode regardless of app theme (dark bg +
  hardcoded dark text was unreadable) and render sectioned steps +
  dishes/storage for batch-cook recipes.
- Markdown export mirrors the same sectioned structure.
- Cooking mode tags each step with which dish(es) it belongs to.
- Meal planner: mealPlanEntries.batchDishId lets a slot point at one
  specific dish within a batch session; picking a batch-cook recipe
  now prompts for which dish, and the grid shows the dish name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 02:32:57 +02:00
Arnaud 8e83cc0dc7 fix: wait for minio-init before starting web
web only depended on minio being healthy, not on minio-init having
created the upload bucket — a race on cold deploys, and likely why
the deploy tool flagged minio-init's exit(0) as an anomaly (it wasn't
wired into the dependency graph the way migrate is).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 01:44:59 +02:00
Arnaud 78d0599ffc feat: add batch-cooking sessions (single "mega recipe" with dish sections)
New AI-generated batch-cooking flow: pick N dinners/lunches + servings,
get one unified recipe covering a full prep session — merged shopping
list, steps tagged per-dish (a step can advance several dishes at once,
e.g. a shared oven bake), and per-dish storage (fridge/freezer) + exact
day-of reheat/finishing instructions.

Schema: recipes.isBatchCook, recipeSteps.appliesTo (dish names), new
recipeBatchDishes table. Batch recipes are excluded from the normal
/recipes list and live under their own /batch-cooking section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 01:23:37 +02:00
Arnaud 68f0f490b9 chore: move docker/ compose files and infra to repo root
Moved compose.yml, compose.prod.yml, DEPLOY.md, traefik/, and cron/ out
of docker/ into the repo root (docker/ removed). Updated every reference:

- Dockerfile: COPY paths for the cron stage
- .dockerignore: dropped the now-unneeded docker/!docker-cron
  exclude+exception pair (cron/ is just a normal top-level dir now, no
  special-casing needed)
- compose.prod.yml: build context ".." -> "." (Dockerfile and compose
  files are now siblings, not one level apart)
- CLAUDE.md, DEPLOY.md, compose.yml, traefik/epicure.yml,
  cron/run-digest.sh, weekly-digest route.ts: path references in comments/
  docs

Verified: both compose files validate, and all three Dockerfile targets
(runner/cron/migrator) build clean via a local --no-cache docker build
from the new layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 20:25:38 +02:00
Arnaud 8b749f432e feat: shared, more pleasing empty-state component across the app
Every "nothing here" page had its own copy-pasted dashed-border box —
icon + one muted line, inconsistent (some had a CTA, some didn't, no
description text anywhere). Replaced with one shared EmptyState
component: icon in a soft tinted circle, a real heading plus optional
description, and primary/secondary actions (either a Link or an
arbitrary action slot for things like "New Collection" that open a
dialog rather than navigate).

Applied to: recipes (no recipes / no search match), favorites, feed
(no one followed / no new posts / trending / for-you), collections
(index + detail), shopping lists, pantry, notifications, can-cook.
Left the small inline "no trending"/"no recent" lines inside Explore's
already-labeled sections and the notification-bell dropdown alone —
different context, a full empty-state box would be heavier than the
space warrants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:30:56 +02:00
Arnaud e0d39c86ef fix: clicking the favorite button on recipe cards still navigated to the recipe
stopPropagation() alone doesn't cancel a Link's native anchor navigation —
that's the browser's default action for the click reaching the <a>, not
a bubbling JS handler propagation can block. Needed preventDefault() too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 16:09:06 +02:00
Arnaud 0cb926bd26 fix: grid-card favorite button too prominent, move next to visibility icon
Was floating over the cover photo with a circular dark backdrop — too
heavy. Moved into the bottom meta row next to the visibility lock icon,
plain ghost icon button matching the recipe detail page's style exactly
(no background, same size/variant). List and compact rows were already
placed there with no backdrop, only the grid card needed this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 15:50:56 +02:00
Arnaud 580d5bbf2f feat: favorite button on the My Recipes grid; fix mobile overflow in bulk-action bar
- Added a heart toggle to all three recipe-grid views (grid card overlay,
  list row, compact row), hidden while in multi-select mode. Detail page
  already had it; this was the other place it made sense.
- recipes/page.tsx now fetches which of the current page's recipes are
  already favorited and passes it through.
- FavoriteButton gained an optional iconClassName so the unfavorited heart
  reads on a photo overlay (was invisible in light mode against the dark
  backdrop — filled/favorited state was already fine since it's opaque red).
- Fixed the floating bulk-select action bar overflowing both edges of the
  viewport on mobile (screenshotted: centered with no max-width, wider
  than the screen, clipping "Supprimer" on one side and the selected-count
  label on the other). Now caps to viewport width, scrolls horizontally
  as a fallback, and drops button text labels below sm (icon + aria-label
  + tooltip only), matching the icon-row convention used elsewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 15:37:23 +02:00