feat: settings background script setup #2104

Merged
konrad merged 1 commits from dpschen/frontend:feature/feat-settings-background-script-setup into main 2022-09-01 16:09:52 +00:00
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,15 +4,17 @@
: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">
<template #footer>
<slot name="footer">
<x-button <x-button
v-if="tertiary !== ''" v-if="tertiary !== ''"
:shadow="false" :shadow="false"
@ -31,11 +33,12 @@
variant="primary" variant="primary"
@click.prevent.stop="primary()" @click.prevent.stop="primary()"
:icon="primaryIcon" :icon="primaryIcon"
:disabled="primaryDisabled" :disabled="primaryDisabled || loading"
> >
{{ primaryLabel || $t('misc.create') }} {{ primaryLabel || $t('misc.create') }}
</x-button> </x-button>
</footer> </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,6 +47,8 @@
/> />
</div> </div>
</div> </div>
<template #footer>
<x-button <x-button
:loading="savedFilterService.loading" :loading="savedFilterService.loading"
:disabled="savedFilterService.loading" :disabled="savedFilterService.loading"
@ -55,6 +57,7 @@
> >
{{ $t('filters.create.action') }} {{ $t('filters.create.action') }}
</x-button> </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') }}
dpschen marked this conversation as resolved Outdated

Does this generate an error message when backgroundUploadInput is undefined or null? Or is vue smart enough to figure this out and prevent an error?

Does this generate an error message when `backgroundUploadInput` is undefined or null? Or is vue smart enough to figure this out and prevent an error?

I think this compiles to @click="null"

I think this compiles to `@click="null"`
@ -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(), useTitle(() => t('list.background.title'))
backgroundSearchTerm: '',
backgroundSearchResult: [], const backgroundService = shallowReactive(new BackgroundUnsplashService())
backgroundThumbs: {}, const backgroundSearchTerm = ref('')
backgroundBlurHashes: {}, const backgroundSearchResult = ref([])
currentPage: 1, 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. // 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, 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 // Show the default collection of backgrounds
this.newBackgroundSearch() newBackgroundSearch()
},
methods: { function newBackgroundSearch() {
newBackgroundSearch() { if (!unsplashBackgroundEnabled.value) {
if (!this.unsplashBackgroundEnabled) {
return return
} }
// This is an extra method to reset a few things when searching to not break loading more photos. // This is an extra method to reset a few things when searching to not break loading more photos.
this.backgroundSearchResult = [] backgroundSearchResult.value = []
this.backgroundThumbs = {} backgroundThumbs.value = {}
this.searchBackgrounds() searchBackgrounds()
}, }
async searchBackgrounds(page = 1) { async function searchBackgrounds(page = 1) {
this.currentPage = page currentPage.value = page
const result = await this.backgroundService.getAll({}, {s: this.backgroundSearchTerm, p: page}) const result = await backgroundService.getAll({}, {s: backgroundSearchTerm.value, p: page})
this.backgroundSearchResult = this.backgroundSearchResult.concat(result) backgroundSearchResult.value = backgroundSearchResult.value.concat(result)
result.forEach(background => { result.forEach((background: BackgroundImageModel) => {
getBlobFromBlurHash(background.blurHash) getBlobFromBlurHash(background.blurHash)
.then(b => { .then((b) => {
this.backgroundBlurHashes[background.id] = window.URL.createObjectURL(b) backgroundBlurHashes.value[background.id] = window.URL.createObjectURL(b)
}) })
this.backgroundService.thumb(background) backgroundService.thumb(background).then(b => {
.then(b => { backgroundThumbs.value[background.id] = b
this.backgroundThumbs[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 // Don't set a background if we're in the process of setting one
if (this.backgroundService.loading) { if (backgroundService.loading) {
return return
} }
const list = await this.backgroundService.update({id: backgroundId, listId: this.$route.params.listId}) const list = await backgroundService.update({id: backgroundId, listId: route.params.listId})
await this.$store.dispatch(CURRENT_LIST, {list, forceUpdate: true}) await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
this.$store.commit('namespaces/setListInNamespaceById', list) store.commit('namespaces/setListInNamespaceById', list)
this.$store.commit('lists/setList', list) store.commit('lists/setList', list)
this.$message.success({message: this.$t('list.background.success')}) success({message: t('list.background.success')})
}, }
async uploadBackground() { const backgroundUploadInput = ref<HTMLInputElement | null>(null)
if (this.$refs.backgroundUploadInput.files.length === 0) { async function uploadBackground() {
if (backgroundUploadInput.value?.files?.length === 0) {
return return
} }
const list = await this.backgroundUploadService.create(this.$route.params.listId, this.$refs.backgroundUploadInput.files[0]) const list = await backgroundUploadService.value.create(route.params.listId, backgroundUploadInput.value?.files[0])
await this.$store.dispatch(CURRENT_LIST, {list, forceUpdate: true}) await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
this.$store.commit('namespaces/setListInNamespaceById', list) store.commit('namespaces/setListInNamespaceById', list)
this.$store.commit('lists/setList', list) store.commit('lists/setList', list)
this.$message.success({message: this.$t('list.background.success')}) success({message: t('list.background.success')})
}, }
async removeBackground() { async function removeBackground() {
const list = await this.listService.removeBackground(this.currentList) const list = await listService.value.removeBackground(currentList.value)
await this.$store.dispatch(CURRENT_LIST, {list, forceUpdate: true}) await store.dispatch(CURRENT_LIST, {list, forceUpdate: true})
this.$store.commit('namespaces/setListInNamespaceById', list) store.commit('namespaces/setListInNamespaceById', list)
this.$store.commit('lists/setList', list) store.commit('lists/setList', list)
this.$message.success({message: this.$t('list.background.removeSuccess')}) success({message: t('list.background.removeSuccess')})
this.$router.back() router.back()
}, }
},
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.list-background-setting { .unsplash-credit {
.unsplash-link {
text-align: right; text-align: right;
font-size: .8rem; font-size: .8rem;
}
a { .unsplash-credit__link {
color: var(--grey-800); 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 { .image-search__result-item {
margin-top: 1rem; margin-top: 0; // FIXME: removes padding from .content
display: flex; aspect-ratio: 16 / 10;
flex-flow: row wrap;
.image {
width: calc(100% / 5 - 1rem);
height: 120px;
margin: .5rem;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
display: flex; display: flex;
position: relative; position: relative;
@media screen and (min-width: $desktop) {
&:nth-child(5n) {
break-after: always;
}
} }
@media screen and (max-width: $desktop) { .image-search__image-button {
width: calc(100% / 4 - 1rem); width: 100%;
&:nth-child(4n) {
break-after: always;
}
} }
@media screen and (max-width: $tablet) { .image-search__image {
width: calc(100% / 2 - 1rem); width: 100%;
height: 100%;
&:nth-child(2n) { object-fit: cover;
break-after: always;
}
} }
@media screen and (max-width: ($mobile)) { .image-search__info {
width: calc(100% - 1rem); position: absolute;
bottom: 0;
&:nth-child(1n) {
break-after: always;
}
}
.info {
align-self: flex-end;
display: block;
opacity: 0;
width: 100%; width: 100%;
padding: .25rem 0; padding: .25rem 0;
opacity: 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;
} }
.image-search__result-item:hover .image-search__info {
img {
object-fit: cover;
}
&:hover .info {
opacity: 1; opacity: 1;
} }
}
}
.is-load-more-button { .is-load-more-button {
margin: 1rem auto 0 !important; margin: 1rem auto 0 !important;
display: block; display: block;
width: 200px; width: 200px;
} }
}
</style> </style>