From 85a8699b7892eeb02aca8b471b62d1d66f81a075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 21 Apr 2026 23:07:06 +0400 Subject: [PATCH] Render header photo in place details Shows the blurhash and fades in the image once downloaded --- app/components/blurhash.gjs | 37 +++++++++++++++++ app/components/place-details.gjs | 68 +++++++++++++++++++++++++++++++ app/components/places-sidebar.gjs | 11 ++++- app/modifiers/fade-in-image.js | 30 ++++++++++++++ app/styles/app.css | 43 +++++++++++++++++++ app/utils/poi-categories.js | 2 +- 6 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 app/components/blurhash.gjs create mode 100644 app/modifiers/fade-in-image.js diff --git a/app/components/blurhash.gjs b/app/components/blurhash.gjs new file mode 100644 index 0000000..0dee902 --- /dev/null +++ b/app/components/blurhash.gjs @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { modifier } from 'ember-modifier'; +import { decode } from 'blurhash'; + +export default class Blurhash extends Component { + renderBlurhash = modifier((canvas, [hash, width, height]) => { + if (!hash || !canvas) return; + + // Default size to a small multiple of aspect ratio to save CPU + // 32x18 is a good balance of speed vs quality for 16:9 + const renderWidth = width || 32; + const renderHeight = height || 18; + + canvas.width = renderWidth; + canvas.height = renderHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + try { + const pixels = decode(hash, renderWidth, renderHeight); + const imageData = ctx.createImageData(renderWidth, renderHeight); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + } catch (e) { + console.warn('Failed to decode blurhash:', e); + } + }); + + +} diff --git a/app/components/place-details.gjs b/app/components/place-details.gjs index 4c3acb6..662fa30 100644 --- a/app/components/place-details.gjs +++ b/app/components/place-details.gjs @@ -12,6 +12,8 @@ import PlaceListsManager from './place-lists-manager'; import PlacePhotoUpload from './place-photo-upload'; import NostrConnect from './nostr-connect'; import Modal from './modal'; +import Blurhash from './blurhash'; +import fadeInImage from '../modifiers/fade-in-image'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; @@ -19,6 +21,7 @@ import { action } from '@ember/object'; export default class PlaceDetails extends Component { @service storage; @service nostrAuth; + @service nostrData; @tracked isEditing = false; @tracked showLists = false; @tracked isPhotoUploadModalOpen = false; @@ -76,6 +79,53 @@ export default class PlaceDetails extends Component { return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place'; } + get headerPhoto() { + const photos = this.nostrData.placePhotos; + if (!photos || photos.length === 0) return null; + + // Sort by created_at ascending (oldest first) + const sortedEvents = [...photos].sort( + (a, b) => a.created_at - b.created_at + ); + + let firstPortrait = null; + + for (const event of sortedEvents) { + // Find all imeta tags + const imetas = event.tags.filter((t) => t[0] === 'imeta'); + for (const imeta of imetas) { + let url = null; + let blurhash = null; + let isLandscape = false; + + for (const tag of imeta.slice(1)) { + if (tag.startsWith('url ')) { + url = tag.substring(4); + } else if (tag.startsWith('blurhash ')) { + blurhash = tag.substring(9); + } else if (tag.startsWith('dim ')) { + const dimStr = tag.substring(4); + const [width, height] = dimStr.split('x').map(Number); + if (width && height && width > height) { + isLandscape = true; + } + } + } + + if (url) { + const photoData = { url, blurhash }; + if (isLandscape) { + return photoData; // Return the first landscape photo found + } else if (!firstPortrait) { + firstPortrait = photoData; // Save the first portrait as fallback + } + } + } + } + + return firstPortrait; + } + @action startEditing() { if (!this.isSaved) return; // Only allow editing saved places @@ -339,6 +389,24 @@ export default class PlaceDetails extends Component { @onCancel={{this.cancelEditing}} /> {{else}} + {{#if this.headerPhoto}} +
+ {{#if this.headerPhoto.blurhash}} + + {{/if}} + {{this.name}} +
+ {{/if}}

{{this.name}}

{{this.type}} diff --git a/app/components/places-sidebar.gjs b/app/components/places-sidebar.gjs index bf39204..e3bcc2d 100644 --- a/app/components/places-sidebar.gjs +++ b/app/components/places-sidebar.gjs @@ -14,6 +14,7 @@ export default class PlacesSidebar extends Component { @service storage; @service router; @service mapUi; + @service nostrData; @action createNewPlace() { @@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component { return !qp.q && !qp.category && qp.lat && qp.lon; } + get hasHeaderPhoto() { + return ( + this.args.selectedPlace && + this.nostrData.placePhotos && + this.nostrData.placePhotos.length > 0 + ); + } +