feat: use BlurHash when rendering unsplash search results

This commit is contained in:
kolaente 2021-12-12 22:25:11 +01:00
parent ed332f5dd3
commit 356e01cd14
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
5 changed files with 130 additions and 69 deletions

View File

@ -24,6 +24,7 @@
"@vue/compat": "3.2.26", "@vue/compat": "3.2.26",
"@vueuse/core": "7.3.0", "@vueuse/core": "7.3.0",
"@vueuse/router": "7.3.0", "@vueuse/router": "7.3.0",
"blurhash": "^1.1.4",
"bulma-css-variables": "0.9.33", "bulma-css-variables": "0.9.33",
"camel-case": "4.1.2", "camel-case": "4.1.2",
"codemirror": "5.64.0", "codemirror": "5.64.0",

View File

@ -0,0 +1,31 @@
import {decode} from 'blurhash'
export async function getBlobFromBlurHash(blurHash: string): Promise<Blob | null> {
if (blurHash === '') {
return null
}
const pixels = decode(blurHash, 32, 32)
const canvas = document.createElement('canvas')
canvas.width = 32
canvas.height = 32
const ctx = canvas.getContext('2d')
if (ctx === null) {
return null
}
const imageData = ctx.createImageData(32, 32)
imageData.data.set(pixels)
ctx.putImageData(imageData, 0, 0)
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(b => {
if (b === null) {
reject(b)
return
}
resolve(b)
})
})
}

View File

@ -7,6 +7,7 @@ export default class BackgroundImageModel extends AbstractModel {
url: '', url: '',
thumb: '', thumb: '',
info: {}, info: {},
blurHash: '',
} }
} }
} }

View File

