feat: use blurHash when loading list backgrounds (#1188)
continuous-integration/drone/push Build is passing Details

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: #1188
This commit is contained in:
konrad 2022-04-02 15:05:30 +00:00
parent 53787a65df
commit 4cff3ebee1
13 changed files with 387 additions and 234 deletions

View File

@ -26,6 +26,7 @@
"@vue/compat": "3.2.31", "@vue/compat": "3.2.31",
"@vueuse/core": "8.2.3", "@vueuse/core": "8.2.3",
"@vueuse/router": "8.2.3", "@vueuse/router": "8.2.3",
"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.65.2", "codemirror": "5.65.2",

View File

@ -1,17 +1,21 @@
<template> <template>
<div> <div class="content-auth" :class="{'modal-active': modalActive}">
<BaseButton <BaseButton
v-if="menuActive" v-if="menuActive"
@click="$store.commit('menuActive', false)" @click="$store.commit('menuActive', false)"
class="menu-hide-button" class="menu-hide-button"
> >
<icon icon="times" /> <icon icon="times"/>
</BaseButton> </BaseButton>
<div <div
:class="{'has-background': background}" :class="{'has-background': background || blurHash}"
:style="{'background-image': background && `url(${background})`}" :style="{'background-image': blurHash && `url(${blurHash})`}"
class="app-container" class="app-container"
> >
<div
:class="{'is-visible': background}"
class="app-container-background background-fade-in"
:style="{'background-image': background && `url(${background})`}"></div>
<navigation/> <navigation/>
<main <main
:class="[ :class="[
@ -31,18 +35,18 @@
<router-view :route="routeWithModal" v-slot="{ Component }"> <router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']"> <keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component" /> <component :is="Component"/>
</keep-alive> </keep-alive>
</router-view> </router-view>
<transition name="modal"> <transition name="modal">
<modal <modal
v-if="currentModal" v-if="currentModal"
@close="closeModal()" @close="closeModal()"
variant="scrolling" variant="scrolling"
class="task-detail-view-modal" class="task-detail-view-modal"
> >
<component :is="currentModal" /> <component :is="currentModal"/>
</modal> </modal>
</transition> </transition>
@ -115,16 +119,17 @@ function useRouteWithModal() {
} }
} }
return { routeWithModal, currentModal, closeModal } return {routeWithModal, currentModal, closeModal}
} }
const { routeWithModal, currentModal, closeModal } = useRouteWithModal() const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
const store = useStore() const store = useStore()
const background = computed(() => store.state.background) const background = computed(() => store.state.background)
const blurHash = computed(() => store.state.blurHash)
const menuActive = computed(() => store.state.menuActive) const menuActive = computed(() => store.state.menuActive)
const modalActive = computed(() => store.state.modalActive)
function showKeyboardShortcuts() { function showKeyboardShortcuts() {
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true) store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
@ -151,7 +156,7 @@ watch(() => route.name as string, (routeName) => {
'migrate.start', 'migrate.start',
'migrate.wunderlist', 'migrate.wunderlist',
'namespaces.index', 'namespaces.index',
].includes(routeName) || ].includes(routeName) ||
routeName.startsWith('user.settings') routeName.startsWith('user.settings')
) )
) { ) {
@ -163,7 +168,7 @@ watch(() => route.name as string, (routeName) => {
function useRenewTokenOnFocus() { function useRenewTokenOnFocus() {
const router = useRouter() const router = useRouter()
const userInfo = computed(() => store.state.auth.info) const userInfo = computed(() => store.state.auth.info)
const authenticated = computed(() => store.state.auth.authenticated) const authenticated = computed(() => store.state.auth.authenticated)
@ -227,39 +232,41 @@ store.dispatch('labels/loadAllLabels')
} }
.app-container { .app-container {
min-height: calc(100vh - 65px); min-height: calc(100vh - 65px);
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
padding-top: $navbar-height; padding-top: $navbar-height;
}
.app-content {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
// Used to make sure the spinner is always in the middle while loading
> .loader-container {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
} }
@media screen and (max-width: $tablet) { .app-content {
margin-left: 0; padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
padding-top: 1.5rem; z-index: 10;
min-height: calc(100vh - 4rem); position: relative;
}
&.is-menu-enabled { // Used to make sure the spinner is always in the middle while loading
margin-left: $navbar-width; > .loader-container {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
}
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
min-width: 100%; margin-left: 0;
margin-left: 0; padding-top: 1.5rem;
} min-height: calc(100vh - 4rem);
} }
.card { &.is-menu-enabled {
background: var(--white); margin-left: $navbar-width;
}
} @media screen and (max-width: $tablet) {
min-width: 100%;
margin-left: 0;
}
}
.card {
background: var(--white);
}
}
} }
.mobile-overlay { .mobile-overlay {
@ -290,11 +297,20 @@ store.dispatch('labels/loadAllLabels')
color: var(--grey-500); color: var(--grey-500);
transition: color $transition; transition: color $transition;
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
display: none; display: none;
} }
} }
.content-auth {
position: relative;
z-index: 1;
&.modal-active {
z-index: unset;
}
}
@include modal-transition(); @include modal-transition();
</style> </style>

