Public: anyone can read this; the crew can change it.
Describe each photo once: the words become its alt text on the listing, the property page, the email and the flyer #77
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/73-describe-each-photo-once-the-words-becom → main 2 commits fork 1 thread · 1 unresolved
17 files
changed +298
−34
README.md +15 −3
| @@ -126,8 +126,20 @@ list cannot drift from the app. | ||
| 126 | 126 | line, in your words), and up to eight photos (24 MB at a time) — the first |
| 127 | 127 | is the hero. Photos can be changed later, on **Edit facts**: add, remove, |
| 128 | 128 | or make another the hero (24 MB of new files per save; the flyer and the email need approving again). |
| 129 | Over the JSON API, `PATCH` `{ photos: { order, remove } }` reorders and | |
| 130 | removes; uploads are multipart on the page only. | |
| 129 | Over the JSON API, `PATCH` `{ photos: { order, remove, alt } }` reorders, | |
| 130 | removes and describes; uploads are multipart on the page only. | |
| 131 | **Describe each photo once.** On **Edit facts**, each photo has an optional | |
| 132 | one-line description (up to 120 characters, plain text, no control | |
| 133 | characters), saved with the photo order. It becomes the `alt` text on the | |
| 134 | listing's photo strip, the property page, the email (and the HTML "Copy as | |
| 135 | email" copies), the flyer and the square picture, so a screen reader or a | |
| 136 | mail client with images off says "Kitchen with quartz island" instead of | |
| 137 | "Photo 3 of 412 Alder Lane"; a photo without one keeps today's alt text. | |
| 138 | Descriptions are Fair Housing checked like any typed words, and a flagged one | |
| 139 | shows its finding beside its box (they are public on the property page). | |
| 140 | Removing a photo removes its description. A description is not an asset's | |
| 141 | words, so changing one clears no approval; only a new order or a removal | |
| 142 | clears the flyer's and the email's. The sample listings come described. | |
| 131 | 143 | `GET /api/listings?status=sold&q=oak` and the MCP `list_listings` tool take |
| 132 | 144 | the same filter and search as the home list; an unknown status is All and |
| 133 | 145 | `q` is capped at 100 characters. |
| @@ -360,7 +372,7 @@ curl -X DELETE https://<app>/api/listings/1 | ||
| 360 | 372 | |
| 361 | 373 | As an MCP server, for Claude Code or Claude Desktop — `list_listings`, |
| 362 | 374 | `get_listing`, `launch_listing`, `edit_asset`, `retone_listing`, `set_status`, `update_facts`, |
| 363 | `update_photos` (order and removals; uploads are on the page only), `delete_listing`, | |
| 375 | `update_photos` (order, removals and descriptions; uploads are on the page only), `delete_listing`, | |
| 364 | 376 | `check_fair_housing`, and the notes' `list_notes`, `add_note`, |
| 365 | 377 | `delete_note`: |
| 366 | 378 |
contract/openapi.json +9 −2
| @@ -635,7 +635,7 @@ | ||
| 635 | 635 | "application/json": { |
| 636 | 636 | "schema": { |
| 637 | 637 | "type": "object", |
| 638 | "description": "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the photos changed ({ photos: { order, remove } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.", | |
| 638 | "description": "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the photos changed ({ photos: { order, remove, alt } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.", | |
| 639 | 639 | "properties": { |
| 640 | 640 | "kind": { |
| 641 | 641 | "enum": [ |
| @@ -797,7 +797,7 @@ | ||
| 797 | 797 | }, |
| 798 | 798 | "photos": { |
| 799 | 799 | "type": "object", |
| 800 | "description": "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop. Uploads are multipart on the page only. Clears the flyer's and the email's approval.", | |
| 800 | "description": "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop, `alt` describes photos (like {\"<name>\": \"Kitchen with quartz island\"}, up to 120 characters, empty takes it away): the alt text wherever the photo shows. Uploads are multipart on the page only. A new order or a removal clears the flyer's and the email's approval; a description clears nothing.", | |
| 801 | 801 | "properties": { |
| 802 | 802 | "order": { |
| 803 | 803 | "type": "array", |
| @@ -810,6 +810,13 @@ | ||
| 810 | 810 | "items": { |
| 811 | 811 | "type": "string" |
| 812 | 812 | } |
| 813 | }, | |
| 814 | "alt": { | |
| 815 | "type": "object", | |
| 816 | "additionalProperties": { | |
| 817 | "type": "string", | |
| 818 | "maxLength": 120 | |
| 819 | } | |
| 813 | 820 | } |
| 814 | 821 | } |
| 815 | 822 | }, |
src/actions.ts +27 −6
| @@ -39,6 +39,7 @@ import { | ||
| 39 | 39 | type Kind as PhotoKind, |
| 40 | 40 | PHOTOS_MAX, |
| 41 | 41 | PHOTOS_TOTAL, |
| 42 | cleanAlt, | |
| 42 | 43 | remove as removePhotos, |
| 43 | 44 | nameFor as photoName, |
| 44 | 45 | unwrite as unwritePhotos, |
| @@ -1085,6 +1086,8 @@ export const updatePhotos = ( | ||
| 1085 | 1086 | add?: readonly Upload[]; |
| 1086 | 1087 | remove?: readonly unknown[]; |
| 1087 | 1088 | order?: readonly unknown[]; |
| 1089 | /** Photo name → its new description; empty text takes it away, a photo left out keeps its own. */ | |
| 1090 | alt?: Readonly<Record<string, unknown>>; | |
| 1088 | 1091 | }, |
| 1089 | 1092 | ): Result<Launched> => |
| 1090 | 1093 | audited(db, who, "listing", "update", input.id, changePhotos(db, who, input)); |
| @@ -1097,6 +1100,8 @@ function changePhotos( | ||
| 1097 | 1100 | add?: readonly Upload[]; |
| 1098 | 1101 | remove?: readonly unknown[]; |
| 1099 | 1102 | order?: readonly unknown[]; |
| 1103 | /** Photo name → its new description; empty text takes it away, a photo left out keeps its own. */ | |
| 1104 | alt?: Readonly<Record<string, unknown>>; | |
| 1100 | 1105 | }, |
| 1101 | 1106 | ): Result<Launched> { |
| 1102 | 1107 | const found = listingFor(db, who, "update", input.id, { |
| @@ -1131,18 +1136,34 @@ function changePhotos( | ||
| 1131 | 1136 | files.push({ name: photoName(accepted.kind), bytes: upload.bytes }); |
| 1132 | 1137 | } |
| 1133 | 1138 | const photos = [...kept, ...files.map((f) => f.name)]; |
| 1139 | const alt: Record<string, string> = {}; | |
| 1140 | for (const [name, text] of Object.entries(now.photo_alt)) | |
| 1141 | if (!dropped.has(name)) alt[name] = text; | |
| 1142 | for (const [name, raw] of Object.entries(input.alt ?? {})) { | |
| 1143 | if (!have.has(name)) return fail("invalid", "That photo is not on this listing."); | |
| 1144 | if (dropped.has(name)) continue; | |
| 1145 | const said = cleanAlt(raw); | |
| 1146 | if (!said.ok) return fail("invalid", said.message); | |
| 1147 | if (said.value === "") delete alt[name]; | |
| 1148 | else alt[name] = said.value; | |
| 1149 | } | |
| 1150 | // Only a change to which photos show, or in what order, is a change to the flyer and the email; | |
| 1151 | // a description is not an asset's words, so it leaves every approval as it was. | |
| 1152 | const reshown = JSON.stringify(photos) !== JSON.stringify(now.photos); | |
| 1134 | 1153 | try { |
| 1135 | 1154 | db.transaction(() => { |
| 1136 | db.query("UPDATE listing SET photos = ?1 WHERE id = ?2").run( | |
| 1155 | db.query("UPDATE listing SET photos = ?1, photo_alt = ?2 WHERE id = ?3").run( | |
| 1137 | 1156 | JSON.stringify(photos), |
| 1157 | JSON.stringify(alt), | |
| 1138 | 1158 | now.id, |
| 1139 | 1159 | ); |
| 1140 | 1160 | touch(db, now.id); |
| 1141 | for (const kind of PHOTO_ASSETS) | |
| 1142 | db.query( | |
| 1143 | `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL | |
| 1144 | WHERE listing_id = ?1 AND kind = ?2`, | |
| 1145 | ).run(now.id, kind); | |
| 1161 | if (reshown) | |
| 1162 | for (const kind of PHOTO_ASSETS) | |
| 1163 | db.query( | |
| 1164 | `UPDATE asset SET approved_by_id = NULL, approved_by_name = NULL, approved_at = NULL, approved_via = NULL | |
| 1165 | WHERE listing_id = ?1 AND kind = ?2`, | |
| 1166 | ).run(now.id, kind); | |
| 1146 | 1167 | for (const f of files) writePhoto(db, f.name, f.bytes); |
| 1147 | 1168 | })(); |
| 1148 | 1169 | } catch (error) { |
src/comingsoon.test.ts +4 −1
| @@ -160,7 +160,10 @@ test("the migration keeps every listing, photo name and approval", () => { | ||
| 160 | 160 | expect(() => db.query("UPDATE listing SET status = 'coming_soon' WHERE id = 1").run()).toThrow(); |
| 161 | 161 | |
| 162 | 162 | expect(migrate(db)).toBe(migrations.length); |
| 163 | expect(db.query("SELECT * FROM listing ORDER BY id").all()).toEqual(before); | |
| 163 | // Every row as it was, plus the photo descriptions column a later migration adds, empty. | |
| 164 | expect(db.query("SELECT * FROM listing ORDER BY id").all()).toEqual( | |
| 165 | (before as object[]).map((row) => ({ ...row, photo_alt: "{}" })), | |
| 166 | ); | |
| 164 | 167 | expect(db.query("SELECT * FROM asset ORDER BY listing_id, kind").all()).toEqual(assetsBefore); |
| 165 | 168 | expect((db.query("PRAGMA foreign_key_check").all() as unknown[]).length).toBe(0); |
| 166 | 169 | expect((db.query("PRAGMA integrity_check").get() as { integrity_check: string }).integrity_check).toBe("ok"); |
src/db.ts +10
| @@ -157,6 +157,16 @@ export const migrations: string[] = [ | ||
| 157 | 157 | reason TEXT NOT NULL |
| 158 | 158 | ); |
| 159 | 159 | CREATE INDEX IF NOT EXISTS asset_version_asset ON asset_version (listing_id, kind, id)`, |
| 160 | // What each photo shows, in a few words: { "<photo name>": "Kitchen with quartz island" }. It is the | |
| 161 | // photo's alt text wherever it appears; a photo without one keeps the generic alt. Existing rows get {}. | |
| 162 | `ALTER TABLE listing ADD COLUMN photo_alt TEXT NOT NULL DEFAULT '{}'`, | |
| 163 | // The planted samples, described so the meeting hears them (planted once, so those already there are told). | |
| 164 | `UPDATE listing SET photo_alt = json_object('sample-front.svg', 'Front of 412 Alder Lane with a covered porch', 'sample-garden.svg', 'Back garden with raised beds and a patio') | |
| 165 | WHERE author_id = 'sample' AND photos = '["sample-front.svg","sample-garden.svg"]'`, | |
| 166 | `UPDATE listing SET photo_alt = json_object('sample-condo-front.svg', 'Building entrance with a glass lobby', 'sample-condo-living.svg', 'Living room with floor-to-ceiling windows') | |
| 167 | WHERE author_id = 'sample' AND photos = '["sample-condo-front.svg","sample-condo-living.svg"]'`, | |
| 168 | `UPDATE listing SET photo_alt = json_object('sample-house-front.svg', 'Brick front with a two-car garage', 'sample-house-yard.svg', 'Fenced yard with a mature oak') | |
| 169 | WHERE author_id = 'sample' AND photos = '["sample-house-front.svg","sample-house-yard.svg"]'`, | |
| 160 | 170 | ]; |
| 161 | 171 | |
| 162 | 172 | export function openDb(dir = dataDir): Database { |
src/listing.ts +8
| @@ -49,6 +49,8 @@ export interface Listing extends ListingInput { | ||
| 49 | 49 | readonly author_id: string; |
| 50 | 50 | readonly author_name: string; |
| 51 | 51 | readonly photos: readonly string[]; |
| 52 | /** What each photo shows, by photo name, for the ones the agent described. */ | |
| 53 | readonly photo_alt: Readonly<Record<string, string>>; | |
| 52 | 54 | readonly headshot: string | null; |
| 53 | 55 | readonly created_at: string; |
| 54 | 56 | readonly updated_at: string; |
| @@ -118,6 +120,7 @@ export interface ListingRow { | ||
| 118 | 120 | readonly agent_phone: string; |
| 119 | 121 | readonly agent_email: string; |
| 120 | 122 | readonly photos: string; |
| 123 | readonly photo_alt: string; | |
| 121 | 124 | readonly headshot: string | null; |
| 122 | 125 | readonly created_at: string; |
| 123 | 126 | readonly updated_at: string; |
| @@ -128,8 +131,13 @@ export const fromRow = (row: ListingRow): Listing => ({ | ||
| 128 | 131 | features: JSON.parse(row.features) as string[], |
| 129 | 132 | open_houses: JSON.parse(row.open_houses) as OpenHouse[], |
| 130 | 133 | photos: JSON.parse(row.photos) as string[], |
| 134 | photo_alt: JSON.parse(row.photo_alt) as Record<string, string>, | |
| 131 | 135 | }); |
| 132 | 136 | |
| 137 | /** A photo's alt text: the agent's description when there is one, else `fallback`. */ | |
| 138 | export const altOf = (l: Pick<Listing, "photo_alt">, name: string, fallback: string): string => | |
| 139 | l.photo_alt[name] ?? fallback; | |
| 140 | ||
| 133 | 141 | export interface AssetRow { |
| 134 | 142 | readonly kind: string; |
| 135 | 143 | readonly body: string; |
src/mcp.ts +6 −1
| @@ -301,18 +301,23 @@ export const tools: readonly Tool[] = [ | ||
| 301 | 301 | { |
| 302 | 302 | name: "update_photos", |
| 303 | 303 | description: |
| 304 | "Reorder or remove a launched listing's photos: `order` names the photos to put first (the first is the hero on the flyer and the email), `remove` names the ones to drop; the names are the ones get_listing shows. Uploading photos is on the page only. Clears the flyer's and the email's approval; the other assets keep theirs. Only the listing's agent or an admin can.", | |
| 304 | "Reorder or remove a launched listing's photos: `order` names the photos to put first (the first is the hero on the flyer and the email), `remove` names the ones to drop; the names are the ones get_listing shows. `alt` describes photos (like {\"<name>\": \"Kitchen with quartz island\"}, up to 120 characters, empty takes it away): their alt text wherever they show, and it clears no approval. Uploading photos is on the page only. A new order or a removal clears the flyer's and the email's approval; the other assets keep theirs. Only the listing's agent or an admin can.", | |
| 305 | 305 | inputSchema: { |
| 306 | 306 | type: "object", |
| 307 | 307 | properties: { |
| 308 | 308 | id: { type: "integer" }, |
| 309 | 309 | order: { type: "array", items: { type: "string" } }, |
| 310 | 310 | remove: { type: "array", items: { type: "string" } }, |
| 311 | alt: { type: "object", additionalProperties: { type: "string", maxLength: 120 } }, | |
| 311 | 312 | }, |
| 312 | 313 | required: ["id"], |
| 313 | 314 | }, |
| 314 | 315 | run: (db, who, args) => |
| 315 | 316 | updatePhotos(db, who, { |
| 317 | alt: | |
| 318 | typeof args.alt === "object" && args.alt !== null && !Array.isArray(args.alt) | |
| 319 | ? (args.alt as Record<string, unknown>) | |
| 320 | : {}, | |
| 316 | 321 | id: Number(args.id), |
| 317 | 322 | order: Array.isArray(args.order) ? args.order : [], |
| 318 | 323 | remove: Array.isArray(args.remove) ? args.remove : [], |
src/pages/card.ts +2 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { factItems, money, priceCut, whenShort } from "../generate.ts"; |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | import type { Launched } from "../listing.ts"; | |
| 10 | import { altOf, type Launched } from "../listing.ts"; | |
| 11 | 11 | import type { Who } from "../who.ts"; |
| 12 | 12 | import { layout } from "./layout.ts"; |
| 13 | 13 | import { draftBand, photoUrl } from "./listing.ts"; |
| @@ -32,7 +32,7 @@ export function cardPage(input: { who: Who; listing: Launched }): string { | ||
| 32 | 32 | <span class="muted">A 1080 × 1080 square: save as PDF or take a screenshot.</span> |
| 33 | 33 | </div> |
| 34 | 34 | <article class="card-square palette-${escape(l.palette)}"> |
| 35 | ${hero === undefined ? `<div class="card-photo brand-fill"></div>` : `<img class="card-photo" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1080" height="1080">`} | |
| 35 | ${hero === undefined ? `<div class="card-photo brand-fill"></div>` : `<img class="card-photo" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="1080" height="1080">`} | |
| 36 | 36 | ${draftBand(l, "social_short")} |
| 37 | 37 | <div class="card-band brand-fill"> |
| 38 | 38 | <p class="card-headline">${escape(cardHeadline(l))}</p> |
src/pages/email.ts +2 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { factItems, money } from "../generate.ts"; |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | import { propertyPath, type Launched } from "../listing.ts"; | |
| 10 | import { altOf, propertyPath, type Launched } from "../listing.ts"; | |
| 11 | 11 | import type { Who } from "../who.ts"; |
| 12 | 12 | import { layout } from "./layout.ts"; |
| 13 | 13 | import { draftBand, photoUrl } from "./listing.ts"; |
| @@ -41,7 +41,7 @@ export function email(l: Launched, origin = ""): string { | ||
| 41 | 41 | ${draftBand(l, "email")} |
| 42 | 42 | <p class="email-subject"><span class="muted">Subject</span> <span data-subject>${escape(subject)}</span></p> |
| 43 | 43 | <div class="email-card"> |
| 44 | ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="600" height="400">`} | |
| 44 | ${hero === undefined ? "" : `<img class="email-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="600" height="400">`} | |
| 45 | 45 | <h2 class="email-address brand">${escape(l.address)}</h2> |
| 46 | 46 | <p class="email-price brand">${money(l.price)}</p> |
| 47 | 47 | <p class="email-facts brand-fill">${factItems(l).map((i) => `${escape(i.value)} ${i.label}`).join(" · ")}</p> |
src/pages/facts.ts +15 −4
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | |
| 6 | 6 | import { money } from "../generate.ts"; |
| 7 | 7 | import { escape } from "../http.ts"; |
| 8 | import type { Listing } from "../listing.ts"; | |
| 8 | import { check } from "../fairhousing.ts"; | |
| 9 | import { altOf, type Listing } from "../listing.ts"; | |
| 9 | 10 | import type { Who } from "../who.ts"; |
| 10 | 11 | import { draftOf, form } from "./home.ts"; |
| 11 | import { PHOTOS_MAX } from "../photos.ts"; | |
| 12 | import { ALT_MAX, PHOTOS_MAX } from "../photos.ts"; | |
| 12 | 13 | import { layout, toast } from "./layout.ts"; |
| 13 | 14 | import { photoUrl } from "./listing.ts"; |
| 14 | 15 | |
| @@ -28,13 +29,23 @@ export function factsPage(input: { who: Who; listing: Listing; notice?: string | | ||
| 28 | 29 | ); |
| 29 | 30 | } |
| 30 | 31 | |
| 32 | /** One photo's description box, with the Fair Housing finding beside it: the words are public on the property page. */ | |
| 33 | function altField(l: Listing, name: string, i: number): string { | |
| 34 | const said = l.photo_alt[name] ?? ""; | |
| 35 | const found = check(said).findings; | |
| 36 | return `<label class="field"><span>Describe photo ${i + 1} <small class="muted">optional, up to ${ALT_MAX} characters; read aloud instead of "Photo ${i + 1} of ${escape(l.address)}"</small></span> | |
| 37 | <input class="input" name="alt.${escape(name)}" value="${escape(said)}" maxlength="${ALT_MAX}" autocomplete="off"${found.length > 0 ? ` aria-describedby="alt-fh-${i}"` : ""}></label> | |
| 38 | ${found.length === 0 ? "" : `<ul class="fh-list" id="alt-fh-${i}" aria-label="Phrases to review in this description">${found.map((f) => `<li><b>${escape(f.text)}</b>: ${escape(f.line)}</li>`).join("")}</ul>`}`; | |
| 39 | } | |
| 40 | ||
| 31 | 41 | /** The photos as they are: make one the hero, drop some, add more. Its own POST, multipart. */ |
| 32 | 42 | function photosForm(l: Listing): string { |
| 33 | 43 | const room = PHOTOS_MAX - l.photos.length; |
| 34 | 44 | return `<form class="card form" method="post" action="/listings/${l.id}/photos" enctype="multipart/form-data" aria-labelledby="photos-title"> |
| 35 | 45 | <h3 id="photos-title">Photos</h3> |
| 36 | <p class="muted">The first is the hero on the flyer and the email. Saving clears the approval of those two; the other assets keep theirs.</p> | |
| 37 | ${l.photos.length === 0 ? `<p class="muted">No photos yet.</p>` : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 1} of ${escape(l.address)}" width="240" height="160" loading="lazy"> | |
| 46 | <p class="muted">The first is the hero on the flyer and the email. Making a different one the hero or removing one clears the approval of those two; the other assets keep theirs. Descriptions clear nothing.</p> | |
| 47 | ${l.photos.length === 0 ? `<p class="muted">No photos yet.</p>` : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 1} of ${l.address}`))}" width="240" height="160" loading="lazy"> | |
| 48 | ${altField(l, p, i)} | |
| 38 | 49 | ${i === 0 ? `<b>Hero</b>` : `<button class="button button-quiet" name="hero" value="${escape(p)}">Make hero</button>`} |
| 39 | 50 | <label class="choice"><input type="checkbox" name="remove" value="${escape(p)}"> <span>Remove</span></label></li>`).join("")}</ul>`} |
| 40 | 51 | ${room <= 0 ? `<p class="muted">Eight photos is the most; remove one to add another.</p>` : `<label class="field"><span>Add photos <small class="muted">up to ${room} more, JPEG, PNG or WebP, 24 MB at a time</small></span> |
src/pages/home.ts +2 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { count, money, STATUS_LABELS, TONES, type Tone } from "../generate.ts"; |
| 9 | 9 | import type { ListingSummary } from "../actions.ts"; |
| 10 | import { altOf } from "../listing.ts"; | |
| 10 | 11 | import { counts, Q_MAX, find, FILTERS, type Filter, type Find } from "../find.ts"; |
| 11 | 12 | import { escape } from "../http.ts"; |
| 12 | 13 | import { |
| @@ -219,7 +220,7 @@ function list(listings: readonly ListingSummary[], wanted: Find): string { | ||
| 219 | 220 | const hero = l.photos[0]; |
| 220 | 221 | return `<li class="listing-card"> |
| 221 | 222 | <a href="/listings/${l.id}"> |
| 222 | ${hero === undefined ? `<span class="thumb thumb-empty palette-${l.palette}"><span class="brand-fill"></span></span>` : `<img class="thumb" src="${escape(photoUrl(l, hero))}" alt="" width="320" height="200" loading="lazy">`} | |
| 223 | ${hero === undefined ? `<span class="thumb thumb-empty palette-${l.palette}"><span class="brand-fill"></span></span>` : `<img class="thumb" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, ""))}" width="320" height="200" loading="lazy">`} | |
| 223 | 224 | <span class="card-body"> |
| 224 | 225 | <b>${escape(l.address)}</b> |
| 225 | 226 | <span>${money(l.price)} · ${l.beds} bd · ${l.baths} ba · ${count(l.sqft)} sq ft</span> |
src/pages/listing.ts +4 −4
| @@ -19,7 +19,7 @@ import { | ||
| 19 | 19 | TONES, |
| 20 | 20 | } from "../generate.ts"; |
| 21 | 21 | import { asset as assetHref, escape } from "../http.ts"; |
| 22 | import { approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts"; | |
| 22 | import { altOf, approvalLabel, ASSET_MAX, propertyPath, type Asset, type Launched, type Listing } from "../listing.ts"; | |
| 23 | 23 | import { isPost, lengthLine, meantForX, overXNote } from "../postlength.ts"; |
| 24 | 24 | import { isSample } from "../photos.ts"; |
| 25 | 25 | import type { Who } from "../who.ts"; |
| @@ -254,7 +254,7 @@ export function listingPage(input: { | ||
| 254 | 254 | </header> |
| 255 | 255 | ${editable ? "" : `<p class="card read-only">You are reading ${escape(l.author_name === "Sample listing" ? "the sample listing" : `${l.author_name}'s listing`)}. <a href="/?from=${l.id}">Start a new one from it</a> to make the words your own.</p>`} |
| 256 | 256 | ${propertyLink(l, origin)} |
| 257 | ${l.photos.length === 0 ? "" : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 1} of ${escape(l.address)}" width="240" height="160" loading="${i < 3 ? "eager" : "lazy"}"></li>`).join("")}</ul>`} | |
| 257 | ${l.photos.length === 0 ? "" : `<ul class="strip">${l.photos.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 1} of ${l.address}`))}" width="240" height="160" loading="${i < 3 ? "eager" : "lazy"}"></li>`).join("")}</ul>`} | |
| 258 | 258 | <div class="tabs" data-tabs> |
| 259 | 259 | <nav class="tablist" role="tablist" aria-label="Assets">${TABS.map( |
| 260 | 260 | (t) => `<a role="tab" href="?tab=${t.id}" id="tab-${t.id}" aria-controls="panel-${t.id}" aria-selected="${t.id === open}">${t.label}${tabFlags(t.kinds) === 0 ? "" : ` <span class="tab-flag" aria-label="${tabFlags(t.kinds)} to review">${tabFlags(t.kinds)}</span>`}</a>`, |
| @@ -293,7 +293,7 @@ export function flyer(l: Launched, origin = ""): string { | ||
| 293 | 293 | ${draftBand(l, "flyer")} |
| 294 | 294 | ${l.status === "active" ? "" : `<p class="flyer-status status-${l.status}" role="note">${escape(statusLine(l))}</p>`} |
| 295 | 295 | <div class="flyer-hero"> |
| 296 | ${hero === undefined ? `<div class="flyer-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`} | |
| 296 | ${hero === undefined ? `<div class="flyer-hero-empty brand-fill"></div>` : `<img src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="1200" height="800">`} | |
| 297 | 297 | <p class="flyer-badge brand-fill">${l.status === "sold" ? "Just sold" : l.status === "active" ? "Just listed" : STATUS_LABELS[l.status]}</p> |
| 298 | 298 | </div> |
| 299 | 299 | <header class="flyer-head"> |
| @@ -309,7 +309,7 @@ export function flyer(l: Launched, origin = ""): string { | ||
| 309 | 309 | </ul> |
| 310 | 310 | <div class="flyer-body"> |
| 311 | 311 | <p>${escape(copy.body)}</p> |
| 312 | ${more.length === 0 ? "" : `<div class="flyer-more">${more.slice(0, 2).map((p) => `<img src="${escape(photoUrl(l, p))}" alt="" width="400" height="267">`).join("")}</div>`} | |
| 312 | ${more.length === 0 ? "" : `<div class="flyer-more">${more.slice(0, 2).map((p) => `<img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, ""))}" width="400" height="267">`).join("")}</div>`} | |
| 313 | 313 | </div> |
| 314 | 314 | <footer class="flyer-agent"> |
| 315 | 315 | ${l.headshot === null ? "" : `<img class="flyer-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="96" height="96">`} |
src/pages/property.ts +3 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { factItems, money, statusLine, whenOf } from "../generate.ts"; |
| 9 | 9 | import { escape } from "../http.ts"; |
| 10 | import { propertyPath, publicDescription, type Launched } from "../listing.ts"; | |
| 10 | import { altOf, propertyPath, publicDescription, type Launched } from "../listing.ts"; | |
| 11 | 11 | import type { Who } from "../who.ts"; |
| 12 | 12 | import { layout } from "./layout.ts"; |
| 13 | 13 | import { photoUrl } from "./listing.ts"; |
| @@ -20,7 +20,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string { | ||
| 20 | 20 | return layout( |
| 21 | 21 | `<article class="home palette-${escape(l.palette)}"> |
| 22 | 22 | ${words === null ? `<p class="card home-notice" role="note">Not public yet: approve the MLS description</p>` : ""} |
| 23 | ${hero === undefined ? "" : `<img class="home-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(l.address)}" width="1200" height="800">`} | |
| 23 | ${hero === undefined ? "" : `<img class="home-hero" src="${escape(photoUrl(l, hero))}" alt="${escape(altOf(l, hero, l.address))}" width="1200" height="800">`} | |
| 24 | 24 | <header class="home-head"> |
| 25 | 25 | <h2>${escape(l.address)}</h2> |
| 26 | 26 | <p class="home-price brand">${money(l.price)}</p> |
| @@ -31,7 +31,7 @@ export function propertyPage(input: { who: Who; listing: Launched }): string { | ||
| 31 | 31 | </ul> |
| 32 | 32 | ${l.open_houses.length === 0 || l.status !== "active" ? "" : `<p class="home-open">Open house: ${l.open_houses.map((o) => escape(whenOf(o))).join(" and ")}</p>`} |
| 33 | 33 | ${words === null ? "" : `<p class="home-words">${escape(words).replace(/\n/g, "<br>")}</p>`} |
| 34 | ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="Photo ${i + 2} of ${escape(l.address)}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`} | |
| 34 | ${more.length === 0 ? "" : `<ul class="strip">${more.map((p, i) => `<li><img src="${escape(photoUrl(l, p))}" alt="${escape(altOf(l, p, `Photo ${i + 2} of ${l.address}`))}" width="240" height="160" loading="lazy"></li>`).join("")}</ul>`} | |
| 35 | 35 | <footer class="home-agent card"> |
| 36 | 36 | ${l.headshot === null ? "" : `<img class="home-face" src="${escape(photoUrl(l, l.headshot))}" alt="Photo of ${escape(l.agent_name)}" width="72" height="72">`} |
| 37 | 37 | <div> |
src/photoalt.test.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | /** A photo's description: saved with the photos, the alt text on every surface that shows it, checked like typed words. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { getListing, insertListing, listListings, updatePhotos } from "./actions.ts"; | |
| 7 | import { Database } from "bun:sqlite"; | |
| 8 | import { migrations, openDb } from "./db.ts"; | |
| 9 | import { SAMPLE, seed } from "./sample.ts"; | |
| 10 | import { email } from "./pages/email.ts"; | |
| 11 | import { cardPage } from "./pages/card.ts"; | |
| 12 | import { factsPage } from "./pages/facts.ts"; | |
| 13 | import { flyer, listingPage } from "./pages/listing.ts"; | |
| 14 | import { home } from "./pages/home.ts"; | |
| 15 | import { propertyPage } from "./pages/property.ts"; | |
| 16 | import type { Who } from "./who.ts"; | |
| 17 | ||
| 18 | const db = openDb(mkdtempSync(join(tmpdir(), "photoalt-"))); | |
| 19 | const pat: Who = { role: "user", id: "pat", name: "Pat" }; | |
| 20 | const other: Who = { role: "user", id: "other", name: "Other" }; | |
| 21 | const made = insertListing(db, { id: "pat", name: "Pat" }, SAMPLE, ["sample-front.svg", "sample-garden.svg", "sample-condo-front.svg"], null); | |
| 22 | const id = made.id; | |
| 23 | const load = () => { | |
| 24 | const r = getListing(db, pat, { id }); | |
| 25 | if (!r.ok) throw new Error(r.message); | |
| 26 | return r.value; | |
| 27 | }; | |
| 28 | const alts = (html: string) => [...html.matchAll(/<img [^>]*?alt="([^"]*)"/g)].map((m) => m[1]); | |
| 29 | ||
| 30 | test("without descriptions today's alt text stays", () => { | |
| 31 | const l = load(); | |
| 32 | expect(l.photo_alt).toEqual({}); | |
| 33 | expect(alts(propertyPage({ who: pat, listing: l }))).toContain(`Photo 2 of ${SAMPLE.address}`); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a description is saved and is the alt text on every surface", () => { | |
| 37 | const done = updatePhotos(db, pat, { id, alt: { "sample-front.svg": " Kitchen with quartz island ", "sample-garden.svg": "Back garden" } }); | |
| 38 | expect(done.ok).toBe(true); | |
| 39 | const l = load(); | |
| 40 | expect(l.photo_alt["sample-front.svg"]).toBe("Kitchen with quartz island"); | |
| 41 | for (const html of [ | |
| 42 | propertyPage({ who: pat, listing: l }), | |
| 43 | email(l), | |
| 44 | flyer(l), | |
| 45 | cardPage({ who: pat, listing: l }), | |
| 46 | factsPage({ who: pat, listing: l }), | |
| 47 | ]) | |
| 48 | expect(alts(html)).toContain("Kitchen with quartz island"); | |
| 49 | expect(alts(propertyPage({ who: pat, listing: l }))).toContain("Back garden"); | |
| 50 | expect(alts(flyer(l))).toContain("Back garden"); | |
| 51 | expect(alts(listingPage({ who: pat, listing: l, tab: "mls" } as never))).toContain("Back garden"); | |
| 52 | // the third has none: its generic alt stays | |
| 53 | expect(alts(factsPage({ who: pat, listing: l }))).toContain(`Photo 3 of ${SAMPLE.address}`); | |
| 54 | }); | |
| 55 | ||
| 56 | test("too long, a control character, a stranger's photo and a stranger are refused", () => { | |
| 57 | for (const alt of ["x".repeat(121), "two\nlines", "bell\u0007", 5]) { | |
| 58 | const r = updatePhotos(db, pat, { id, alt: { "sample-front.svg": alt } }); | |
| 59 | expect(r.ok).toBe(false); | |
| 60 | } | |
| 61 | expect(updatePhotos(db, pat, { id, alt: { "nope.jpg": "x" } }).ok).toBe(false); | |
| 62 | expect(updatePhotos(db, other, { id, alt: { "sample-front.svg": "mine" } }).ok).toBe(false); | |
| 63 | expect(updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "x".repeat(120) } }).ok).toBe(true); | |
| 64 | expect(load().photo_alt["sample-front.svg"]).toBe("Kitchen with quartz island"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a hostile description is escaped in every alt", () => { | |
| 68 | const evil = `"><script>alert(1)</script><img alt="`; | |
| 69 | expect(updatePhotos(db, pat, { id, alt: { "sample-front.svg": evil } }).ok).toBe(true); | |
| 70 | const l = load(); | |
| 71 | for (const html of [ | |
| 72 | propertyPage({ who: pat, listing: l }), | |
| 73 | email(l), | |
| 74 | flyer(l), | |
| 75 | cardPage({ who: pat, listing: l }), | |
| 76 | factsPage({ who: pat, listing: l }), | |
| 77 | listingPage({ who: pat, listing: l, tab: "mls" } as never), | |
| 78 | ]) { | |
| 79 | expect(html).not.toContain("<script>alert(1)"); | |
| 80 | expect(html).toContain("<script>"); | |
| 81 | } | |
| 82 | }); | |
| 83 | ||
| 84 | test("a flagged description shows its finding beside the box", () => { | |
| 85 | updatePhotos(db, pat, { id, alt: { "sample-front.svg": "Perfect for a young family" } }); | |
| 86 | const html = factsPage({ who: pat, listing: load() }); | |
| 87 | expect(html).toContain('aria-label="Phrases to review in this description"'); | |
| 88 | updatePhotos(db, pat, { id, alt: { "sample-front.svg": "Front porch" } }); | |
| 89 | expect(factsPage({ who: pat, listing: load() })).not.toContain("Phrases to review in this description"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a description clears no approval; a new order does", () => { | |
| 93 | const approve = () => | |
| 94 | db.query("UPDATE asset SET approved_by_id = 'x', approved_by_name = 'x', approved_at = 'now', approved_via = 'x' WHERE listing_id = ?1").run(id); | |
| 95 | const approved = () => | |
| 96 | db.query<{ n: number }, [number]>("SELECT count(*) AS n FROM asset WHERE listing_id = ?1 AND approved_at IS NOT NULL").get(id)!.n; | |
| 97 | approve(); | |
| 98 | const all = approved(); | |
| 99 | updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "Garden at dusk" } }); | |
| 100 | expect(approved()).toBe(all); | |
| 101 | updatePhotos(db, pat, { id, order: ["sample-garden.svg"] }); | |
| 102 | expect(approved()).toBe(all - 2); | |
| 103 | }); | |
| 104 | ||
| 105 | test("removing a photo removes its description; empty text takes one away", () => { | |
| 106 | updatePhotos(db, pat, { id, alt: { "sample-garden.svg": "Garden at dusk" } }); | |
| 107 | updatePhotos(db, pat, { id, remove: ["sample-garden.svg"] }); | |
| 108 | expect(load().photo_alt["sample-garden.svg"]).toBeUndefined(); | |
| 109 | updatePhotos(db, pat, { id, alt: { "sample-front.svg": "" } }); | |
| 110 | expect(load().photo_alt).toEqual({}); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the planted samples are described", () => { | |
| 114 | seed(db); | |
| 115 | const rows = db.query<{ photo_alt: string }, []>("SELECT photo_alt FROM listing WHERE author_id = 'sample'").all(); | |
| 116 | expect(rows.length).toBe(3); | |
| 117 | for (const r of rows) expect(Object.keys(JSON.parse(r.photo_alt)).length).toBe(2); | |
| 118 | }); | |
| 119 | ||
| 120 | test("the migration keeps existing rows and describes samples already planted", () => { | |
| 121 | const old = new Database(":memory:"); | |
| 122 | old.exec("PRAGMA foreign_keys = ON"); | |
| 123 | old.exec("CREATE TABLE schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT '')"); | |
| 124 | const last = migrations.length - 4; | |
| 125 | migrations.slice(0, last).forEach((sql, i) => { | |
| 126 | old.exec(sql); | |
| 127 | old.query("INSERT INTO schema_version (version) VALUES (?1)").run(i + 1); | |
| 128 | }); | |
| 129 | const cols = "author_id, author_name, address, beds, baths, sqft, lot, year, price, features, tone, palette, agent_name, brokerage, license, agent_phone, agent_email, photos"; | |
| 130 | const row = (author: string, photos: string) => | |
| 131 | old.query(`INSERT INTO listing (${cols}) VALUES (?1, 'n', '1 Old Road, Town', 1, 1, 1, 'l', 1999, 1, '[]', 'warm', 'teal', 'a', 'b', 'c', 'd', 'e', ?2)`).run(author, photos); | |
| 132 | row("pat", '["a1b2.jpg"]'); | |
| 133 | row("sample", '["sample-front.svg","sample-garden.svg"]'); | |
| 134 | migrations.slice(last).forEach((sql, i) => { | |
| 135 | old.exec(sql); | |
| 136 | old.query("INSERT INTO schema_version (version) VALUES (?1)").run(last + i + 1); | |
| 137 | }); | |
| 138 | const got = old.query<{ author_id: string; photos: string; photo_alt: string }, []>("SELECT author_id, photos, photo_alt FROM listing ORDER BY id").all(); | |
| 139 | expect(got[0]).toEqual({ author_id: "pat", photos: '["a1b2.jpg"]', photo_alt: "{}" }); | |
| 140 | expect(JSON.parse(got[1]!.photo_alt)["sample-front.svg"]).toContain("Alder Lane"); | |
| 141 | }); | |
| 142 | ||
| 143 | test("the home list's card thumbnail carries the hero's description", () => { | |
| 144 | expect(updatePhotos(db, pat, { id, alt: { "sample-front.svg": "Kitchen with quartz island" } }).ok).toBe(true); | |
| 145 | const r = listListings(db, pat); | |
| 146 | if (!r.ok) throw new Error(r.message); | |
| 147 | const html = home({ who: pat, listings: r.value, draft: {} as never }); | |
| 148 | expect(alts(html)).toContain("Kitchen with quartz island"); | |
| 149 | }); |
src/photos.ts +20
| @@ -19,6 +19,26 @@ export const PHOTO_BYTES = 6 * 1024 * 1024; | ||
| 19 | 19 | /** All of a launch's photos and the headshot together: shown at page size, they need no more. */ |
| 20 | 20 | export const PHOTOS_TOTAL = 24 * 1024 * 1024; |
| 21 | 21 | |
| 22 | /** The longest one-line description of a photo: its alt text everywhere it is shown. */ | |
| 23 | export const ALT_MAX = 120; | |
| 24 | ||
| 25 | /** | |
| 26 | * A photo's description as typed: trimmed, one line, at most ALT_MAX | |
| 27 | * characters, no control characters. Empty means none. | |
| 28 | */ | |
| 29 | export function cleanAlt( | |
| 30 | value: unknown, | |
| 31 | ): { ok: true; value: string } | { ok: false; message: string } { | |
| 32 | if (typeof value !== "string") | |
| 33 | return { ok: false, message: "A photo's description is text." }; | |
| 34 | const text = value.trim(); | |
| 35 | if (/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(text)) | |
| 36 | return { ok: false, message: "A photo's description is one line of plain text." }; | |
| 37 | if ([...text].length > ALT_MAX) | |
| 38 | return { ok: false, message: `A photo's description is at most ${ALT_MAX} characters.` }; | |
| 39 | return { ok: true, value: text }; | |
| 40 | } | |
| 41 | ||
| 22 | 42 | export type Kind = "jpg" | "png" | "webp"; |
| 23 | 43 | export const TYPES: Readonly<Record<Kind | "svg", string>> = { |
| 24 | 44 | jpg: "image/jpeg", |
src/routes.ts +13 −4
| @@ -297,7 +297,7 @@ const LISTING_BODY: Schema = { | ||
| 297 | 297 | const LISTING_CHANGE: Schema = { |
| 298 | 298 | type: "object", |
| 299 | 299 | description: |
| 300 | "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the photos changed ({ photos: { order, remove } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.", | |
| 300 | "Either one asset's new words ({ kind, text }), one asset signed off or withdrawn ({ kind, approved, seen }), every asset written again in a tone ({ tone }), or the photos changed ({ photos: { order, remove, alt } }), or the facts changed ({ facts: { price, ... } }, any of the launch fields), which writes every asset again; both replace edits and clear approvals.", | |
| 301 | 301 | properties: { |
| 302 | 302 | kind: { enum: [...KINDS] }, |
| 303 | 303 | text: { type: "string", minLength: 1, maxLength: ASSET_MAX }, |
| @@ -311,10 +311,11 @@ const LISTING_CHANGE: Schema = { | ||
| 311 | 311 | }, |
| 312 | 312 | photos: { |
| 313 | 313 | type: "object", |
| 314 | description: "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop. Uploads are multipart on the page only. Clears the flyer's and the email's approval.", | |
| 314 | description: "Photos to change: `order` names the photos to put first (the first is the hero), `remove` names the ones to drop, `alt` describes photos (like {\"<name>\": \"Kitchen with quartz island\"}, up to 120 characters, empty takes it away): the alt text wherever the photo shows. Uploads are multipart on the page only. A new order or a removal clears the flyer's and the email's approval; a description clears nothing.", | |
| 315 | 315 | properties: { |
| 316 | 316 | order: { type: "array", items: { type: "string" } }, |
| 317 | 317 | remove: { type: "array", items: { type: "string" } }, |
| 318 | alt: { type: "object", additionalProperties: { type: "string", maxLength: 120 } }, | |
| 318 | 319 | }, |
| 319 | 320 | }, |
| 320 | 321 | restore: { type: "integer", description: "Put back an earlier version of the asset `kind` (its id from earlier_versions in the listing page or list_versions): a new save, checked again, approval cleared. With `seen`, a conflict (409) if the words have changed since." }, |
| @@ -389,9 +390,12 @@ const uploads = async (form: FormData, name: string): Promise<Upload[]> => | ||
| 389 | 390 | ); |
| 390 | 391 | |
| 391 | 392 | /** A JSON photo change: the order and the removals, whatever else was sent left out. */ |
| 392 | const photoChange = (raw: Record<string, unknown>): { order: unknown[]; remove: unknown[] } => ({ | |
| 393 | const photoChange = ( | |
| 394 | raw: Record<string, unknown>, | |
| 395 | ): { order: unknown[]; remove: unknown[]; alt: Record<string, unknown> } => ({ | |
| 393 | 396 | order: Array.isArray(raw.order) ? raw.order : [], |
| 394 | 397 | remove: Array.isArray(raw.remove) ? raw.remove : [], |
| 398 | alt: fields(raw.alt), | |
| 395 | 399 | }); |
| 396 | 400 | |
| 397 | 401 | /** The form's text fields, as plain data. */ |
| @@ -535,9 +539,14 @@ export const routes: readonly Route[] = [ | ||
| 535 | 539 | add: await uploads(form, "photos"), |
| 536 | 540 | remove: form.getAll("remove").map(String), |
| 537 | 541 | order: typeof hero === "string" && hero !== "" ? [hero] : [], |
| 542 | alt: Object.fromEntries( | |
| 543 | [...form.entries()].flatMap(([k, v]) => | |
| 544 | k.startsWith("alt.") && typeof v === "string" ? [[k.slice(4), v]] : [], | |
| 545 | ), | |
| 546 | ), | |
| 538 | 547 | }); |
| 539 | 548 | return done.ok |
| 540 | ? back("Photos saved. The flyer and the email need approving again.", to) | |
| 549 | ? back("Photos saved. If you changed the order or removed one, the flyer and the email need approving again; descriptions clear nothing.", to) | |
| 541 | 550 | : back(done.message, to); |
| 542 | 551 | }, |
| 543 | 552 | }, |
src/sample.ts +9 −1
| @@ -107,17 +107,20 @@ interface Sample { | ||
| 107 | 107 | readonly listing: ListingInput; |
| 108 | 108 | readonly photos: readonly string[]; |
| 109 | 109 | readonly headshot: string; |
| 110 | /** What each photo shows, in the order of `photos`: their alt text. */ | |
| 111 | readonly alt: readonly string[]; | |
| 110 | 112 | /** Where the sample stands; absent means active. */ |
| 111 | 113 | readonly sold_at?: number; |
| 112 | 114 | } |
| 113 | 115 | |
| 114 | 116 | const SAMPLES: readonly Sample[] = [ |
| 115 | { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT }, | |
| 117 | { name: "sample", listing: { ...SAMPLE, open_houses: [SAMPLE_OPEN_HOUSE] }, photos: SAMPLE_PHOTOS, headshot: SAMPLE_HEADSHOT, alt: ["Front of 412 Alder Lane with a covered porch", "Back garden with raised beds and a patio"] }, | |
| 116 | 118 | { |
| 117 | 119 | name: "sample-condo", |
| 118 | 120 | listing: SAMPLE_CONDO, |
| 119 | 121 | photos: ["sample-condo-front.svg", "sample-condo-living.svg"], |
| 120 | 122 | headshot: "sample-agent-2.svg", |
| 123 | alt: ["Building entrance with a glass lobby", "Living room with floor-to-ceiling windows"], | |
| 121 | 124 | // Sold, so the meeting sees the band, the pill and the "Just sold" post and email. |
| 122 | 125 | sold_at: 752000, |
| 123 | 126 | }, |
| @@ -126,6 +129,7 @@ const SAMPLES: readonly Sample[] = [ | ||
| 126 | 129 | listing: SAMPLE_HOUSE, |
| 127 | 130 | photos: ["sample-house-front.svg", "sample-house-yard.svg"], |
| 128 | 131 | headshot: "sample-agent-3.svg", |
| 132 | alt: ["Brick front with a two-car garage", "Fenced yard with a mature oak"], | |
| 129 | 133 | }, |
| 130 | 134 | ]; |
| 131 | 135 | |
| @@ -136,6 +140,10 @@ export function seed(db: Database): void { | ||
| 136 | 140 | const done = db.query("INSERT OR IGNORE INTO seeded (name) VALUES (?1)").run(s.name); |
| 137 | 141 | if (done.changes === 0) continue; |
| 138 | 142 | const made = insertListing(db, { id: "sample", name: "Sample listing" }, s.listing, s.photos, s.headshot); |
| 143 | db.query("UPDATE listing SET photo_alt = ?2 WHERE id = ?1").run( | |
| 144 | made.id, | |
| 145 | JSON.stringify(Object.fromEntries(s.photos.map((name, i) => [name, s.alt[i]]))), | |
| 146 | ); | |
| 139 | 147 | // Alder Lane opens finished: every asset signed off by its sample agent, never by a person's door. |
| 140 | 148 | if (s.name === "sample") |
| 141 | 149 | db.query( |
This is the square picture; the home list card thumbnail (src/pages/home.ts:222, alt="") still ignores the description. Use altOf there too and test it.