@ -35,16 +35,21 @@
v-model="backgroundSearchTerm" v-model="backgroundSearchTerm"
/> />
<p class="unsplash-link"> <p class="unsplash-link">
<a href="https://unsplash.com" rel="noreferrer noopener nofollow" target="_blank">{{ $t('list.background.poweredByUnsplash') }}</a> <a href="https://unsplash.com" rel="noreferrer noopener nofollow"
target="_blank">{{ $t('list.background.poweredByUnsplash') }}</a>
</p> </p>
<div class="image-search-result"> <div class="image-search-result">
<a <a
:key="im.id" :key="im.id"
:style="{'background-image': `url(${backgroundThumbs[im.id]})`}" :style="{'background-image': `url(${backgroundBlurHashes[im.id]})`}"
@click="() => setBackground(im.id)" @click="() => setBackground(im.id)"
class="image" class="image"
v-for="im in backgroundSearchResult"> v-for="im in backgroundSearchResult">
<a :href="`https://unsplash.com/@${im.info.author}`" rel="noreferrer noopener nofollow" target="_blank" class="info"> <transition name="fade">
<img :src="backgroundThumbs[im.id]" alt="" v-if="backgroundThumbs[im.id]"/>
</transition>
<a :href="`https://unsplash.com/@${im.info.author}`" rel="noreferrer noopener nofollow"
target="_blank" class="info">
{{ im.info.authorName }} {{ im.info.authorName }}
</a> </a>
</a> </a>
@ -65,6 +70,9 @@
<script> <script>
import {mapState} from 'vuex' import {mapState} from 'vuex'
import {decode} from 'blurhash'
import {getBlobFromBlurHash} from '../../../helpers/getBlobFromBlurHash'
import BackgroundUnsplashService from '../../../services/backgroundUnsplash' import BackgroundUnsplashService from '../../../services/backgroundUnsplash'
import BackgroundUploadService from '../../../services/backgroundUpload' import BackgroundUploadService from '../../../services/backgroundUpload'
import ListService from '@/services/list' import ListService from '@/services/list'
@ -83,6 +91,7 @@ export default {
backgroundSearchTerm: '', backgroundSearchTerm: '',
backgroundSearchResult: [], backgroundSearchResult: [],
backgroundThumbs: {}, backgroundThumbs: {},
backgroundBlurHashes: {},
currentPage: 1, currentPage: 1,
// We're using debounce to not search on every keypress but with a delay. // We're using debounce to not search on every keypress but with a delay.
@ -120,8 +129,16 @@ export default {
this.currentPage = page this.currentPage = page
const result = await this.backgroundService.getAll({}, {s: this.backgroundSearchTerm, p: page}) const result = await this.backgroundService.getAll({}, {s: this.backgroundSearchTerm, p: page})
this.backgroundSearchResult = this.backgroundSearchResult.concat(result) this.backgroundSearchResult = this.backgroundSearchResult.concat(result)
result.forEach(async background => { result.forEach(background => {
this.backgroundThumbs[background.id] = await this.backgroundService.thumb(background) getBlobFromBlurHash(background.blurHash)
.then(b => {
this.backgroundBlurHashes[background.id] = window.URL.createObjectURL(b)
})
this.backgroundService.thumb(background)
.then(b => {
this.backgroundThumbs[background.id] = b
})
}) })
}, },
@ -162,82 +179,88 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.list-background-setting { .list-background-setting {
.unsplash-link { .unsplash-link {
text-align: right; text-align: right;
font-size: .8rem; font-size: .8rem;
a { a {
color: var(--grey-800); color: var(--grey-800);
} }
} }
.image-search-result { .image-search-result {
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
.image { .image {
width: calc(100% / 5 - 1rem); width: calc(100% / 5 - 1rem);
height: 120px; height: 120px;
margin: .5rem; margin: .5rem;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
display: flex; display: flex;
position: relative;
@media screen and (min-width: $desktop) { @media screen and (min-width: $desktop) {
&:nth-child(5n) { &:nth-child(5n) {
break-after: always; break-after: always;
} }
} }
@media screen and (max-width: $desktop) { @media screen and (max-width: $desktop) {
width: calc(100% / 4 - 1rem); width: calc(100% / 4 - 1rem);
&:nth-child(4n) { &:nth-child(4n) {
break-after: always; break-after: always;
} }
} }
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
width: calc(100% / 2 - 1rem); width: calc(100% / 2 - 1rem);
&:nth-child(2n) { &:nth-child(2n) {
break-after: always; break-after: always;
} }
} }
@media screen and (max-width: ($mobile)) { @media screen and (max-width: ($mobile)) {
width: calc(100% - 1rem); width: calc(100% - 1rem);
&:nth-child(1n) { &:nth-child(1n) {
break-after: always; break-after: always;
} }
} }
.info { .info {
align-self: flex-end; align-self: flex-end;
display: block; display: block;
opacity: 0; opacity: 0;
width: 100%; width: 100%;
padding: .25rem 0; padding: .25rem 0;
text-align: center; text-align: center;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
font-size: .75rem; font-size: .75rem;
font-weight: bold; font-weight: bold;
color: var(--white); color: var(--white);
transition: opacity $transition; transition: opacity $transition;
} position: absolute;
}
&:hover .info { img {
opacity: 1; object-fit: cover;
} }
}
}
.is-load-more-button { &:hover .info {
margin: 1rem auto 0 !important; opacity: 1;
display: block; }
width: 200px; }
} }
.is-load-more-button {
margin: 1rem auto 0 !important;
display: block;
width: 200px;
}
} }
</style> </style>

View File

@ -4512,6 +4512,11 @@ blueimp-md5@^2.10.0:
resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0" resolved "https://registry.yarnpkg.com/blueimp-md5/-/blueimp-md5-2.19.0.tgz#b53feea5498dcb53dc6ec4b823adb84b729c4af0"
integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w== integrity sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==
blurhash@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.4.tgz#a7010ceb3019cd2c9809b17c910ebf6175d29244"
integrity sha512-MXIPz6zwYUKayju+Uidf83KhH0vodZfeRl6Ich8Gu+KGl0JgKiFq9LsfqV7cVU5fKD/AotmduZqvOfrGKOfTaA==
body-parser@1.19.0: body-parser@1.19.0:
version "1.19.0" version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"