Skip to content

Personalize shopping lists with images #154

Description

@OffCrazyFreak

Problem or motivation

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:

// frontend/src/utils/browser/image.ts
resizeImageToWebp(file, maxSize = 256, quality = 0.8): Promise<string>

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  • Digital cards PR feat(digital-cards): Rebuild the loyalty card wallet #142, whose image components this would lift into a shared location.

Additional context

  • 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

Affected area

Frontend (web / PWA)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions