Skip to content

Project preview images

Goal

Let a user attach a preview image to a project — at creation or later — and show it in the home project list, desaturated by default and in full color on hover/focus.

Decisions

Decision Choice Why
Plan file location docs/project-preview-image.md (not a version file) Not tied to a specific release version, matches the existing non-versioned ui-components-refactor.md precedent
Data field previewImage?: string on MapProject, a public path (same shape as MapSource's upload path) Smallest addition that reuses the existing upload storage convention
Setting a preview on an existing project New action on project.tsx itself (no separate edit route) The route already loads the project; no other edits exist yet, so one action is enough
Upload validation/storage Reuse saveUpload() as-is (20MB, png/jpeg/webp, /uploads/<uuid>/image.<ext>) Confirmed with user — same rules as map uploads
Replacing a preview Old file left orphaned, not deleted Confirmed with user — matches existing map-upload behavior (no cleanup anywhere in the app today)
Sepia treatment scope Applies to both uploaded previews and the avatar.vercel.sh placeholder Confirmed with user — visual consistency across the whole list
Sepia mechanism Tailwind sepia / sepia-0 utilities keyed off the group-hover/item and group-focus-visible/item variants already available from Item's existing group/item class No new CSS needed; keyboard focus gets the same treatment as mouse hover for free since Item renders as a Link

Scope

In:

  • MapProject gains an optional previewImage path
  • create.tsx can attach a preview image when creating a project
  • project.tsx gains its own action to set or replace the preview on an existing project
  • home.tsx renders each project's thumbnail (real preview or placeholder) sepia by default, true color on hover/keyboard focus

Out:

  • Deleting orphaned files when a preview is replaced
  • Cropping/resizing/dimension constraints on the uploaded image
  • Any change to thumbnail size, shape, or list layout

Data model

// app/lib/projects.server.ts
export type MapProject = {
  id: string
  name: string
  description?: string
  previewImage?: string // public path, e.g. "/uploads/<uploadId>/image.png"
  createdAt: string
  maps: MapRef[]
}

data/projects.json's three existing projects need no change — the field is optional and absent entries just render the placeholder, unchanged from today.

Why this shape

  • A bare optional string on MapProject (rather than a nested object) matches how MapSource's upload variant already stores a path — one convention for "a path to something in /uploads".
  • Keeping the edit affordance as an action on project.tsx (instead of a new route) avoids adding routing/navigation for a single-field update; it's the only edit the app has, so a dedicated route would be premature.
  • Driving the sepia/hover effect off the group/item class Item already applies (see item.tsx) means the home route needs zero new CSS — just Tailwind utilities on the existing img.

Tasks

Ordered so each task type-checks and is shippable on its own; later tasks depend on earlier ones.

T1 — Data model: previewImage

Goal: Represent an optional preview image on a project, and a way to set it after creation.

Acceptance criteria:

  • app/lib/projects.server.ts's MapProject gains previewImage?: string as shown above.
  • createProject({ name, description, previewImage }) accepts an optional previewImage path and stores it on the created project when present.
  • A new export, e.g. setPreviewImage(projectId: string, path: string): MapProject | undefined, updates an existing project's previewImage and persists it, returning undefined if the project id doesn't resolve (matching addMap's existing not-found handling — callers 404).
  • data/projects.json's three existing projects remain valid with no previewImage present.
  • pnpm typecheck passes.

T2 — Create flow: attach a preview at creation

Goal: A user can optionally attach a preview image while creating a project.

Acceptance criteria:

  • create.tsx's <Form> sets encType="multipart/form-data" and gains a file field (e.g. name="previewImage"), reusing the same Attachment/hidden-file-input pattern already used in add-map.tsx (accept="image/png,image/jpeg,image/webp").
  • The field is optional — submitting without a file still creates the project exactly as today.
  • The action reads the file via request.formData(); when present and non-empty, it calls saveUpload() before createProject() and passes the returned path as previewImage.
  • A saveUpload() failure (wrong type, too large) re-renders the form with actionData.error (same shape as the existing name-required error) and does not create the project.
  • pnpm typecheck passes.

T3 — Edit flow: set/replace a preview on an existing project

Goal: Any project — with or without a preview — can get one set or replaced from its own project page.

Acceptance criteria:

  • project.tsx gains an action (the route has none today) that reads a previewImage file field from request.formData(), requires it to be present and non-empty, calls saveUpload(), and on success calls setPreviewImage(project.id, path); a bad/missing file returns an error instead of touching the project.
  • The page renders an always-visible file-picker control (same Attachment pattern as T2) near the project header, inside its own <Form method="post" encType="multipart/form-data"> posting back to the same route — independent of the existing "Add map" action/button.
  • On success the action redirects back to /project/:projectId, matching the redirect-after-post pattern used by create.tsx/add-map.tsx.
  • On failure (bad type/size), the project's existing previewImage (if any) is untouched and the page renders an error message.
  • Replacing an existing preview does not delete the previous file from disk (confirmed: orphaning is acceptable).
  • pnpm typecheck passes.

T4 — Home list: sepia by default, true color on hover/focus

Goal: Project thumbnails in the home list read as a muted set until a row is hovered or focused.

Acceptance criteria:

  • home.tsx's per-project <img> (inside ItemMedia variant="image") keeps using project.previewImage ?? placeholder as its src, unchanged.
  • The <img> gets a sepia filter by default and a transition, and drops to full color when its ancestor Item is hovered or keyboard-focused — using Tailwind's sepia utility plus the group-hover/item: and group-focus-visible/item: variants against Item's existing group/item class (item.tsx); no app.css changes needed.
  • The treatment applies equally to the placeholder avatar.vercel.sh image and to a real previewImage — same <img>, same classes, regardless of which src is in use.
  • Only the hovered/focused row's thumbnail changes; other rows in the list stay sepia.
  • Manual check: pnpm dev, visit /, confirm all demo projects' thumbnails render sepia and turn full-color on mouse hover and on Tab-key focus.
  • pnpm typecheck passes.