Favorite lists (#237)

Remove/show favorites namespace if a task/list is the first to being marked as favorite

Add special case to prevent marking an archived list as favorite

Add marking a task as favorite  on namespaces page

Prevent toggling the favorite state for the favorites list

Add method to toggle list favorite in the menu

Add favorite icon to lists in menu

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/237
This commit is contained in:
konrad 2020-09-06 14:20:48 +00:00
parent 5a0ef73b54
commit f2fcf42639
7 changed files with 162 additions and 29 deletions

View file

@ -207,16 +207,26 @@
are nested inside of the namespaces makes it a lot harder.--> are nested inside of the namespaces makes it a lot harder.-->
<li :key="l.id" v-if="!l.isArchived"> <li :key="l.id" v-if="!l.isArchived">
<router-link <router-link
class="list-menu-link"
:class="{'router-link-exact-active': currentList.id === l.id}" :class="{'router-link-exact-active': currentList.id === l.id}"
:to="{ name: 'list.index', params: { listId: l.id} }"> :to="{ name: 'list.index', params: { listId: l.id} }"
<span class="name"> tag="span"
>
<span <span
:style="{ backgroundColor: l.hexColor }" :style="{ backgroundColor: l.hexColor }"
class="color-bubble" class="color-bubble"
v-if="l.hexColor !== ''"> v-if="l.hexColor !== ''">
</span> </span>
<span class="list-menu-title">
{{ l.title }} {{ l.title }}
</span> </span>
<span
:class="{'is-favorite': l.isFavorite}"
@click.stop="toggleFavoriteList(l)"
class="favorite">
<icon icon="star" v-if="l.isFavorite"/>
<icon :icon="['far', 'star']" v-else/>
</span>
</router-link> </router-link>
</li> </li>
</template> </template>
@ -508,6 +518,15 @@ export default {
// Notify the service worker to actually do the update // Notify the service worker to actually do the update
this.registration.waiting.postMessage('skipWaiting') this.registration.waiting.postMessage('skipWaiting')
}, },
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
.catch(e => this.error(e, this))
},
}, },
} }
</script> </script>

View file

@ -35,6 +35,7 @@ export default class ListModel extends AbstractModel {
hexColor: '', hexColor: '',
identifier: '', identifier: '',
backgroundInformation: null, backgroundInformation: null,
isFavorite: false,
created: null, created: null,
updated: null, updated: null,

View file

@ -1,4 +1,7 @@
import Vue from 'vue' import Vue from 'vue'
import ListService from '@/services/list'
const FavoriteListsNamespace = -2
export default { export default {
namespaced: true, namespaced: true,
@ -22,4 +25,32 @@ export default {
return null return null
}, },
}, },
actions: {
toggleListFavorite(ctx, list) {
list.isFavorite = !list.isFavorite
const listService = new ListService()
return listService.update(list)
.then(r => {
if (r.isFavorite) {
ctx.commit('addList', r)
r.namespaceId = FavoriteListsNamespace
ctx.commit('namespaces/addListToNamespace', r, {root: true})
} else {
ctx.commit('namespaces/setListInNamespaceById', r, {root: true})
r.namespaceId = FavoriteListsNamespace
ctx.commit('namespaces/removeListFromNamespaceById', r, {root: true})
}
ctx.dispatch('namespaces/loadNamespacesIfFavoritesDontExist', null, {root: true})
ctx.dispatch('namespaces/removeFavoritesNamespaceIfEmpty', null, {root: true})
return Promise.resolve(r)
})
.catch(e => {
// Reset the list state to the initial one to avoid confusion for the user
list.isFavorite = !list.isFavorite
ctx.commit('addList', list)
return Promise.reject(e)
})
},
},
} }

View file

@ -24,6 +24,7 @@ export default {
for (const n in state.namespaces) { for (const n in state.namespaces) {
// We don't have the namespace id on the list which means we need to loop over all lists until we find it. // We don't have the namespace id on the list which means we need to loop over all lists until we find it.
// FIXME: Not ideal at all - we should fix that at the api level. // FIXME: Not ideal at all - we should fix that at the api level.
if (state.namespaces[n].id === list.namespaceId) {
for (const l in state.namespaces[n].lists) { for (const l in state.namespaces[n].lists) {
if (state.namespaces[n].lists[l].id === list.id) { if (state.namespaces[n].lists[l].id === list.id) {
const namespace = state.namespaces[n] const namespace = state.namespaces[n]
@ -33,6 +34,7 @@ export default {
} }
} }
} }
}
}, },
addNamespace(state, namespace) { addNamespace(state, namespace) {
state.namespaces.push(namespace) state.namespaces.push(namespace)
@ -45,6 +47,20 @@ export default {
} }
} }
}, },
removeListFromNamespaceById(state, list) {
for (const n in state.namespaces) {
// We don't have the namespace id on the list which means we need to loop over all lists until we find it.
// FIXME: Not ideal at all - we should fix that at the api level.
if (state.namespaces[n].id === list.namespaceId) {
for (const l in state.namespaces[n].lists) {
if (state.namespaces[n].lists[l].id === list.id) {
state.namespaces[n].lists.splice(l, 1)
return
}
}
}
}
},
}, },
getters: { getters: {
getListAndNamespaceById: state => listId => { getListAndNamespaceById: state => listId => {
@ -99,5 +115,11 @@ export default {
return ctx.dispatch('loadNamespaces') return ctx.dispatch('loadNamespaces')
} }
}, },
removeFavoritesNamespaceIfEmpty(ctx) {
if (ctx.state.namespaces[0].id === -2 && ctx.state.namespaces[0].lists.length === 0) {
ctx.state.namespaces.splice(0, 1)
return Promise.resolve()
}
},
}, },
} }

