Render header photo in place details
Shows the blurhash and fades in the image once downloaded
This commit is contained in:
37
app/components/blurhash.gjs
Normal file
37
app/components/blurhash.gjs
Normal 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>
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
30
app/modifiers/fade-in-image.js
Normal file
30
app/modifiers/fade-in-image.js
Normal 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);
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user