feat: settings background script setup (#2104)

Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/2104
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
Dominik Pschenitschni 2022-09-01 16:09:50 +00:00 committed by konrad
parent a8d4892a0f
commit ff655808b3
8 changed files with 275 additions and 244 deletions

View file

@ -59,7 +59,7 @@ describe('Lists', () => {
.click() .click()
cy.get('#title') cy.get('#title')
.type(`{selectall}${newListName}`) .type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .button') cy.get('footer.card-footer .button')
.contains('Save') .contains('Save')
.click() .click()

View file

@ -63,7 +63,7 @@ describe('Namepaces', () => {
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded .should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
cy.get('#namespacetext') cy.get('#namespacetext')
.type(`{selectall}${newNamespaceName}`) .type(`{selectall}${newNamespaceName}`)
cy.get('footer.modal-card-foot .button') cy.get('footer.card-footer .button')
.contains('Save') .contains('Save')
.click() .click()

View file

@ -69,9 +69,11 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
text-transform: uppercase; text-transform: uppercase;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: bold; font-weight: bold;
height: auto;
min-height: $button-height; min-height: $button-height;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
display: inline-flex; display: inline-flex;
white-space: break-spaces;
&:hover { &:hover {
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);

View file

@ -16,11 +16,21 @@
</span> </span>
</BaseButton> </BaseButton>
</header> </header>
<div class="card-content loader-container" :class="{'p-0': !padding, 'is-loading': loading}"> <div
class="card-content loader-container"
:class="{
'p-0': !padding,
'is-loading': loading
}"
>
<div :class="{'content': hasContent}"> <div :class="{'content': hasContent}">
<slot></slot> <slot />
</div> </div>
</div> </div>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</div> </div>
</template> </template>
@ -76,9 +86,11 @@ defineEmits(['close'])
border-radius: $radius $radius 0 0; border-radius: $radius $radius 0 0;
} }
// FIXME: should maybe be merged somehow with modal .card-footer {
:deep(.modal-card-foot) {
background-color: var(--grey-50); background-color: var(--grey-50);
border-top: 0; border-top: 0;
padding: var(--modal-card-head-padding);
display: flex;
justify-content: flex-end;
} }
</style> </style>

View file

@ -4,38 +4,41 @@
:title="title" :title="title"
:shadow="false" :shadow="false"
:padding="false" :padding="false"
class="has-text-left has-overflow" class="has-text-left"
:has-close="true" :has-close="true"
@close="$router.back()" @close="$router.back()"
:loading="loading" :loading="loading"
> >
<div class="p-4"> <div class="p-4">
<slot></slot> <slot />
</div> </div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
<x-button <template #footer>
v-if="tertiary !== ''" <slot name="footer">
:shadow="false" <x-button
variant="tertiary" v-if="tertiary !== ''"
@click.prevent.stop="$emit('tertiary')" :shadow="false"
> variant="tertiary"
{{ tertiary }} @click.prevent.stop="$emit('tertiary')"
</x-button> >
<x-button {{ tertiary }}
variant="secondary" </x-button>
@click.prevent.stop="$router.back()" <x-button
> variant="secondary"
{{ $t('misc.cancel') }} @click.prevent.stop="$router.back()"
</x-button> >
<x-button {{ $t('misc.cancel') }}
variant="primary" </x-button>
@click.prevent.stop="primary()" <x-button
:icon="primaryIcon" variant="primary"
:disabled="primaryDisabled" @click.prevent.stop="primary()"
> :icon="primaryIcon"
{{ primaryLabel || $t('misc.create') }} :disabled="primaryDisabled || loading"
</x-button> >
</footer> {{ primaryLabel || $t('misc.create') }}
</x-button>
</slot>
</template>
</card> </card>
</modal> </modal>
</template> </template>

View file

@ -19,17 +19,16 @@
{{ $t('about.apiVersion', {version: apiVersion}) }} {{ $t('about.apiVersion', {version: apiVersion}) }}
</p> </p>
</div> </div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end"> <template #footer>
<x-button <x-button
variant="secondary" variant="secondary"
@click.prevent.stop="$router.back()" @click.prevent.stop="$router.back()"
> >
{{ $t('misc.close') }} {{ $t('misc.close') }}
</x-button> </x-button>
</footer> </template>
</card> </card>
</modal> </modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View file

@ -47,14 +47,17 @@
/> />
</div> </div>
</div> </div>
<x-button
:loading="savedFilterService.loading" <template #footer>
:disabled="savedFilterService.loading" <x-button
@click="create()" :loading="savedFilterService.loading"
class="is-fullwidth" :disabled="savedFilterService.loading"
> @click="create()"
{{ $t('filters.create.action') }} class="is-fullwidth"
</x-button> >
{{ $t('filters.create.action') }}
</x-button>
</template>
</card> </card>
</modal> </modal>
</template> </template>

View file

@ -1,13 +1,10 @@
<template> <template>
<create-edit <create-edit
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
:title="$t('list.background.title')" :title="$t('list.background.title')"
primary-label=""
:loading="backgroundService.loading" :loading="backgroundService.loading"
class="list-background-setting" class="list-background-setting"
:wide="true" :wide="true"
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
:tertiary="hasBackground ? $t('list.background.remove') : ''"
@tertiary="removeBackground()"
> >
<div class="mb-4" v-if="uploadBackgroundEnabled"> <div class="mb-4" v-if="uploadBackgroundEnabled">
<input <input
@ -19,7 +16,7 @@
/> />
<x-button <x-button
:loading="backgroundUploadService.loading" :loading="backgroundUploadService.loading"
@click="$refs.backgroundUploadInput.click()" @click="backgroundUploadInput?.click()"
variant="primary" variant="primary"
> >
{{ $t('list.background.upload') }} {{ $t('list.background.upload') }}
@ -28,245 +25,260 @@
<template v-if="unsplashBackgroundEnabled"> <template v-if="unsplashBackgroundEnabled">
<input <input
:class="{'is-loading': backgroundService.loading}" :class="{'is-loading': backgroundService.loading}"
@keyup="() => debounceNewBackgroundSearch()" @keyup="debounceNewBackgroundSearch()"
class="input is-expanded" class="input is-expanded"
:placeholder="$t('list.background.searchPlaceholder')" :placeholder="$t('list.background.searchPlaceholder')"
type="text" type="text"
v-model="backgroundSearchTerm" v-model="backgroundSearchTerm"
/> />
<p class="unsplash-link">
<BaseButton href="https://unsplash.com">{{ $t('list.background.poweredByUnsplash') }}</BaseButton> <p class="unsplash-credit">
<BaseButton class="unsplash-credit__link" href="https://unsplash.com">{{ $t('list.background.poweredByUnsplash') }}</BaseButton>
</p> </p>
<div class="image-search-result">
<a <ul class="image-search__result-list">
<li
v-for="im in backgroundSearchResult"
class="image-search__result-item"
:key="im.id" :key="im.id"
:style="{'background-image': `url(${backgroundBlurHashes[im.id]})`}" :style="{'background-image': `url(${backgroundBlurHashes[im.id]})`}"
@click="() => setBackground(im.id)" >
class="image"
v-for="im in backgroundSearchResult">
<transition name="fade"> <transition name="fade">
<img :src="backgroundThumbs[im.id]" alt="" v-if="backgroundThumbs[im.id]"/> <BaseButton
v-if="backgroundThumbs[im.id]"
class="image-search__image-button"
@click="setBackground(im.id)"
>
<img class="image-search__image" :src="backgroundThumbs[im.id]" alt="" />
</BaseButton>
</transition> </transition>
<a
<BaseButton
:href="`https://unsplash.com/@${im.info.author}`" :href="`https://unsplash.com/@${im.info.author}`"
rel="noreferrer noopener nofollow" class="image-search__info"
target="_blank" >
class="info">
{{ im.info.authorName }} {{ im.info.authorName }}
</a> </BaseButton>
</a> </li>
</div> </ul>
<x-button <x-button
v-if="backgroundSearchResult.length > 0"
:disabled="backgroundService.loading" :disabled="backgroundService.loading"
@click="() => searchBackgrounds(currentPage + 1)" @click="searchBackgrounds(currentPage + 1)"
class="is-load-more-button mt-4" class="is-load-more-button mt-4"
:shadow="false" :shadow="false"
variant="secondary" variant="secondary"
v-if="backgroundSearchResult.length > 0"
> >
{{ backgroundService.loading ? $t('misc.loading') : $t('list.background.loadMore') }} {{ backgroundService.loading ? $t('misc.loading') : $t('list.background.loadMore') }}
</x-button> </x-button>
</template> </template>
<template #footer>
<x-button
v-if="hasBackground"
:shadow="false"
variant="tertiary"
class="is-danger"
@click.prevent.stop="removeBackground"
>
{{ $t('list.background.remove') }}
</x-button>
<x-button
variant="secondary"
@click.prevent.stop="$router.back()"
>
{{ $t('misc.close') }}
</x-button>
</template>
</create-edit> </create-edit>
</template> </template>
<script lang="ts"> <script lang="ts">
import {defineComponent} from 'vue' import {defineComponent} from 'vue'
import {mapState} from 'vuex' export default defineComponent({ name: 'list-setting-background' })
import {getBlobFromBlurHash} from '../../../helpers/getBlobFromBlurHash' </script>
import BackgroundUnsplashService from '../../../services/backgroundUnsplash' <script setup lang="ts">
import BackgroundUploadService from '../../../services/backgroundUpload' import {ref, computed, shallowReactive} from 'vue'
import ListService from '@/services/list' import {useI18n} from 'vue-i18n'
import {CURRENT_LIST} from '@/store/mutation-types' import {useStore} from 'vuex'
import CreateEdit from '@/components/misc/create-edit.vue' import {useRoute, useRouter} from 'vue-router'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import BaseButton from '@/components/base/BaseButton.vue' import BaseButton from '@/components/base/BaseButton.vue'
import BackgroundUnsplashService from '@/services/backgroundUnsplash'
import BackgroundUploadService from '@/services/backgroundUpload'
import ListService from '@/services/list'
import BackgroundImageModel from '@/models/backgroundImage'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {useTitle} from '@/composables/useTitle'
import {CURRENT_LIST} from '@/store/mutation-types'
import CreateEdit from '@/components/misc/create-edit.vue'
import { success } from '@/message'
const SEARCH_DEBOUNCE = 300 const SEARCH_DEBOUNCE = 300
export default defineComponent({ const {t} = useI18n()
name: 'list-setting-background', const store = useStore()
components: {CreateEdit, BaseButton}, const route = useRoute()
data() { const router = useRouter()
return {
backgroundService: new BackgroundUnsplashService(),
backgroundSearchTerm: '',
backgroundSearchResult: [],
backgroundThumbs: {},
backgroundBlurHashes: {},
currentPage: 1,
// We're using debounce to not search on every keypress but with a delay. useTitle(() => t('list.background.title'))
debounceNewBackgroundSearch: debounce(this.newBackgroundSearch, SEARCH_DEBOUNCE, {
trailing: true,
}),
backgroundUploadService: new BackgroundUploadService(), const backgroundService = shallowReactive(new BackgroundUnsplashService())
listService: new ListService(), const backgroundSearchTerm = ref('')
} const backgroundSearchResult = ref([])
}, const backgroundThumbs = ref<Record<string, string>>({})
computed: mapState({ const backgroundBlurHashes = ref<Record<string, string>>({})
unsplashBackgroundEnabled: state => state.config.enabledBackgroundProviders.includes('unsplash'), const currentPage = ref(1)
uploadBackgroundEnabled: state => state.config.enabledBackgroundProviders.includes('upload'),
currentList: state => state.currentList,
hasBackground: state => state.background !== null,
}),
created() {
this.setTitle(this.$t('list.background.title'))
// Show the default collection of backgrounds
this.newBackgroundSearch()
},
methods: {
newBackgroundSearch() {
if (!this.unsplashBackgroundEnabled) {
return
}
// This is an extra method to reset a few things when searching to not break loading more photos.
this.backgroundSearchResult = []
this.backgroundThumbs = {}
this.searchBackgrounds()
},
async searchBackgrounds(page = 1) { // We're using debounce to not search on every keypress but with a delay.
this.currentPage = page const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNCE, {
const result = await this.backgroundService.getAll({}, {s: this.backgroundSearchTerm, p: page}) trailing: true,
this.backgroundSearchResult = this.backgroundSearchResult.concat(result)
result.forEach(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
})
})
},
async setBackground(backgroundId) {
// Don't set a background if we're in the process of setting one
if (this.backgroundService.loading) {
return
}
const list = await this.backgroundService.update({id: backgroundId, listId: this.$route.params.listId})
await this.$store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
this.$store.commit('namespaces/setListInNamespaceById', list)
this.$store.commit('lists/setList', list)
this.$message.success({message: this.$t('list.background.success')})
},
async uploadBackground() {
if (this.$refs.backgroundUploadInput.files.length === 0) {
return
}
const list = await this.backgroundUploadService.create(this.$route.params.listId, this.$refs.backgroundUploadInput.files[0])
await this.$store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
this.$store.commit('namespaces/setListInNamespaceById', list)
this.$store.commit('lists/setList', list)
this.$message.success({message: this.$t('list.background.success')})
},
async removeBackground() {
const list = await this.listService.removeBackground(this.currentList)
await this.$store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
this.$store.commit('namespaces/setListInNamespaceById', list)
this.$store.commit('lists/setList', list)
this.$message.success({message: this.$t('list.background.removeSuccess')})
this.$router.back()
},
},
}) })
const backgroundUploadService = ref(new BackgroundUploadService())
const listService = ref(new ListService())
const unsplashBackgroundEnabled = computed(() => store.state.config.enabledBackgroundProviders.includes('unsplash'))
const uploadBackgroundEnabled = computed(() => store.state.config.enabledBackgroundProviders.includes('upload'))
const currentList = computed(() => store.state.currentList)
const hasBackground = computed(() => store.state.background !== null)
// Show the default collection of backgrounds
newBackgroundSearch()
function newBackgroundSearch() {
if (!unsplashBackgroundEnabled.value) {
return
}
// This is an extra method to reset a few things when searching to not break loading more photos.
backgroundSearchResult.value = []
backgroundThumbs.value = {}
searchBackgrounds()
}
async function searchBackgrounds(page = 1) {
currentPage.value = page
const result = await backgroundService.getAll({}, {s: backgroundSearchTerm.value, p: page})
backgroundSearchResult.value = backgroundSearchResult.value.concat(result)
result.forEach((background: BackgroundImageModel) => {
getBlobFromBlurHash(background.blurHash)
.then((b) => {
backgroundBlurHashes.value[background.id] = window.URL.createObjectURL(b)
})
backgroundService.thumb(background).then(b => {
backgroundThumbs.value[background.id] = b
})
})
}
async function setBackground(backgroundId: string) {
// Don't set a background if we're in the process of setting one
if (backgroundService.loading) {
return
}
const list = await backgroundService.update({id: backgroundId, listId: route.params.listId})
await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
store.commit('namespaces/setListInNamespaceById', list)
store.commit('lists/setList', list)
success({message: t('list.background.success')})
}
const backgroundUploadInput = ref<HTMLInputElement | null>(null)
async function uploadBackground() {
if (backgroundUploadInput.value?.files?.length === 0) {
return
}
const list = await backgroundUploadService.value.create(route.params.listId, backgroundUploadInput.value?.files[0])
await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
store.commit('namespaces/setListInNamespaceById', list)
store.commit('lists/setList', list)
success({message: t('list.background.success')})
}
async function removeBackground() {
const list = await listService.value.removeBackground(currentList.value)
await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
store.commit('namespaces/setListInNamespaceById', list)
store.commit('lists/setList', list)
success({message: t('list.background.removeSuccess')})
router.back()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.list-background-setting { .unsplash-credit {
text-align: right;
font-size: .8rem;
}
.unsplash-link { .unsplash-credit__link {
text-align: right; color: var(--grey-800);
font-size: .8rem; }
a { .image-search__result-list {
color: var(--grey-800); --items-per-row: 1;
} margin: 1rem 0 0;
display: grid;
gap: 1rem;
grid-template-columns: repeat(var(--items-per-row), 1fr);
@media screen and (min-width: $mobile) {
--items-per-row: 2;
} }
@media screen and (min-width: $tablet) {
.image-search-result { --items-per-row: 4;
margin-top: 1rem;
display: flex;
flex-flow: row wrap;
.image {
width: calc(100% / 5 - 1rem);
height: 120px;
margin: .5rem;
background-size: cover;
background-position: center;
display: flex;
position: relative;
@media screen and (min-width: $desktop) {
&:nth-child(5n) {
break-after: always;
}
}
@media screen and (max-width: $desktop) {
width: calc(100% / 4 - 1rem);
&:nth-child(4n) {
break-after: always;
}
}
@media screen and (max-width: $tablet) {
width: calc(100% / 2 - 1rem);
&:nth-child(2n) {
break-after: always;
}
}
@media screen and (max-width: ($mobile)) {
width: calc(100% - 1rem);
&:nth-child(1n) {
break-after: always;
}
}
.info {
align-self: flex-end;
display: block;
opacity: 0;
width: 100%;
padding: .25rem 0;
text-align: center;
background: rgba(0, 0, 0, 0.5);
font-size: .75rem;
font-weight: bold;
color: var(--white);
transition: opacity $transition;
position: absolute;
}
img {
object-fit: cover;
}
&:hover .info {
opacity: 1;
}
}
} }
@media screen and (min-width: $tablet) {
.is-load-more-button { --items-per-row: 5;
margin: 1rem auto 0 !important;
display: block;
width: 200px;
} }
} }
.image-search__result-item {
margin-top: 0; // FIXME: removes padding from .content
aspect-ratio: 16 / 10;
background-size: cover;
background-position: center;
display: flex;
position: relative;
}
.image-search__image-button {
width: 100%;
}
.image-search__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-search__info {
position: absolute;
bottom: 0;
width: 100%;
padding: .25rem 0;
opacity: 0;
text-align: center;
background: rgba(0, 0, 0, 0.5);
font-size: .75rem;
font-weight: bold;
color: var(--white);
transition: opacity $transition;
}
.image-search__result-item:hover .image-search__info {
opacity: 1;
}
.is-load-more-button {
margin: 1rem auto 0 !important;
display: block;
width: 200px;
}
</style> </style>