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 PlacePhotoUpload from './place-photo-upload';
|
||||||
import NostrConnect from './nostr-connect';
|
import NostrConnect from './nostr-connect';
|
||||||
import Modal from './modal';
|
import Modal from './modal';
|
||||||
|
import Blurhash from './blurhash';
|
||||||
|
import fadeInImage from '../modifiers/fade-in-image';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
@@ -19,6 +21,7 @@ import { action } from '@ember/object';
|
|||||||
export default class PlaceDetails extends Component {
|
export default class PlaceDetails extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
|
@service nostrData;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
@tracked showLists = false;
|
@tracked showLists = false;
|
||||||
@tracked isPhotoUploadModalOpen = false;
|
@tracked isPhotoUploadModalOpen = false;
|
||||||
@@ -76,6 +79,53 @@ export default class PlaceDetails extends Component {
|
|||||||
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
|
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
|
@action
|
||||||
startEditing() {
|
startEditing() {
|
||||||
if (!this.isSaved) return; // Only allow editing saved places
|
if (!this.isSaved) return; // Only allow editing saved places
|
||||||
@@ -339,6 +389,24 @@ export default class PlaceDetails extends Component {
|
|||||||
@onCancel={{this.cancelEditing}}
|
@onCancel={{this.cancelEditing}}
|
||||||
/>
|
/>
|
||||||
{{else}}
|
{{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>
|
<h3>{{this.name}}</h3>
|
||||||
<p class="place-type">
|
<p class="place-type">
|
||||||
{{this.type}}
|
{{this.type}}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
@service storage;
|
@service storage;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
|
@service nostrData;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
createNewPlace() {
|
createNewPlace() {
|
||||||
@@ -149,9 +150,17 @@ export default class PlacesSidebar extends Component {
|
|||||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasHeaderPhoto() {
|
||||||
|
return (
|
||||||
|
this.args.selectedPlace &&
|
||||||
|
this.nostrData.placePhotos &&
|
||||||
|
this.nostrData.placePhotos.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
|
||||||
{{#if @selectedPlace}}
|
{{#if @selectedPlace}}
|
||||||
<button
|
<button
|
||||||
type="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;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-header.no-border {
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-header h2 {
|
.sidebar-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
@@ -856,6 +860,45 @@ abbr[title] {
|
|||||||
padding-bottom: 2rem;
|
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 {
|
.place-details h3 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const POI_CATEGORIES = [
|
|||||||
id: 'accommodation',
|
id: 'accommodation',
|
||||||
label: 'Hotels',
|
label: 'Hotels',
|
||||||
icon: 'person-sleeping-in-bed',
|
icon: 'person-sleeping-in-bed',
|
||||||
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
|
filter: ['["tourism"~"^(hotel|hostel|motel|chalet)$"]'],
|
||||||
types: ['node', 'way', 'relation'],
|
types: ['node', 'way', 'relation'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user