feat: grid for list cards #2709

Merged
dpschen merged 1 commits from dpschen/frontend:feature/list-card-grid into main 2022-11-18 13:15:01 +00:00
9 changed files with 340 additions and 285 deletions

View File

@ -45,7 +45,7 @@ describe('List History', () => {
cy.get('body')
.should('contain', 'Last viewed')
cy.get('.list-cards-wrapper-2-rows')
cy.get('[data-cy="listCardGrid"]')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)

View File

@ -0,0 +1,176 @@
<template>
<div
class="list-card"
:class="{
'has-light-text': background !== null,
'has-background': blurHashUrl !== '' || background !== null
}"
:style="{
'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined,
konrad marked this conversation as resolved Outdated

Can you only declare the color here and move the size and style in a stylesheet?

Can you only declare the color here and move the size and style in a stylesheet?

yes! makes sense

yes! makes sense

I thought about this again and realized that the reason I put all the border inline was that the border itself is meant to be optional.

In case we have no list.color we don't want a border, because if we would just make the border transparent in that case this offsets the padding but more important creates some distance for backgrounds.
I could have introduced a new class .has-border then set the width and style based on that class and additionally add the color via inline style or even set it as css variable. But that seemed more complex than the one line solution that I used now :)

I thought about this again and realized that the reason I put all the border inline was that the border itself is meant to be optional. In case we have no `list.color` we don't want a border, because if we would just make the border `transparent` in that case this offsets the padding but more important creates some distance for backgrounds. I could have introduced a new class `.has-border` then set the width and style based on that class and additionally add the color via inline style or even set it as css variable. But that seemed more complex than the one line solution that I used now :)

I could have introduced a new class .has-border then set the width and style based on that class and additionally add the color via inline style or even set it as css variable. But that seemed more complex than the one line solution that I used now :)

That sounds reasonable.