View file

@ -30,7 +30,6 @@ $lists-per-row: 5;
border: 1px solid $grey; border: 1px solid $grey;
color: $grey !important; color: $grey !important;
padding: 2px 4px; padding: 2px 4px;
margin-left: .5rem;
border-radius: 3px; border-radius: 3px;
font-family: $vikunja-font; font-family: $vikunja-font;
background: rgba($white, 0.75); background: rgba($white, 0.75);
@ -41,6 +40,7 @@ $lists-per-row: 5;
flex-flow: row wrap; flex-flow: row wrap;
.list { .list {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row}); width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: 150px; height: 150px;
background: $white; background: $white;
@ -100,6 +100,7 @@ $lists-per-row: 5;
.is-archived { .is-archived {
font-size: .75em; font-size: .75em;
float: left;
} }
} }
@ -127,6 +128,29 @@ $lists-per-row: 5;
color: $white; color: $white;
} }
} }
.favorite {
transition: opacity $transition, color $transition;
opacity: 0;
&:hover {
color: $orange;
}
&.is-archived {
display: none;
}
&.is-favorite {
display: inline-block;
opacity: 1;
color: $orange;
}
}
&:hover .favorite {
opacity: 1;
}
} }
} }
} }

View file

@ -164,25 +164,44 @@
overflow: hidden; overflow: hidden;
} }
.menu-label, .menu-list a { .menu-label, .menu-list span.list-menu-link, .menu-list a {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
cursor: pointer;
span.name:not(.icon) { .list-menu-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
width: 100%;
}
.color-bubble { .color-bubble {
display: inline-block; display: inline-block;
vertical-align: initial; width: 14px; // Without this, the bubble is only 10.2342357612px wide and seems squashed.
width: 12px;
height: 12px; height: 12px;
border-radius: 100%; border-radius: 100%;
margin-right: 2px; margin-right: 4px;
}
.favorite {
margin-left: .25rem;
transition: opacity $transition, color $transition;
opacity: 0;
&:hover {
color: $orange;
}
&.is-favorite {
opacity: 1;
color: $orange;
} }
} }
&:hover .favorite {
opacity: 1;
}
} }
.menu-label { .menu-label {
@ -201,7 +220,7 @@
padding: 10px 0.3em 0; padding: 10px 0.3em 0;
} }
.menu-label, .nsettings, .menu-list a { .menu-label, .nsettings, .menu-list span.list-menu-link, .menu-list a {
color: $vikunja-nav-color; color: $vikunja-nav-color;
} }
@ -243,7 +262,7 @@
height: 44px; height: 44px;
} }
a { span.list-menu-link, a {
padding: 0.75em .5em 0.75em $navbar-padding * 1.5; padding: 0.75em .5em 0.75em $navbar-padding * 1.5;
transition: all 0.2s ease; transition: all 0.2s ease;
@ -299,7 +318,7 @@
font-family: $vikunja-font; font-family: $vikunja-font;
} }
a { span.list-menu-link, a {
padding-left: 2em; padding-left: 2em;
display: inline-block; display: inline-block;
} }

View file

@ -33,12 +33,20 @@
}" }"
:to="{ name: 'list.index', params: { listId: l.id} }" :to="{ name: 'list.index', params: { listId: l.id} }"
class="list" class="list"
tag="span"
v-if="showArchived ? true : !l.isArchived" v-if="showArchived ? true : !l.isArchived"
> >
<div class="is-archived-container"> <div class="is-archived-container">
<span class="is-archived" v-if="l.isArchived"> <span class="is-archived" v-if="l.isArchived">
Archived Archived
</span> </span>
<span
:class="{'is-favorite': l.isFavorite, 'is-archived': l.isArchived}"
@click.stop="toggleFavoriteList(l)"
class="favorite">
<icon icon="star" v-if="l.isFavorite"/>
<icon :icon="['far', 'star']" v-else/>
</span>
</div> </div>
<div class="title">{{ l.title }}</div> <div class="title">{{ l.title }}</div>
</router-link> </router-link>
@ -93,6 +101,15 @@ export default {
}) })
}) })
}, },
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
.catch(e => this.error(e, this))
},
}, },
} }
</script> </script>