You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A shopping list is identified only by its title. Once there are more than a handful, the index at /shopping-lists is a wall of near-identical cards, and telling "Tjedna kupovina" from "Tjedna kupovina 2" takes reading rather than glancing.
An image gives each list a distinct visual anchor, and carries information a colour cannot: a photo of the fridge, the party table, or the shelf you are restocking.
Companion issue: personalising lists with a colour is the cheaper half of the same goal and should land first. This issue is the richer half.
Proposed solution
Store a single image per list, the same way digital cards do.
The precedent is now digital cards, not avatars. PR #142 standardised base64-in-Postgres for user-supplied images, and it is a much closer fit than the avatar field:
backend/.../digitalCard/domain/DigitalCard.java has icon_image, front_image and back_image as TEXT columns
frontend/src/app/(user)/digital-cards/components/forms/card-image-slot.tsx is the pick-and-preview slot
frontend/src/app/(user)/digital-cards/components/forms/card-images-field.tsx sets the size budget per slot: icon 256px / 400,000 chars, faces 1024px / 1,200,000 chars
frontend/src/app/(user)/digital-cards/hooks/use-card-images.ts keeps images out of react-hook-form, because base64 blows the localStorage draft quota
frontend/src/utils/browser/image.ts (resizeImageToWebp) does the downscale and encode
the zod schema caps length directly: iconImage: z.string().max(400_000, "Ikona je prevelika").nullable()
So the work here is mostly lifting three digital-card components into a shared place and adding one field, rather than inventing a pipeline.
Suggested shape:
shopping_list.image as TEXT, nullable
One slot in the create/edit modal, icon-sized rather than face-sized. A list card is small; 256px is enough
Copying a list copies the image, which is a string copy
Compression is mandatory, not optional
No original file is ever stored. Every image goes through resizeImageToWebp in the browser before it reaches state, the request, or the database, exactly as the avatar and the card images already do:
It downscales on a canvas so the longest edge is at most maxSize, re-encodes as WebP at the given quality, and returns a base64 data URI. It rejects on input the browser cannot decode, so the caller can say why rather than storing something broken.
The rules to follow, all of which already exist elsewhere:
Downscale and re-encode before anything else.avatar-field.tsx:36 calls resizeImageToWebp(file) and hands the result straight to the update. Never fileToBase64 a picked file directly.
Reject oversized input before decoding.avatar-field.tsx:18 caps the picked file at 15 MB (MAX_AVATAR_BYTES) and bails with a message. That guard is about not handing a 200 MB file to createImageBitmap, and is separate from the stored size.
Cap the stored length in the zod schema. Digital cards do this per slot: iconImage: z.string().max(400_000, "Ikona je prevelika"). A cap in the schema is what makes the limit real, because it is enforced on the response as well as the request.
Pick the budget from what the surface actually renders. A list card thumbnail is small, so 256px at quality 0.8 is the right target, matching the card icon rather than the card faces. card-images-field.tsx is the worked example of choosing per slot: icon 256px / 400,000 chars, faces 1024px / 1,200,000 chars.
This matters more here than for an avatar. A list image is returned by GET /api/shopping-lists/me for every list, persisted to IndexedDB, and sent to everyone holding a share link. An uncompressed original would multiply across all three.
Alternatives considered
Real object storage (Cloudflare R2). R2 already exists in the stack but only as a pg_dump backup target (docs/DEPLOYMENT.md §8), not wired to the app. It would need multipart upload on both sides (there is no MultipartFile anywhere in backend/), server-side validation and re-encoding, signed URLs, images.remotePatterns in frontend/next.config.ts (which has no images block at all), and an orphan-cleanup story. Copy-on-duplicate becomes a deliberate object copy or a refcount.
Rejected for now: correct at any size, but the digital-card precedent already answers the question at the size a list card needs, and this would be the first storage service in the stack.
Per-item photos rather than per-list. This is what AnyList actually does, and for a good reason: a photo of the exact product helps you find it on the shelf. Genuinely useful, genuinely a different feature. Worth its own issue.
Blocked on
The colours issue should land first. It is cheaper, it solves most of the "tell them apart" need, and it settles where the personalisation controls live in the modal.
Payload cost is the thing to watch.GET /api/shopping-lists/me returns every list with its items, that response is persisted to IndexedDB, and since sharing it also travels to anyone holding a share link. At the icon budget (400,000 chars worst case, realistically 15 to 30 KB for a 256px WebP) twenty lists is a few hundred KB in the offline cache. Measure it rather than assume, and consider whether the list index needs the image inline or a separate lightweight endpoint.
The offline cache is now identity-scoped (frontend/src/lib/offline/cache-identity.ts), so a shared list's image lands only under the viewing identity. See docs/SHARING.md §6.
CSP already allows img-src 'self' data: blob: https:, so no CSP change is needed for base64.
ddl-auto=update adds a nullable column cleanly. No manual step, unlike a drop (docs/DEPLOYMENT.md §10.1).
Fields to touch: the entity, ShoppingListRequest, ShoppingListDto, and frontend/src/lib/api/schemas/shopping-list.ts.
Acceptance:
The digital-card image slot and resize helper live somewhere shared, not under digital-cards/
An owner can set, replace and clear a list image
The image appears on the list card and the list detail header
Copying a list copies the image
Share-link recipients see it at every access level
Every image is downscaled and re-encoded through resizeImageToWebp before it leaves the browser; no original file is ever stored
An oversized pick is rejected with a message before it is decoded
A documented size cap, enforced in the zod schema like the card images
The offline payload cost is measured and recorded, not assumed
docs/SHARING.md and docs/PWA.md updated if the payload or cache story changes
Problem or motivation
A shopping list is identified only by its title. Once there are more than a handful, the index at
/shopping-listsis a wall of near-identical cards, and telling "Tjedna kupovina" from "Tjedna kupovina 2" takes reading rather than glancing.An image gives each list a distinct visual anchor, and carries information a colour cannot: a photo of the fridge, the party table, or the shelf you are restocking.
Companion issue: personalising lists with a colour is the cheaper half of the same goal and should land first. This issue is the richer half.
Proposed solution
Store a single image per list, the same way digital cards do.
The precedent is now digital cards, not avatars. PR #142 standardised base64-in-Postgres for user-supplied images, and it is a much closer fit than the avatar field:
backend/.../digitalCard/domain/DigitalCard.javahasicon_image,front_imageandback_imageasTEXTcolumnsfrontend/src/app/(user)/digital-cards/components/forms/card-image-slot.tsxis the pick-and-preview slotfrontend/src/app/(user)/digital-cards/components/forms/card-images-field.tsxsets the size budget per slot: icon 256px / 400,000 chars, faces 1024px / 1,200,000 charsfrontend/src/app/(user)/digital-cards/hooks/use-card-images.tskeeps images out of react-hook-form, because base64 blows the localStorage draft quotafrontend/src/utils/browser/image.ts(resizeImageToWebp) does the downscale and encodeiconImage: z.string().max(400_000, "Ikona je prevelika").nullable()So the work here is mostly lifting three digital-card components into a shared place and adding one field, rather than inventing a pipeline.
Suggested shape:
shopping_list.imageasTEXT, nullableCompression is mandatory, not optional
No original file is ever stored. Every image goes through
resizeImageToWebpin the browser before it reaches state, the request, or the database, exactly as the avatar and the card images already do:It downscales on a canvas so the longest edge is at most
maxSize, re-encodes as WebP at the given quality, and returns a base64 data URI. It rejects on input the browser cannot decode, so the caller can say why rather than storing something broken.The rules to follow, all of which already exist elsewhere:
avatar-field.tsx:36callsresizeImageToWebp(file)and hands the result straight to the update. NeverfileToBase64a picked file directly.avatar-field.tsx:18caps the picked file at 15 MB (MAX_AVATAR_BYTES) and bails with a message. That guard is about not handing a 200 MB file tocreateImageBitmap, and is separate from the stored size.iconImage: z.string().max(400_000, "Ikona je prevelika"). A cap in the schema is what makes the limit real, because it is enforced on the response as well as the request.card-images-field.tsxis the worked example of choosing per slot: icon 256px / 400,000 chars, faces 1024px / 1,200,000 chars.This matters more here than for an avatar. A list image is returned by
GET /api/shopping-lists/mefor every list, persisted to IndexedDB, and sent to everyone holding a share link. An uncompressed original would multiply across all three.Alternatives considered
Real object storage (Cloudflare R2). R2 already exists in the stack but only as a
pg_dumpbackup target (docs/DEPLOYMENT.md§8), not wired to the app. It would need multipart upload on both sides (there is noMultipartFileanywhere inbackend/), server-side validation and re-encoding, signed URLs,images.remotePatternsinfrontend/next.config.ts(which has noimagesblock at all), and an orphan-cleanup story. Copy-on-duplicate becomes a deliberate object copy or a refcount.Rejected for now: correct at any size, but the digital-card precedent already answers the question at the size a list card needs, and this would be the first storage service in the stack.
Per-item photos rather than per-list. This is what AnyList actually does, and for a good reason: a photo of the exact product helps you find it on the shelf. Genuinely useful, genuinely a different feature. Worth its own issue.
Blocked on
Additional context
GET /api/shopping-lists/mereturns every list with its items, that response is persisted to IndexedDB, and since sharing it also travels to anyone holding a share link. At the icon budget (400,000 chars worst case, realistically 15 to 30 KB for a 256px WebP) twenty lists is a few hundred KB in the offline cache. Measure it rather than assume, and consider whether the list index needs the image inline or a separate lightweight endpoint.frontend/src/lib/offline/cache-identity.ts), so a shared list's image lands only under the viewing identity. Seedocs/SHARING.md§6.img-src 'self' data: blob: https:, so no CSP change is needed for base64.ddl-auto=updateadds a nullable column cleanly. No manual step, unlike a drop (docs/DEPLOYMENT.md§10.1).ShoppingListRequest,ShoppingListDto, andfrontend/src/lib/api/schemas/shopping-list.ts.Acceptance:
digital-cards/resizeImageToWebpbefore it leaves the browser; no original file is ever storeddocs/SHARING.mdanddocs/PWA.mdupdated if the payload or cache story changesAffected area
Frontend (web / PWA)