> I could have introduced a new class .has-border then set the width and style based on that class and additionally add the color via inline style or even set it as css variable. But that seemed more complex than the one line solution that I used now :) That sounds reasonable.
'background-image': blurHashUrl !== '' ? `url(${blurHashUrl})` : undefined,
}"
>
<div
class="list-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<span v-if="list.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
<div class="list-title" aria-hidden="true">{{ list.title }}</div>
<BaseButton
class="list-button"
:aria-label="list.title"
:title="list.description"
:to="{
name: 'list.index',
params: { listId: list.id}
}"
/>
<BaseButton
v-if="!list.isArchived"
class="favorite"
:class="{'is-favorite': list.isFavorite}"
@click.prevent.stop="listStore.toggleListFavorite(list)"
>
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
</BaseButton>
</div>
</template>
<script lang="ts" setup>
import {toRef, type PropType} from 'vue'
import type {IList} from '@/modelTypes/IList'
import BaseButton from '@/components/base/BaseButton.vue'
import {useListBackground} from './useListBackground'
import {useListStore} from '@/stores/lists'
const props = defineProps({
list: {
type: Object as PropType<IList>,
required: true,
},
})
const {background, blurHashUrl} = useListBackground(toRef(props, 'list'))
const listStore = useListStore()
</script>
<style lang="scss" scoped>
.list-card {
--list-card-padding: 1rem;
konrad marked this conversation as resolved Outdated

It looks like this variable is only used here. (I assume you wanted to use it as well in the .favorite declaration?

It looks like this variable is only used here. (I assume you wanted to use it as well in the `.favorite` declaration?

I do??

top: var(--list-card-padding);
	right: var(--list-card-padding);
I do?? ``` top: var(--list-card-padding); right: var(--list-card-padding); ```

But the variable is only defined in the scope of .list-card and .favorites is not part of it. Or is it?

But the variable is only defined in the scope of `.list-card` and `.favorites` is not part of it. Or is it?

list-card is the root class of the component (same name as component, only kebab case). That's a pattern I really love to use. CSS properties are super might this way. Especially when you set them via js :)

`list-card` is the root class of the component (same name as component, only kebab case). That's a pattern I really love to use. CSS properties are super might this way. Especially when you set them via js :)

I did reuse the variable in the max-height of .list-title as well.

I did reuse the variable in the `max-height` of `.list-title` as well.

Ohhh that makes a lot of sense. Very nice!

Ohhh that makes a lot of sense. Very nice!
background: var(--white);
padding: var(--list-card-padding);
border-radius: $radius;
box-shadow: var(--shadow-sm);
transition: box-shadow $transition;
position: relative;
overflow: hidden; // hide background
display: flex;
justify-content: space-between;
flex-wrap: wrap;
&:hover {
box-shadow: var(--shadow-md);
}
&:active,
&:focus {
box-shadow: var(--shadow-xs) !important;
}
> * {
// so the elements are on top of the background
position: relative;
}
}
.has-background,
.list-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
.list-background,
.list-button {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.is-archived {
font-size: .75rem;
float: left;
}
.list-title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
line-height: var(--title-line-height);
color: var(--text);
width: 100%;
margin-bottom: 0;
max-height: calc(100% - (var(--list-card-padding) + 1rem)); // padding & height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.has-light-text .list-title {
color: var(--grey-100);
}
.has-background .list-title {
text-shadow:
0 0 10px var(--black),
1px 1px 5px var(--grey-700),
-1px -1px 5px var(--grey-700);
color: var(--white);
}
.favorite {
position: absolute;
top: var(--list-card-padding);
right: var(--list-card-padding);
transition: opacity $transition, color $transition;
opacity: 0;
&:hover {
color: var(--warning);
}
&.is-favorite {
display: inline-block;
opacity: 1;
color: var(--warning);
}
}
.list-card:hover .favorite {
opacity: 1;
}
.background-fade-in {
opacity: 0;
transition: opacity $transition;
transition-delay: $transition-duration * 2; // To fake an appearing background
&.is-visible {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<ul class="list-grid">
<li
v-for="(item, index) in filteredLists"
:key="`list_${item.id}_${index}`"
class="list-grid-item"
>
<ListCard :list="item" />
</li>
</ul>
</template>
<script lang="ts" setup>
import {computed, type PropType} from 'vue'
import type {IList} from '@/modelTypes/IList'
import ListCard from './ListCard.vue'
const props = defineProps({
lists: {
type: Array as PropType<IList[]>,
default: () => [],
},
showArchived: {
default: false,
type: Boolean,
},
itemLimit: {
type: Boolean,
default: false,
},
})
const filteredLists = computed(() => {
return props.showArchived
? props.lists
: props.lists.filter(l => !l.isArchived)
})
</script>
<style lang="scss" scoped>
$list-height: 150px;
$list-spacing: 1rem;
.list-grid {
margin: 0; // reset li
list-style-type: none;
display: grid;
grid-template-columns: repeat(var(--list-columns), 1fr);
grid-auto-rows: $list-height;
gap: $list-spacing;
@media screen and (min-width: $mobile) {
--list-rows: 4;
--list-columns: 1;
}
@media screen and (min-width: $mobile) and (max-width: $tablet) {
--list-columns: 2;
}
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
--list-columns: 3;
--list-rows: 3;
}
@media screen and (min-width: $widescreen) {
--list-columns: 5;
--list-rows: 2;
}
}
.list-grid-item {
display: grid;
margin-top: 0; // remove padding coming form .content li + li
}
</style>

View File

@ -1,226 +0,0 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor) || background !== null,
'has-background': blurHashUrl !== '' || background !== null,
}"
:style="{
'border-color': `${list.hexColor}`,
'background-image': blurHashUrl !== null ? `url(${blurHashUrl})` : false,
}"
:to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card"
v-if="list !== null && (showArchived ? true : !list.isArchived)"
>
<div
class="list-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<div class="list-content">
<span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }}
</span>
<BaseButton
v-else
:class="{'is-favorite': list.isFavorite}"
@click.stop="listStore.toggleListFavorite(list)"
class="favorite"
>
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<div class="title">{{ list.title }}</div>
</div>
</router-link>
</template>
<script lang="ts" setup>
import {type PropType, ref, watch} from 'vue'
import ListService from '@/services/list'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import BaseButton from '@/components/base/BaseButton.vue'
import type {IList} from '@/modelTypes/IList'
import {useListStore} from '@/stores/lists'
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
const blurHashUrl = ref('')
const props = defineProps({
list: {
type: Object as PropType<IList>,
required: true,
},
showArchived: {
default: false,
type: Boolean,
},
})
watch(props.list, loadBackground, {immediate: true})
async function loadBackground() {
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
return
}
const blurHash = await getBlobFromBlurHash(props.list.backgroundBlurHash)
if (blurHash) {
blurHashUrl.value = window.URL.createObjectURL(blurHash)
}
backgroundLoading.value = true
const listService = new ListService()
try {
background.value = await listService.background(props.list)
} finally {
backgroundLoading.value = false
}
}
const listStore = useListStore()
</script>
<style lang="scss" scoped>
.list-card {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height;
border-left-width: 0.8rem;
border-left-style: solid;
background: var(--white);
margin: 0 $list-spacing $list-spacing 0;
border-radius: $radius;
box-shadow: var(--shadow-sm);
transition: box-shadow $transition;
position: relative;
overflow: hidden;
&.has-light-text .title {
color: var(--grey-100) !important;
}
&.has-background,
.list-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
&.has-background .title {
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
color: var(--white);
}
.list-background {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
&:hover {
box-shadow: var(--shadow-md);
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
@media screen and (min-width: $widescreen) {
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
$lists-per-row: 3;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $tablet) {
$lists-per-row: 2;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $mobile) {
$lists-per-row: 1;
& {
width: 100%;
margin-right: 0;
}
}
.list-content {
display: flex;
align-content: flex-start;
flex-wrap: wrap;
row-gap: 0.8rem;
padding: 1rem;
position: absolute;
height: 100%;
width: 100%;
.is-archived {
font-size: .75rem;
}
.favorite {
margin-left: auto;
transition: opacity $transition, color $transition;
opacity: 0;
display: block;
&:hover,
&.is-favorite {
color: var(--warning);
}
}
.favorite.is-favorite,
&:hover .favorite {
opacity: 1;
}
.title {
align-self: flex-start;
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;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
}
}
</style>

View File

@ -0,0 +1,55 @@
import {ref, watch, type Ref} from 'vue'
import ListService from '@/services/list'
import type {IList} from '@/modelTypes/IList'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
export function useListBackground(list: Ref<IList>) {
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
const blurHashUrl = ref('')
watch(
() => [list.value.id, list.value.backgroundBlurHash] as [IList['id'], IList['backgroundBlurHash']],
async ([listId, blurHash], oldValue) => {
if (
list.value === null ||
!list.value.backgroundInformation ||
backgroundLoading.value
) {
return
}
const [oldListId, oldBlurHash] = oldValue || []
if (
oldValue !== undefined &&
listId === oldListId && blurHash === oldBlurHash
) {
// list hasn't changed
return
}
backgroundLoading.value = true
try {
const blurHashPromise = getBlobFromBlurHash(blurHash).then((blurHash) => {
blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : ''
})
const listService = new ListService()
const backgroundPromise = listService.background(list.value).then((result) => {
background.value = result
})
await Promise.all([blurHashPromise, backgroundPromise])
konrad marked this conversation as resolved Outdated

Don't the two promises need a return to actually resolve the promise? Or is that implicit?

Don't the two promises need a `return` to actually resolve the promise? Or is that implicit?

Not sure if I got what you mean here, but trying to explan:

The handler of a watcher doesn't care if it has a return value or what it is.
By prefixing the handler with async the return value of the handler will automatically be a promise. If we don't return anything it will be a promise that resolves to undefined, which is fine for us, since as written above, the watch function doesnt care what value is returned by it's handler.

By not using await in front of getBlobFromBlurhash we get the promise as result, not the resolved value. Same true for for the background.

By using promise.all both promises do resolve now in parallel.
The await in front of the promise.all makes sure that the promise resolves before we set backgroundLoading to false again.

Not sure if I got what you mean here, but trying to explan: The handler of a watcher doesn't care if it has a return value or what it is. By prefixing the handler with `async` the return value of the handler will automatically be a promise. If we don't return anything it will be a promise that resolves to undefined, which is fine for us, since as written above, the watch function doesnt care what value is returned by it's handler. By not using await in front of getBlobFromBlurhash we get the promise as result, not the resolved value. Same true for for the background. By using promise.all both promises do resolve now in parallel. The await in front of the promise.all makes sure that the promise resolves before we set backgroundLoading to false again.

Makes sense (I've learnt something) but I thought of the .then. Does that simply resolve to undefined if no return value was specified? (but it does resolve without a return)?

Makes sense (I've learnt something) but I thought of the `.then`. Does that simply resolve to `undefined` if no return value was specified? (but it does resolve without a return)?

I think that

function foo {
  return Promise.resolve()
}

and

async function foo {}

is the same

I think that ```ts function foo { return Promise.resolve() } ``` and ```ts async function foo {} ``` is the same
} finally {
backgroundLoading.value = false
}
},
{ immediate: true },
)
return {
background,
blurHashUrl,
backgroundLoading,
}
}

View File

@ -39,8 +39,8 @@ export default class ListService extends AbstractService<IList> {
return list
}
async background(list) {
if (list.background === null) {
async background(list: Pick<IList, 'id' | 'backgroundInformation'>) {
if (list.backgroundInformation === null) {
return ''
}
@ -52,7 +52,7 @@ export default class ListService extends AbstractService<IList> {
return window.URL.createObjectURL(new Blob([response.data]))
}
async removeBackground(list) {
async removeBackground(list: Pick<IList, 'id'>) {
const cancel = this.setLoading()
try {

View File

@ -32,8 +32,4 @@ $switch-view-height: 2.69rem;
$navbar-height: 4rem;
$navbar-width: 300px;
$navbar-icon-width: 40px;
$lists-per-row: 5;
$list-height: 150px;
$list-spacing: 1rem;
$navbar-icon-width: 40px;

View File

@ -40,17 +40,11 @@
</template>
<div v-if="listHistory.length > 0" class="is-max-width-desktop has-text-left mt-4">
<h3>{{ $t('home.lastViewed') }}</h3>
<div class="is-flex list-cards-wrapper-2-rows">
<list-card
v-for="(l, k) in listHistory"
:key="`l${k}`"
:list="l"
/>
</div>
<ListCardGrid :lists="listHistory" v-cy="'listCardGrid'" />
</div>
<ShowTasks
v-if="hasLists"
class="mt-4"
class="show-tasks"
:key="showTasksKey"
/>
</div>
@ -61,7 +55,7 @@ import {ref, computed} from 'vue'
import Message from '@/components/misc/message.vue'
import ShowTasks from '@/views/tasks/ShowTasks.vue'
import ListCard from '@/components/list/partials/list-card.vue'
import ListCardGrid from '@/components/list/partials/ListCardGrid.vue'
import AddTask from '@/components/tasks/add-task.vue'
import {getHistory} from '@/modules/listHistory'
@ -113,14 +107,8 @@ function updateTaskList() {
}
</script>
<style lang="scss" scoped>
.list-cards-wrapper-2-rows {
flex-wrap: wrap;
max-height: calc(#{$list-height * 2} + #{$list-spacing * 2} - 4px);
overflow: hidden;
@media screen and (max-width: $mobile) {
max-height: calc(#{$list-height * 4} + #{$list-spacing * 4} - 4px);
}
<style scoped lang="scss">
.show-tasks {
margin-top: 2rem;
}
</style>

View File

@ -15,28 +15,28 @@
</div>
</header>
<p class="has-text-centered has-text-grey mt-4 is-italic" v-if="namespaces.length === 0">
<p v-if="namespaces.length === 0" class="has-text-centered has-text-grey mt-4 is-italic">
{{ $t('namespace.noneAvailable') }}
<router-link :to="{name: 'namespace.create'}">
<BaseButton :to="{name: 'namespace.create'}">
{{ $t('namespace.create.title') }}.
</router-link>
</BaseButton>
</p>
<section :key="`n${n.id}`" class="namespace" v-for="n in namespaces">
<x-button
v-if="n.id > 0 && n.lists.length > 0"
:to="{name: 'list.create', params: {namespaceId: n.id}}"
class="is-pulled-right"
variant="secondary"
v-if="n.id > 0 && n.lists.length > 0"
icon="plus"
>
{{ $t('list.create.header') }}
</x-button>
<x-button
v-if="n.isArchived"
:to="{name: 'namespace.settings.archive', params: {id: n.id}}"
class="is-pulled-right mr-4"
variant="secondary"
v-if="n.isArchived"
icon="archive"
>
{{ $t('namespace.unarchive') }}
@ -44,26 +44,22 @@
<h2 class="namespace-title">
<span v-cy="'namespace-title'">{{ getNamespaceTitle(n) }}</span>
<span class="is-archived" v-if="n.isArchived">
<span v-if="n.isArchived" class="is-archived">
{{ $t('namespace.archived') }}
</span>
</h2>
<p class="has-text-centered has-text-grey mt-4 is-italic" v-if="n.lists.length === 0">
<p v-if="n.lists.length === 0" class="has-text-centered has-text-grey mt-4 is-italic">
{{ $t('namespace.noLists') }}
<router-link :to="{name: 'list.create', params: {namespaceId: n.id}}">
<BaseButton :to="{name: 'list.create', params: {namespaceId: n.id}}">
{{ $t('namespace.createList') }}
</router-link>
</BaseButton>
</p>
<div class="lists">
<list-card
v-for="l in n.lists"
:key="`l${l.id}`"
:list="l"
:show-archived="showArchived"
/>
</div>
<ListCardGrid v-else
:lists="n.lists"
:show-archived="showArchived"
/>
</section>
</div>
</template>
@ -72,8 +68,9 @@
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import ListCard from '@/components/list/partials/list-card.vue'
import ListCardGrid from '@/components/list/partials/ListCardGrid.vue'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {useTitle} from '@/composables/useTitle'
@ -89,11 +86,10 @@ const showArchived = useStorage('showArchived', false)
const loading = computed(() => namespaceStore.isLoading)
const namespaces = computed(() => {
return namespaceStore.namespaces.filter(n => showArchived.value ? true : !n.isArchived)
// return namespaceStore.namespaces.filter(n => showArchived.value ? true : !n.isArchived).map(n => {
// n.lists = n.lists.filter(l => !l.isArchived)
// return n
// })
return namespaceStore.namespaces.filter(namespace => showArchived.value
? true
: !namespace.isArchived,
)
})
</script>
@ -121,10 +117,8 @@ const namespaces = computed(() => {
}
}
.namespace {
& + & {
margin-top: 1rem;
}
.namespace:not(:first-child) {
margin-top: 1rem;
}
.namespace-title {
@ -142,9 +136,4 @@ const namespaces = computed(() => {
background: var(--white-translucent);
margin-left: .5rem;
}
.lists {
display: flex;
flex-flow: row wrap;
}
</style>