View File

@ -2,28 +2,34 @@
<router-link <router-link
:class="{ :class="{
'has-light-text': !colorIsDark(list.hexColor), 'has-light-text': !colorIsDark(list.hexColor),
'has-background': background !== null 'has-background': blurHashUrl !== ''
}" }"
:style="{ :style="{
'background-color': list.hexColor, 'background-color': list.hexColor,
'background-image': background !== null ? `url(${background})` : false, 'background-image': blurHashUrl !== null ? `url(${blurHashUrl})` : false,
}" }"
:to="{ name: 'list.index', params: { listId: list.id} }" :to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card" class="list-card"
v-if="list !== null && (showArchived ? true : !list.isArchived)" v-if="list !== null && (showArchived ? true : !list.isArchived)"
> >
<div class="is-archived-container"> <div
class="list-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : false}"></div>
<div class="list-content">
<div class="is-archived-container">
<span class="is-archived" v-if="list.isArchived"> <span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }} {{ $t('namespace.archived') }}
</span> </span>
<span <span
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}" :class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
@click.stop="toggleFavoriteList(list)" @click.stop="toggleFavoriteList(list)"
class="favorite"> class="favorite">
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" /> <icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
</span> </span>
</div>
<div class="title">{{ list.title }}</div>
</div> </div>
<div class="title">{{ list.title }}</div>
</router-link> </router-link>
</template> </template>
@ -32,12 +38,14 @@ import {PropType, ref, watch} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import ListService from '@/services/list' import ListService from '@/services/list'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {colorIsDark} from '@/helpers/color/colorIsDark' import {colorIsDark} from '@/helpers/color/colorIsDark'
import ListModel from '@/models/list' import ListModel from '@/models/list'
const background = ref<string | null>(null) const background = ref<string | null>(null)
const backgroundLoading = ref(false) const backgroundLoading = ref(false)
const blurHashUrl = ref('')
const props = defineProps({ const props = defineProps({
list: { list: {
@ -50,13 +58,18 @@ const props = defineProps({
}, },
}) })
watch(props.list, loadBackground, { immediate: true }) watch(props.list, loadBackground, {immediate: true})
async function loadBackground() { async function loadBackground() {
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) { if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
return return
} }
const blurHash = await getBlobFromBlurHash(props.list.backgroundBlurHash)
if (blurHash) {
blurHashUrl.value = window.URL.createObjectURL(blurHash)
}
backgroundLoading.value = true backgroundLoading.value = true
const listService = new ListService() const listService = new ListService()
@ -81,129 +94,145 @@ function toggleFavoriteList(list: ListModel) {
<style lang="scss" scoped> <style lang="scss" scoped>
.list-card { .list-card {
cursor: pointer; cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row}); width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height; height: $list-height;
background: var(--white); background: var(--white);
margin: 0 $list-spacing $list-spacing 0; margin: 0 $list-spacing $list-spacing 0;
padding: 1rem; border-radius: $radius;
border-radius: $radius; box-shadow: var(--shadow-sm);
box-shadow: var(--shadow-sm); transition: box-shadow $transition;
transition: box-shadow $transition; position: relative;
overflow: hidden;
display: flex; &.has-light-text .title {
justify-content: space-between; color: var(--light);
flex-wrap: wrap; }
&:hover { &.has-background, .list-background {
box-shadow: var(--shadow-md); background-size: cover;
} background-repeat: no-repeat;
background-position: center;
}
&:active, &.has-background .list-content .title {
&:focus, text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
&:focus:not(:active) { color: var(--white);
box-shadow: var(--shadow-xs) !important; }
}
@media screen and (min-width: $widescreen) { .list-background {
&:nth-child(#{$lists-per-row}n) { position: absolute;
margin-right: 0; top: 0;
} right: 0;
} bottom: 0;
left: 0;
}
@media screen and (max-width: $widescreen) and (min-width: $tablet) { &:hover {
$lists-per-row: 3; box-shadow: var(--shadow-md);
& { }
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) { &:active,
margin-right: 0; &:focus,
} &:focus:not(:active) {
} box-shadow: var(--shadow-xs) !important;
}
@media screen and (max-width: $tablet) { @media screen and (min-width: $widescreen) {
$lists-per-row: 2; &:nth-child(#{$lists-per-row}n) {
& { margin-right: 0;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row}); }
} }
&:nth-child(#{$lists-per-row}n) { @media screen and (max-width: $widescreen) and (min-width: $tablet) {
margin-right: 0; $lists-per-row: 3;
} & {
} width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
@media screen and (max-width: $mobile) { &:nth-child(#{$lists-per-row}n) {
$lists-per-row: 1; margin-right: 0;
& { }
width: 100%; }
margin-right: 0;
}
}
.is-archived-container { @media screen and (max-width: $tablet) {
width: 100%; $lists-per-row: 2;
text-align: right; & {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
.is-archived { &:nth-child(#{$lists-per-row}n) {
font-size: .75rem; margin-right: 0;
float: left; }
} }
}
.title { @media screen and (max-width: $mobile) {
align-self: flex-end; $lists-per-row: 1;
font-family: $vikunja-font; & {
font-weight: 400; width: 100%;
font-size: 1.5rem; margin-right: 0;
color: var(--text); }
width: 100%; }
margin-bottom: 0;
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box; .list-content {
-webkit-line-clamp: 3; display: flex;
-webkit-box-orient: vertical; justify-content: space-between;
} flex-wrap: wrap;
padding: 1rem;
position: absolute;
height: 100%;
width: 100%;
&.has-light-text .title { .is-archived-container {
color: var(--light); width: 100%;
} text-align: right;
&.has-background { .is-archived {
background-size: cover; font-size: .75rem;
background-repeat: no-repeat; float: left;
background-position: center; }
}
.title { .title {
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700); align-self: flex-end;
color: var(--white); font-family: $vikunja-font;
} font-weight: 400;
} font-size: 1.5rem;
color: var(--text);
width: 100%;
margin-bottom: 0;
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
.favorite { display: -webkit-box;
transition: opacity $transition, color $transition; -webkit-line-clamp: 3;
opacity: 0; -webkit-box-orient: vertical;
}
&:hover { .favorite {
color: var(--warning); transition: opacity $transition, color $transition;
} opacity: 0;
&.is-archived { &:hover {
display: none; color: var(--warning);
} }
&.is-favorite { &.is-archived {
display: inline-block; display: none;
opacity: 1; }
color: var(--warning);
}
}
&:hover .favorite { &.is-favorite {
opacity: 1; display: inline-block;
} opacity: 1;
color: var(--warning);
}
}
&:hover .favorite {
opacity: 1;
}
}
} }
</style> </style>

