Merge pull request 'Add alt text to place photos' (#71) from bugix/photo_alt_text into master
CI / Lint (push) Successful in 53s
CI / Test (push) Successful in 1m2s

Reviewed-on: #71
This commit was merged in pull request #71.
This commit is contained in:
2026-08-07 13:30:39 +00:00
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}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
alt={{photo.alt}}
{{fadeInImage photo.url}}
/>
{{else if this.isGalleryThumbnails}}
@@ -245,7 +245,7 @@ export default class PhotoCarousel extends Component {
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo
{{if photo.isLandscape 'landscape' 'portrait'}}"
alt={{@name}}
alt={{photo.alt}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{else}}
@@ -260,7 +260,7 @@ export default class PhotoCarousel extends Component {
<img
data-src={{photo.url}}
class="place-header-photo landscape"
alt={{@name}}
alt={{photo.alt}}
{{fadeInImage photo.url}}
/>
</picture>
@@ -269,7 +269,7 @@ export default class PhotoCarousel extends Component {
<img
data-src={{if photo.thumbUrl photo.thumbUrl photo.url}}
class="place-header-photo portrait"
alt={{@name}}
alt={{photo.alt}}
{{fadeInImage (if photo.thumbUrl photo.thumbUrl photo.url)}}
/>
{{/if}}
+30 -8
View File
@@ -27,6 +27,7 @@ export default class PlacePhotoUpload extends Component {
@tracked isPublishing = false;
@tracked isDragging = false;
@tracked selectedTags = [];
@tracked altText = '';
get place() {
return this.args.place || {};
@@ -103,6 +104,7 @@ export default class PlacePhotoUpload extends Component {
this.file = null;
this.uploadedPhoto = null;
this.selectedTags = [];
this.altText = '';
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
}
@@ -118,6 +120,11 @@ export default class PlacePhotoUpload extends Component {
this.selectedTags = [tag];
}
@action
updateAltText(event) {
this.altText = event.target.value;
}
deletePhotoTask = task(async (photoData) => {
try {
if (photoData.hash) {
@@ -177,7 +184,10 @@ export default class PlacePhotoUpload extends Component {
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) {
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
this.file = null;
this.uploadedPhoto = null;
this.altText = '';
if (this.args.onUploadStateChange) {
this.args.onUploadStateChange(false);
@@ -235,13 +246,11 @@ export default class PlacePhotoUpload extends Component {
{{/if}}
{{#if this.file}}
<div class="photo-grid">
<PlacePhotoUploadItem
@file={{this.file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
</div>
<PlacePhotoUploadItem
@file={{this.file}}
@onSuccess={{this.handleUploadSuccess}}
@onRemove={{this.removeFile}}
/>
{{#if this.suggestedTags.length}}
<div class="photo-tag-suggestions">
@@ -263,6 +272,19 @@ export default class PlacePhotoUpload extends Component {
</div>
{{/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
type="button"
class="btn btn-primary btn-publish"
+6 -8
View File
@@ -245,13 +245,6 @@ body {
display: none;
}
.photo-grid {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.photo-upload-item {
position: relative;
aspect-ratio: 4 / 3;
@@ -259,6 +252,7 @@ body {
overflow: hidden;
background: #1e262e;
width: 100%;
margin-bottom: 1.25rem;
}
.photo-upload-item img {
@@ -1866,8 +1860,12 @@ button.create-place {
font-size: 1.2rem;
}
.place-photo-upload .form-group {
margin-bottom: 1.5rem;
}
.photo-tag-suggestions {
margin: 1rem 0 1.5rem;
margin-bottom: 1.25rem;
}
.photo-tag-suggestions-title {
+6
View File
@@ -67,6 +67,7 @@ export function parsePlacePhotos(events) {
let blurhash = null;
let isLandscape = false;
let aspectRatio = 16 / 9; // default
let altText = null;
let placeIdentifier = event.tags.find((t) => t[0] === 'i')?.[1];
for (const tag of imeta.slice(1)) {
@@ -85,6 +86,10 @@ export function parsePlacePhotos(events) {
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,
placeIdentifier,
tags: eventTagValues,
alt: altText,
});
}
}
+9 -8
View File
@@ -2331,8 +2331,9 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
baseline-browser-mapping@2.9.14:
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
baseline-browser-mapping@2.11.12:
resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==}
engines: {node: '>=6.0.0'}
hasBin: true
basic-auth@2.0.1:
@@ -2538,8 +2539,8 @@ packages:
resolution: {integrity: sha512-RbsNrFyhwkx+6psk/0fK/Q9orOUr9VMxohGd8vTa4djf4TGLfblBgUfqZChrZuW0Q+mz2eBPFLusw9Jfukzmhg==}
hasBin: true
caniuse-lite@1.0.30001764:
resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
caniuse-lite@1.0.30001809:
resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
capture-exit@2.0.0:
resolution: {integrity: sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==}
@@ -8420,7 +8421,7 @@ snapshots:
base64id@2.0.0: {}
baseline-browser-mapping@2.9.14: {}
baseline-browser-mapping@2.11.12: {}
basic-auth@2.0.1:
dependencies:
@@ -8752,8 +8753,8 @@ snapshots:
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.9.14
caniuse-lite: 1.0.30001764
baseline-browser-mapping: 2.11.12
caniuse-lite: 1.0.30001809
electron-to-chromium: 1.5.267
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
@@ -8805,7 +8806,7 @@ snapshots:
dependencies:
tmp: 0.0.28
caniuse-lite@1.0.30001764: {}
caniuse-lite@1.0.30001809: {}
capture-exit@2.0.0:
dependencies:
@@ -97,4 +97,27 @@ module('Integration | Component | place-photo-upload', function (hooks) {
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');
});
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) {
const relays = uniqNormalizedRelays([
'Relay.example.com',