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,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') }}
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(),
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>