View File

@ -64,6 +64,9 @@
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import {ref, watch} from 'vue' import {ref, watch} from 'vue'
import {useScrollLock} from '@vueuse/core' import {useScrollLock} from '@vueuse/core'
import {useStore} from 'vuex'
const store = useStore()
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
enabled?: boolean, enabled?: boolean,
@ -86,6 +89,7 @@ watch(
() => props.enabled, () => props.enabled,
enabled => { enabled => {
scrollLock.value = enabled scrollLock.value = enabled
store.commit('modalActive', enabled)
}, },
{ {
immediate: true, immediate: true,

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

@ -20,7 +20,7 @@ export default class ListModel extends AbstractModel {
this.owner = new UserModel(this.owner) this.owner = new UserModel(this.owner)
if(typeof this.subscription !== 'undefined' && this.subscription !== null) { if (typeof this.subscription !== 'undefined' && this.subscription !== null) {
this.subscription = new SubscriptionModel(this.subscription) this.subscription = new SubscriptionModel(this.subscription)
} }
@ -44,6 +44,7 @@ export default class ListModel extends AbstractModel {
isFavorite: false, isFavorite: false,
subscription: null, subscription: null,
position: 0, position: 0,
backgroundBlurHash: '',
created: null, created: null,
updated: null, updated: null,

View File

@ -1,6 +1,8 @@
import {createStore} from 'vuex' import {createStore} from 'vuex'
import {getBlobFromBlurHash} from '../helpers/getBlobFromBlurHash'
import { import {
BACKGROUND, BACKGROUND,
BLUR_HASH,
CURRENT_LIST, CURRENT_LIST,
HAS_TASKS, HAS_TASKS,
KEYBOARD_SHORTCUTS_ACTIVE, KEYBOARD_SHORTCUTS_ACTIVE,
@ -44,10 +46,12 @@ export const store = createStore({
isArchived: false, isArchived: false,
}), }),
background: '', background: '',
blurHash: '',
hasTasks: false, hasTasks: false,
menuActive: true, menuActive: true,
keyboardShortcutsActive: false, keyboardShortcutsActive: false,
quickActionsActive: false, quickActionsActive: false,
modalActive: false,
}, },
mutations: { mutations: {
[LOADING](state, loading) { [LOADING](state, loading) {
@ -83,6 +87,12 @@ export const store = createStore({
[BACKGROUND](state, background) { [BACKGROUND](state, background) {
state.background = background state.background = background
}, },
[BLUR_HASH](state, blurHash) {
state.blurHash = blurHash
},
modalActive(state, active) {
state.modalActive = active
},
}, },
actions: { actions: {
async [CURRENT_LIST]({state, commit}, currentList) { async [CURRENT_LIST]({state, commit}, currentList) {
@ -90,6 +100,7 @@ export const store = createStore({
if (currentList === null) { if (currentList === null) {
commit(CURRENT_LIST, {}) commit(CURRENT_LIST, {})
commit(BACKGROUND, null) commit(BACKGROUND, null)
commit(BLUR_HASH, null)
return return
} }
@ -122,10 +133,15 @@ export const store = createStore({
) { ) {
if (currentList.backgroundInformation) { if (currentList.backgroundInformation) {
try { try {
const blurHash = await getBlobFromBlurHash(currentList.backgroundBlurHash)
if (blurHash) {
commit(BLUR_HASH, window.URL.createObjectURL(blurHash))
}
const listService = new ListService() const listService = new ListService()
const background = await listService.background(currentList) const background = await listService.background(currentList)
commit(BACKGROUND, background) commit(BACKGROUND, background)
} catch(e) { } catch (e) {
console.error('Error getting background image for list', currentList.id, e) console.error('Error getting background image for list', currentList.id, e)
} }
} }
@ -133,6 +149,7 @@ export const store = createStore({
if (typeof currentList.backgroundInformation === 'undefined' || currentList.backgroundInformation === null) { if (typeof currentList.backgroundInformation === 'undefined' || currentList.backgroundInformation === null) {
commit(BACKGROUND, null) commit(BACKGROUND, null)
commit(BLUR_HASH, null)
} }
commit(CURRENT_LIST, currentList) commit(CURRENT_LIST, currentList)

View File

@ -6,6 +6,6 @@ export const MENU_ACTIVE = 'menuActive'
export const KEYBOARD_SHORTCUTS_ACTIVE = 'keyboardShortcutsActive' export const KEYBOARD_SHORTCUTS_ACTIVE = 'keyboardShortcutsActive'
export const QUICK_ACTIONS_ACTIVE = 'quickActionsActive' export const QUICK_ACTIONS_ACTIVE = 'quickActionsActive'
export const BACKGROUND = 'background' export const BACKGROUND = 'background'
export const BLUR_HASH = 'blurHash'
export const CONFIG = 'config' export const CONFIG = 'config'
export const AUTH = 'auth'

View File

@ -1,12 +1,16 @@
.app-container.has-background, .app-container.has-background,
.link-share-container.has-background { .link-share-container.has-background {
background-position: center; position: relative;
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed;
min-height: 100vh;
// FIXME: move to pagination component &, .app-container-background {
background-position: center;
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed;
min-height: 100vh;
}
// FIXME: move to pagination component
.pagination-link:not(.is-current) { .pagination-link:not(.is-current) {
background: var(--grey-100); background: var(--grey-100);
} }
@ -32,4 +36,21 @@
border-radius: $radius !important; border-radius: $radius !important;
} }
} }
} }
.app-container-background {
width: 100vw;
height: 100vh;
position: fixed;
z-index: 0;
}
.background-fade-in {
opacity: 0;
transition: opacity $transition;
transition-delay: $transition-duration * 2; // To fake an appearing background
&.is-visible {
opacity: 1;
}
}

View File

@ -55,7 +55,7 @@ import Message from '@/components/misc/message.vue'
import ListModel from '@/models/list' import ListModel from '@/models/list'
import ListService from '@/services/list' import ListService from '@/services/list'
import {BACKGROUND, CURRENT_LIST} from '@/store/mutation-types' import {BACKGROUND, BLUR_HASH, CURRENT_LIST} from '@/store/mutation-types'
import {getListTitle} from '@/helpers/getListTitle' import {getListTitle} from '@/helpers/getListTitle'
import {saveListToHistory} from '@/modules/listHistory' import {saveListToHistory} from '@/modules/listHistory'
@ -145,6 +145,7 @@ async function loadList(listIdToLoad: number) {
const listFromStore = store.getters['lists/getListById'](listData.id) const listFromStore = store.getters['lists/getListById'](listData.id)
if (listFromStore !== null) { if (listFromStore !== null) {
store.commit(BACKGROUND, null) store.commit(BACKGROUND, null)
store.commit(BLUR_HASH, null)
store.commit(CURRENT_LIST, listFromStore) store.commit(CURRENT_LIST, listFromStore)
} }

View File

@ -35,16 +35,25 @@
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 +74,8 @@
<script> <script>
import {mapState} from 'vuex' import {mapState} from 'vuex'
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 +94,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 +132,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
})
}) })
}, },
@ -165,82 +185,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

@ -4217,6 +4217,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"