Render header photo in place details

Shows the blurhash and fades in the image once downloaded
This commit is contained in:
2026-04-21 23:07:06 +04:00
parent 99cfd96ca1
commit 85a8699b78
6 changed files with 189 additions and 2 deletions

View File

@@ -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);
}
});
<template>
<canvas
class="blurhash-canvas"
...attributes
{{this.renderBlurhash @hash @width @height}}
></canvas>
</template>
}

View File

@@ -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}}
<div class="place-header-photo-wrapper">
{{#if this.headerPhoto.blurhash}}
<Blurhash
@hash={{this.headerPhoto.blurhash}}
@width={{32}}
@height={{18}}
class="place-header-photo-blur"
/>
{{/if}}
<img
src={{this.headerPhoto.url}}
class="place-header-photo"
alt={{this.name}}
{{fadeInImage this.headerPhoto.url}}
/>
</div>
{{/if}}
<h3>{{this.name}}</h3>
<p class="place-type">
{{this.type}}

View File

@@ -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
);
}
<template>
<div class="sidebar">
<div class="sidebar-header">
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
{{#if @selectedPlace}}
<button
type="button"

View File

@@ -0,0 +1,30 @@
import { modifier } from 'ember-modifier';
export default modifier((element, [url]) => {
if (!url) return;
// Remove classes when URL changes
element.classList.remove('loaded');
element.classList.remove('loaded-instant');
// Create an off-DOM image to reliably check cache status
// without waiting for the actual DOM element to load it
const img = new Image();
img.src = url;
if (img.complete) {
// Already in browser cache, skip the animation
element.classList.add('loaded-instant');
return;
}
const handleLoad = () => {
element.classList.add('loaded');
};
element.addEventListener('load', handleLoad);
return () => {
element.removeEventListener('load', handleLoad);
};
});

View File

@@ -462,6 +462,10 @@ body {
align-items: center;
}
.sidebar-header.no-border {
border-bottom-color: transparent;
}
.sidebar-header h2 {
margin: 0;
font-size: 1.2rem;
@@ -856,6 +860,45 @@ abbr[title] {
padding-bottom: 2rem;
}
.place-header-photo-wrapper {
margin: -1rem -1rem 1rem;
background-color: var(--hover-bg);
position: relative;
overflow: hidden;
aspect-ratio: 16 / 9;
}
.place-header-photo-blur {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.place-header-photo {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.place-header-photo.loaded {
opacity: 1;
}
.place-header-photo.loaded-instant {
opacity: 1;
transition: none;
}
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;

View File

@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
id: 'accommodation',
label: 'Hotels',
icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
types: ['node', 'way', 'relation'],
},
];