Add alt text to photo uploads
CI / Lint (pull_request) Successful in 53s
CI / Test (pull_request) Successful in 1m6s
Release Drafter / Update release notes draft (pull_request) Successful in 5s

- Form field for optionally adding a description
- Publish as `imeta` `alt`
- Render as `<img>` alt when set

Fixes a placeholder being published instead. Also strips the current
placeholder if set as alt text (for existing uploads).
This commit is contained in:
2026-08-07 07:25:53 -06:00
parent ac9870240f
commit 9807a2b822
7 changed files with 140 additions and 28 deletions
+4 -4
View File
@@ -237,7 +237,7 @@ export default class PhotoCarousel extends Component {
data-src={{photo.url}} data-src={{photo.url}}
class="place-header-photo class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}" {{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}} alt={{photo.alt}}
{{fadeInImage photo.url}} {{fadeInImage photo.url}}
/> />
{{else if this.isGalleryThumbnails}} {{else if this.isGalleryThumbnails}}
@@ -245,7 +245,7 @@ export default class PhotoCarousel extends Component {
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}} data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}" {{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}} alt={{photo.alt}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}} {{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/> />
{{else}} {{else}}
@@ -260,7 +260,7 @@ export default class PhotoCarousel extends Component {
<img <img
data-src={{photo.url}} data-src={{photo.url}}
class="place-header-photo landscape" class="place-header-photo landscape"
alt={{@name}} alt={{photo.alt}}
{{fadeInImage photo.url}} {{fadeInImage photo.url}}
/> />
</picture> </picture>
@@ -269,7 +269,7 @@ export default class PhotoCarousel extends Component {
<img <img
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}} data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait" class="place-header-photo portrait"
alt={{@name}} alt={{photo.alt}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}} {{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/> />
{{/if}} {{/if}}
+30 -8
View File
@@ -27,6 +27,7 @@ export default class PlacePhotoUpload extends Component {
@tracked isPublishing = false; @tracked isPublishing = false;
@tracked isDragging = false; @tracked isDragging = false;
@tracked selectedTags = []; @tracked selectedTags = [];
@tracked altText = '';
get place() { get place() {
return this.args.place || {}; return this.args.place || {};
@@ -103,6 +104,7 @@ export default class PlacePhotoUpload extends Component {
this.file = null; this.file = null;
this.uploadedPhoto = null; this.uploadedPhoto = null;
this.selectedTags = []; this.selectedTags = [];
this.altText = '';
if (this.args.onUploadStateChange) { if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false); this.args.onUploadStateChange(false);
} }
@@ -118,6 +120,11 @@ export default class PlacePhotoUpload extends Component {
this.selectedTags = [tag]; this.selectedTags = [tag];
} }
@action
updateAltText(event) {
this.altText = event.target.value;
}
deletePhotoTask = task(async (photoData) => { deletePhotoTask = task(async (photoData) => {
try { try {
if (photoData.hash) { if (photoData.hash) {
@@ -177,7 +184,10 @@ export default class PlacePhotoUpload extends Component {
imeta.push(`dim ${photo.dim}`); imeta.push(`dim ${photo.dim}`);
} }
imeta.push('alt A photo of a place'); const alt = this.altText.trim();
if (alt) {
imeta.push(`alt ${alt}`);
}
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) { if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
for (const fallbackUrl of photo.fallbackUrls) { for (const fallbackUrl of photo.fallbackUrls) {
@@ -209,6 +219,7 @@ export default class PlacePhotoUpload extends Component {
// Clear out the file so user can upload more or be done // Clear out the file so user can upload more or be done
this.file = null; this.file = null;
this.uploadedPhoto = null; this.uploadedPhoto = null;
this.altText = '';
if (this.args.onUploadStateChange) { if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false); this.args.onUploadStateChange(false);
@@ -235,13 +246,11 @@ export default class PlacePhotoUpload extends Component {
{{/if}} {{/if}}
{{#if this.file}} {{#if this.file}}
<div class="photo-grid"> <PlacePhotoUploadItem
<PlacePhotoUploadItem @file={{this.file}}
@file={{this.file}} @onSuccess={{this.handleUploadSuccess}}
@onSuccess={{this.handleUploadSuccess}} @onRemove={{this.removeFile}}
@onRemove={{this.removeFile}} />
/>
</div>
{{#if this.suggestedTags.length}} {{#if this.suggestedTags.length}}
<div class="photo-tag-suggestions"> <div class="photo-tag-suggestions">
@@ -263,6 +272,19 @@ export default class PlacePhotoUpload extends Component {
</div> </div>
{{/if}} {{/if}}
<div class="form-group">
<label for="photo-alt-input">Description (optional):</label>
<input
id="photo-alt-input"
type="text"
class="form-control"
placeholder="Describe this photo"
value={{this.altText}}
disabled={{this.isPublishing}}
{{on "input" this.updateAltText}}
/>
</div>
<button <button
type="button" type="button"
class="btn btn-primary btn-publish" class="btn btn-primary btn-publish"
+6 -8
View File
@@ -245,13 +245,6 @@ body {
display: none; display: none;
} }
.photo-grid {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.photo-upload-item { .photo-upload-item {
position: relative; position: relative;
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
@@ -259,6 +252,7 @@ body {
overflow: hidden; overflow: hidden;
background: #1e262e; background: #1e262e;
width: 100%; width: 100%;
margin-bottom: 1.25rem;
} }
.photo-upload-item img { .photo-upload-item img {
@@ -1866,8 +1860,12 @@ button.create-place {
font-size: 1.2rem; font-size: 1.2rem;
} }
.place-photo-upload .form-group {
margin-bottom: 1.5rem;
}
.photo-tag-suggestions { .photo-tag-suggestions {
margin: 1rem 0 1.5rem; margin-bottom: 1.25rem;
} }
.photo-tag-suggestions-title { .photo-tag-suggestions-title {
+6
View File
@@ -67,6 +67,7 @@ export function parsePlacePhotos(events) {
let blurhash = null; let blurhash = null;
let isLandscape = false; let isLandscape = false;
let aspectRatio = 16 / 9; // default let aspectRatio = 16 / 9; // default
let altText = null;
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1]; let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
for (const tag of imeta.slice(1)) { for (const tag of imeta.slice(1)) {
@@ -85,6 +86,10 @@ export function parsePlacePhotos(events) {
isLandscape = true; isLandscape = true;
} }
} }
} else if (tag.startsWith('alt ')) {
const alt = tag.substring(4).trim();
// Strip the legacy placeholder we used to write on every event
altText = alt === 'A photo of a place' ? null : alt || null;
} }
} }
@@ -100,6 +105,7 @@ export function parsePlacePhotos(events) {
aspectRatio, aspectRatio,
placeIdentifier, placeIdentifier,
tags: eventTagValues, tags: eventTagValues,
alt: altText,
}); });
} }
} }
+9 -8
View File
@@ -2331,8 +2331,9 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9} engines: {node: ^4.5.0 || >= 5.9}
baseline-browser-mapping@2.9.14: baseline-browser-mapping@2.11.12:
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==}
engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
basic-auth@2.0.1: basic-auth@2.0.1:
@@ -2538,8 +2539,8 @@ packages:
resolution: {integrity: sha512-RbsNrFyhwkx+6psk/0fK/Q9orOUr9VMxohGd8vTa4djf4TGLfblBgUfqZChrZuW0Q+mz2eBPFLusw9Jfukzmhg==} resolution: {integrity: sha512-RbsNrFyhwkx+6psk/0fK/Q9orOUr9VMxohGd8vTa4djf4TGLfblBgUfqZChrZuW0Q+mz2eBPFLusw9Jfukzmhg==}
hasBin: true hasBin: true
caniuse-lite@1.0.30001764: caniuse-lite@1.0.30001809:
resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
capture-exit@2.0.0: capture-exit@2.0.0:
resolution: {integrity: sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==} resolution: {integrity: sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==}
@@ -8420,7 +8421,7 @@ snapshots:
base64id@2.0.0: {} base64id@2.0.0: {}
baseline-browser-mapping@2.9.14: {} baseline-browser-mapping@2.11.12: {}
basic-auth@2.0.1: basic-auth@2.0.1:
dependencies: dependencies:
@@ -8752,8 +8753,8 @@ snapshots:
browserslist@4.28.1: browserslist@4.28.1:
dependencies: dependencies:
baseline-browser-mapping: 2.9.14 baseline-browser-mapping: 2.11.12
caniuse-lite: 1.0.30001764 caniuse-lite: 1.0.30001809
electron-to-chromium: 1.5.267 electron-to-chromium: 1.5.267
node-releases: 2.0.27 node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1) update-browserslist-db: 1.2.3(browserslist@4.28.1)
@@ -8805,7 +8806,7 @@ snapshots:
dependencies: dependencies:
tmp: 0.0.28 tmp: 0.0.28
caniuse-lite@1.0.30001764: {} caniuse-lite@1.0.30001809: {}
capture-exit@2.0.0: capture-exit@2.0.0:
dependencies: dependencies:
@@ -97,4 +97,27 @@ module('Integration | Component | place-photo-upload', function (hooks) {
assert.dom('.photo-tag-suggestions').doesNotExist(); assert.dom('.photo-tag-suggestions').doesNotExist();
}); });
test('it renders an optional alt text input after upload selection', async function (assert) {
this.place = {
title: 'Cafe Alpha',
osmId: '123',
osmType: 'node',
osmTags: { amenity: 'cafe' },
};
await render(
<template><PlacePhotoUpload @place={{this.place}} /></template>
);
assert.dom('#photo-alt-input').doesNotExist();
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
await selectFile(this.element, file);
assert.dom('#photo-alt-input').exists();
assert
.dom('#photo-alt-input')
.hasAttribute('placeholder', 'Describe this photo');
});
}); });
+62
View File
@@ -148,6 +148,68 @@ module('Unit | Utility | nostr', function () {
assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456'); assert.strictEqual(photos[1].placeIdentifier, 'osm:node:456');
}); });
test('parsePlacePhotos extracts alt text from imeta', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [
[
'imeta',
'url https://example.com/photo.jpg',
'dim 800x600',
'alt A sunny terrace overlooking the bay',
],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].alt, 'A sunny terrace overlooking the bay');
});
test('parsePlacePhotos strips the legacy placeholder alt text', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [
[
'imeta',
'url https://example.com/photo.jpg',
'dim 800x600',
'alt A photo of a place',
],
],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].alt, null);
});
test('parsePlacePhotos leaves alt null when not provided', function (assert) {
const events = [
{
id: 'event-1',
pubkey: 'pubkey-1',
created_at: 100,
tags: [['imeta', 'url https://example.com/photo.jpg', 'dim 800x600']],
},
];
const photos = parsePlacePhotos(events);
assert.strictEqual(photos.length, 1);
assert.strictEqual(photos[0].alt, null);
});
test('uniqNormalizedRelays returns normalized unique relays', function (assert) { test('uniqNormalizedRelays returns normalized unique relays', function (assert) {
const relays = uniqNormalizedRelays([ const relays = uniqNormalizedRelays([
'Relay.example.com', 'Relay.example.com',