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()
cy.get('#title')
.type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .button')
cy.get('footer.card-footer .button')
.contains('Save')
.click()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -47,6 +47,8 @@
/>
</div>
</div>
<template #footer>
<x-button
:loading="savedFilterService.loading"
:disabled="savedFilterService.loading"
@ -55,6 +57,7 @@
>
{{ $t('filters.create.action') }}
</x-button>
</template>
</card>
</modal>
</template>

View file

@ -1,13 +1,10 @@
<template>
<create-edit
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
:title="$t('list.background.title')"
primary-label=""
:loading="backgroundService.loading"
class="list-background-setting"
:wide="true"
v-if="uploadBackgroundEnabled || unsplashBackgroundEnabled"
:tertiary="hasBackground ? $t('list.background.remove') : ''"
@tertiary="removeBackground()"
>
<div class="mb-4" v-if="uploadBackgroundEnabled">
<input
@ -19,7 +16,7 @@
/>
<x-button
:loading="backgroundUploadService.loading"
@click="$refs.backgroundUploadInput.click()"
@click="backgroundUploadInput?.click()"
variant="primary"
>
{{ $t('list.background.upload') }}
@ -28,245 +25,260 @@
<template v-if="unsplashBackgroundEnabled">
<input
:class="{'is-loading': backgroundService.loading}"
@keyup="() => debounceNewBackgroundSearch()"
@keyup="debounceNewBackgroundSearch()"
class="input is-expanded"
:placeholder="$t('list.background.searchPlaceholder')"
type="text"
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>
<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"
:style="{'background-image': `url(${backgroundBlurHashes[im.id]})`}"
@click="() => setBackground(im.id)"
class="image"
v-for="im in backgroundSearchResult">
>
<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>
<a
<BaseButton
:href="`https://unsplash.com/@${im.info.author}`"
rel="noreferrer noopener nofollow"
target="_blank"
class="info">
class="image-search__info"
>
{{ im.info.authorName }}
</a>
</a>
</div>
</BaseButton>
</li>
</ul>
<x-button
v-if="backgroundSearchResult.length > 0"
:disabled="backgroundService.loading"
@click="() => searchBackgrounds(currentPage + 1)"
@click="searchBackgrounds(currentPage + 1)"
class="is-load-more-button mt-4"
:shadow="false"
variant="secondary"
v-if="backgroundSearchResult.length > 0"
>
{{ backgroundService.loading ? $t('misc.loading') : $t('list.background.loadMore') }}
</x-button>
</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>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
import {mapState} from 'vuex'
import {getBlobFromBlurHash} from '../../../helpers/getBlobFromBlurHash'
export default defineComponent({ name: 'list-setting-background' })
</script>
import BackgroundUnsplashService from '../../../services/backgroundUnsplash'
import BackgroundUploadService from '../../../services/backgroundUpload'
import ListService from '@/services/list'
import {CURRENT_LIST} from '@/store/mutation-types'
import CreateEdit from '@/components/misc/create-edit.vue'
<script setup lang="ts">
import {ref, computed, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router'
import debounce from 'lodash.debounce'
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
export default defineComponent({
name: 'list-setting-background',
components: {CreateEdit, BaseButton},
data() {
return {
backgroundService: new BackgroundUnsplashService(),
backgroundSearchTerm: '',
backgroundSearchResult: [],
backgroundThumbs: {},
backgroundBlurHashes: {},
currentPage: 1,
const {t} = useI18n()
const store = useStore()
const route = useRoute()
const router = useRouter()
useTitle(() => t('list.background.title'))
const backgroundService = shallowReactive(new BackgroundUnsplashService())
const backgroundSearchTerm = ref('')
const backgroundSearchResult = ref([])
const backgroundThumbs = ref<Record<string, string>>({})
const backgroundBlurHashes = ref<Record<string, string>>({})
const currentPage = ref(1)
// We're using debounce to not search on every keypress but with a delay.
debounceNewBackgroundSearch: debounce(this.newBackgroundSearch, SEARCH_DEBOUNCE, {
const debounceNewBackgroundSearch = debounce(newBackgroundSearch, SEARCH_DEBOUNCE, {
trailing: true,
}),
})
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)
backgroundUploadService: new BackgroundUploadService(),
listService: new ListService(),
}
},
computed: mapState({
unsplashBackgroundEnabled: state => state.config.enabledBackgroundProviders.includes('unsplash'),
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) {
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.
this.backgroundSearchResult = []
this.backgroundThumbs = {}
this.searchBackgrounds()
},
backgroundSearchResult.value = []
backgroundThumbs.value = {}
searchBackgrounds()
}
async searchBackgrounds(page = 1) {
this.currentPage = page
const result = await this.backgroundService.getAll({}, {s: this.backgroundSearchTerm, p: page})
this.backgroundSearchResult = this.backgroundSearchResult.concat(result)
result.forEach(background => {
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 => {
this.backgroundBlurHashes[background.id] = window.URL.createObjectURL(b)
.then((b) => {
backgroundBlurHashes.value[background.id] = window.URL.createObjectURL(b)
})
this.backgroundService.thumb(background)
.then(b => {
this.backgroundThumbs[background.id] = b
backgroundService.thumb(background).then(b => {
backgroundThumbs.value[background.id] = b
})
})
},
}
async setBackground(backgroundId) {
async function setBackground(backgroundId: string) {
// Don't set a background if we're in the process of setting one
if (this.backgroundService.loading) {
if (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')})
},
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')})
}
async uploadBackground() {
if (this.$refs.backgroundUploadInput.files.length === 0) {
const backgroundUploadInput = ref<HTMLInputElement | null>(null)
async function uploadBackground() {
if (backgroundUploadInput.value?.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')})
},
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 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()
},
},
})
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>
<style lang="scss" scoped>
.list-background-setting {
.unsplash-link {
.unsplash-credit {
text-align: right;
font-size: .8rem;
}
a {
.unsplash-credit__link {
color: var(--grey-800);
}
.image-search__result-list {
--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) {
--items-per-row: 4;
}
@media screen and (min-width: $tablet) {
--items-per-row: 5;
}
}
.image-search-result {
margin-top: 1rem;
display: flex;
flex-flow: row wrap;
.image {
width: calc(100% / 5 - 1rem);
height: 120px;
margin: .5rem;
.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;
@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;
}
.image-search__image-button {
width: 100%;
}
@media screen and (max-width: $tablet) {
width: calc(100% / 2 - 1rem);
&:nth-child(2n) {
break-after: always;
}
.image-search__image {
width: 100%;
height: 100%;
object-fit: cover;
}
@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;
.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;
position: absolute;
}
img {
object-fit: cover;
}
&:hover .info {
.image-search__result-item:hover .image-search__info {
opacity: 1;
}
}
}
.is-load-more-button {
margin: 1rem auto 0 !important;
display: block;
width: 200px;
}
}
</style>