236 Commits

Author SHA1 Message Date
Arnaud 70eb565eec feat: bulk tag editing, bulk export, and move recipes between collections
Extends the existing bulk-select infra: recipes list gets tag add/remove
and multi-recipe Markdown export; collection pages get a select mode to
move recipes to another collection or remove them from the current one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 11:57:07 +02:00
Arnaud 4960dfc7c6 feat: rate limit public routes; backfill v0.9.5 changelog for mobile audit
Sign-in/sign-up now throttle at 3 req/10s/IP via better-auth's built-in
rate limiter; /r/ and /s/ share links throttle at 60 req/min/IP via
proxy.ts using the existing Redis-backed limiter (Next 16's Proxy always
runs Node.js, no Edge-runtime blocker after all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:32:37 +02:00
Arnaud 6818f10fc4 fix: mobile audit — Explore's AI-ideas input unusable, chat FAB overlap
Swept meal-plan, shopping-lists, pantry, nutrition, explore, feed,
collections, recipe detail/edit, and settings sub-pages at a real 390px
viewport. Two real bugs found (rest checked out clean — the meal-plan
calendar and settings tabs' horizontal scroll are intentional, not bugs):

- Explore's "Recipe ideas" input had no flex-basis and both buttons kept
  full text labels, squeezing the input down to ~2 visible characters
  on mobile. Input now flex-1, buttons icon-only below sm: (same pattern
  as the recipes-page Sort/Filter fix).
- Recipe detail page had no bottom clearance, so the fixed per-recipe
  chat button could sit directly on top of the private-notes Save
  button on short pages. Added pb-20, matching recipe-form.tsx's
  existing floating-save-bar clearance.

Verified both live at 390px: input now shows real placeholder text with
tappable icon buttons alongside it; Save button now clears the chat FAB
with real spacing.
2026-07-13 09:25:20 +02:00
Arnaud 7c63388618 chore: bump version to 0.9.4, backfill changelog with real commit times
Every entry got a time component pulled from actual git commit
timestamps (git log --date=format), not guessed — precise enough to
disambiguate the many same-day releases from this session. 0.2.0 and
0.1.0 kept date-only/"earlier" since no reliable timestamp exists for
them. Also adds the 0.9.4 entry for the previous action-buttons/
view-toggle mobile fix, which shipped without its own version bump.
2026-07-13 09:09:55 +02:00
Arnaud 36e707b934 fix: recipes page action buttons and view-toggle row not mobile-optimized
"New recipe" kept its text label below sm:, so on mobile (especially
French, where "Importer une URL"/"Nouvelle recette" run long) it wrapped
to its own second line — icon-only now below sm:, matching the Sort/Filter
treatment from the previous mobile-spacing fix.

The view-toggle + Select row used justify-between with an empty filler
div outside select mode, pushing the controls flush right and leaving a
large dead gap on the left — same width as the row above it, but visually
disconnected. Now left-aligned outside select mode (justify-between still
applies inside select mode, where there's real content on both sides).

Verified at a real 390px mobile viewport in both English and French —
Generate/Import/New now share one row, and the view-toggle row starts
flush left instead of floating on the right with empty space before it.
2026-07-13 09:03:15 +02:00
Arnaud 13b8de0e0c chore: bump version to 0.9.3, changelog entry 2026-07-13 08:38:46 +02:00
Arnaud 257908da7f fix: recipes page wasted too much vertical space above content on mobile
Search bar forced its own full-width row (w-full), pushing Sort/Filter
to a second row below it — on mobile that's title + action buttons +
search row + sort/filter row + toolbar row before any recipe appears.

Sort/Filter now go icon-only below sm: (text labels hidden, matching the
convention already used for compact-view badges), letting Search share
one row with both instead of forcing its own. Also tightened the title
size and inter-section spacing on mobile (unchanged at sm: and up).

Verified at a real 390px mobile viewport: first recipe card now clears
with room to spare, versus barely visible at the same viewport height
before. Desktop screenshot confirms zero visual change there.
2026-07-13 08:37:40 +02:00
Arnaud e117533f6f chore: bump version to 0.9.2, changelog entry 2026-07-13 07:14:38 +02:00
Arnaud d107bce92e fix: recipe-ideas banner buttons clashed with the violet gradient background
"Get ideas" used variant="secondary" (flat grey) and "Surprise me" was
variant="ghost" (no background at all) — both sat awkwardly on the
violet/indigo gradient section. Restyled to match: solid violet for the
primary action, violet-outlined for the secondary.

(Also answering the "what's the difference" question inline: "Get ideas"
generates from whatever you typed in the box; "Surprise me" ignores the
box and picks a random prompt from a fixed list, filling the box and
generating immediately.)
2026-07-13 07:13:39 +02:00
Arnaud 5d351f88b0 chore: bump version to 0.9.1, changelog entry 2026-07-13 06:59:00 +02:00
Arnaud b1995522ee fix: destructive confirm buttons had unreadable black-on-red text
--destructive-foreground was never defined in globals.css (light or dark
theme) — text-destructive-foreground resolved to nothing, so both the
clear-chat-history confirm button and recipe-form's discard-changes
confirm (same bg-destructive text-destructive-foreground pattern) fell
back to the default dark text color on a solid red background.

Added the missing variable (white, matching --primary-foreground's
light value — both themes use a saturated enough red that white stays
legible), mapped through @theme inline alongside the existing
--color-destructive.

Verified locally: screenshotted the clear-history confirm dialog,
button now shows white text on red.
2026-07-13 06:57:52 +02:00
Arnaud da48d0ecef chore: bump version to 0.9.0, changelog entry 2026-07-12 23:28:18 +02:00
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