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:
MapProjectgains an optionalpreviewImagepath- create.tsx can attach a preview image when creating a project
- project.tsx gains its own
actionto 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 howMapSource'suploadvariant 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/itemclassItemalready applies (see item.tsx) means the home route needs zero new CSS — just Tailwind utilities on the existingimg.
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'sMapProjectgainspreviewImage?: stringas shown above.createProject({ name, description, previewImage })accepts an optionalpreviewImagepath 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'spreviewImageand persists it, returningundefinedif the project id doesn't resolve (matchingaddMap's existing not-found handling — callers 404). data/projects.json's three existing projects remain valid with nopreviewImagepresent.pnpm typecheckpasses.
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>setsencType="multipart/form-data"and gains a file field (e.g.name="previewImage"), reusing the sameAttachment/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
actionreads the file viarequest.formData(); when present and non-empty, it callssaveUpload()beforecreateProject()and passes the returned path aspreviewImage. - A
saveUpload()failure (wrong type, too large) re-renders the form withactionData.error(same shape as the existing name-required error) and does not create the project. pnpm typecheckpasses.
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 apreviewImagefile field fromrequest.formData(), requires it to be present and non-empty, callssaveUpload(), and on success callssetPreviewImage(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
Attachmentpattern 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 bycreate.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 typecheckpasses.
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>(insideItemMedia variant="image") keeps usingproject.previewImage ??placeholder as itssrc, unchanged. - The
<img>gets a sepia filter by default and a transition, and drops to full color when its ancestorItemis hovered or keyboard-focused — using Tailwind'ssepiautility plus thegroup-hover/item:andgroup-focus-visible/item:variants againstItem's existinggroup/itemclass (item.tsx); noapp.csschanges needed. - The treatment applies equally to the placeholder
avatar.vercel.shimage and to a realpreviewImage— same<img>, same classes, regardless of whichsrcis 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 onTab-key focus. pnpm typecheckpasses.