Show last visited list on home page

This commit is contained in:
kolaente 2021-07-06 22:22:57 +02:00
parent c7c9b5ee47
commit d09eff1655
No known key found for this signature in database
GPG key ID: F40E70337AB24C9B
12 changed files with 337 additions and 175 deletions

View file

@ -92,6 +92,7 @@
"jest": { "jest": {
"testPathIgnorePatterns": [ "testPathIgnorePatterns": [
"cypress" "cypress"
] ],
"testEnvironment": "jsdom"
} }
} }

View file

@ -0,0 +1,61 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor),
'has-background': backgroundResolver(list.id) !== null
}"
:style="{
'background-color': list.hexColor,
'background-image': backgroundResolver(list.id) !== null ? `url(${backgroundResolver(list.id)})` : false,
}"
:to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card"
tag="span"
v-if="showArchived ? true : !list.isArchived"
>
<div class="is-archived-container">
<span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }}
</span>
<span
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
@click.stop="toggleFavoriteList(list)"
class="favorite">
<icon icon="star" v-if="list.isFavorite"/>
<icon :icon="['far', 'star']" v-else/>
</span>
</div>
<div class="title">{{ list.title }}</div>
</router-link>
</template>
<script>
export default {
name: 'list-card',
props: {
list: {
required: true,
},
showArchived: {
default: false,
type: Boolean,
},
// A function, returning a background blob or null if none exists for that list.
// Receives the list id as parameter.
backgroundResolver: {
required: true,
},
},
methods: {
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))
},
},
}
</script>

View file

@ -1,4 +1,8 @@
export const colorIsDark = color => { export const colorIsDark = color => {
if (typeof color === 'undefined') {
return true // Defaults to dark
}
if (color === '#' || color === '') { if (color === '#' || color === '') {
return true // Defaults to dark return true // Defaults to dark
} }

View file

@ -4,6 +4,7 @@
"welcomeMorning": "Good Morning {username}", "welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}", "welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}", "welcomeEvening": "Good Evening {username}",
"lastViewed": "Last viewed",
"list": { "list": {
"newText": "You can create a new list for your new tasks:", "newText": "You can create a new list for your new tasks:",
"new": "Create a new list", "new": "Create a new list",

View file

@ -0,0 +1,26 @@
export const getHistory = () => {
const savedHistory = localStorage.getItem('listHistory')
if (savedHistory === null) {
return []
}
return JSON.parse(savedHistory)
}
export function saveListToHistory(list) {
const history = getHistory()
// Remove the element if it already exists in history, preventing duplicates and essentially moving it to the beginning
for (const i in history) {
if (history[i].id === list.id) {
history.splice(i, 1)
}
}
history.unshift(list)
if (history.length > 5) {
history.pop()
}
localStorage.setItem('listHistory', JSON.stringify(history))
}

View file

@ -0,0 +1,67 @@
import {getHistory, saveListToHistory} from './listHistory'
test('return an empty history when none was saved', () => {
Storage.prototype.getItem = jest.fn(() => null)
const h = getHistory()
expect(h).toStrictEqual([])
})
test('return a saved history', () => {
const saved = [{id: 1}, {id: 2}]
Storage.prototype.getItem = jest.fn(() => JSON.stringify(saved))
const h = getHistory()
expect(h).toStrictEqual(saved)
})
test('store list in history', () => {
let saved = {}
Storage.prototype.getItem = jest.fn(() => null)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1}]')
})
test('store only the last 5 lists in history', () => {
let saved = null
Storage.prototype.getItem = jest.fn(() => saved)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 2})
saveListToHistory({id: 3})
saveListToHistory({id: 4})
saveListToHistory({id: 5})
saveListToHistory({id: 6})
expect(saved).toBe('[{"id":6},{"id":5},{"id":4},{"id":3},{"id":2}]')
})
test('don\'t store the same list twice', () => {
let saved = null
Storage.prototype.getItem = jest.fn(() => saved)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1}]')
})
test('move a list to the beginning when storing it multiple times', () => {
let saved = null
Storage.prototype.getItem = jest.fn(() => saved)
Storage.prototype.setItem = jest.fn((key, lists) => {
saved = lists
})
saveListToHistory({id: 1})
saveListToHistory({id: 2})
saveListToHistory({id: 1})
expect(saved).toBe('[{"id":1},{"id":2}]')
})

View file

@ -78,6 +78,6 @@ export default {
return Promise.reject(e) return Promise.reject(e)
}) })
.finally(() => cancel()) .finally(() => cancel())
} },
}, },
} }

View file

@ -163,3 +163,140 @@ $filter-container-top-link-share-list: -47px;
.is-archived .notification.is-warning { .is-archived .notification.is-warning {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
$lists-per-row: 5;
$list-height: 150px;
$list-spacing: 1rem;
.list-card {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height;
background: $white;
margin: 0 $list-spacing $list-spacing 0;
padding: 1rem;
border-radius: $radius;
box-shadow: $shadow-sm;
transition: box-shadow $transition;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
&:hover {
box-shadow: $shadow-md;
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: $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;
}
}
.is-archived-container {
width: 100%;
text-align: right;
.is-archived {
font-size: .75rem;
float: left;
}
}
.title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
color: $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;
}
&.has-light-text .title {
color: $light;
}
&.has-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
.title {
text-shadow: 0 0 10px $black, 1px 1px 5px $grey-700, -1px -1px 5px $grey-700;
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;
}
}
.list-cards-wrapper-2-rows {
flex-wrap: wrap;
max-height: calc(#{$list-height * 2} + #{$list-spacing * 2});
overflow: hidden;
}

View file

@ -1,5 +1,3 @@
$lists-per-row: 5;
.namespaces-list { .namespaces-list {
.button.new-namespace { .button.new-namespace {
float: right; float: right;
@ -44,133 +42,6 @@ $lists-per-row: 5;
.lists { .lists {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
.list {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: 150px;
background: $white;
margin: 0 1rem 1rem 0;
padding: 1rem;
border-radius: $radius;
box-shadow: $shadow-sm;
transition: box-shadow $transition;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
&:hover {
box-shadow: $shadow-md;
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: $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;
}
}
.is-archived-container {
width: 100%;
text-align: right;
.is-archived {
font-size: .75rem;
float: left;
}
}
.title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
color: $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;
}
&.has-light-text .title {
color: $light;
}
&.has-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
.title {
text-shadow: 0 0 10px $black, 1px 1px 5px $grey-700, -1px -1px 5px $grey-700;
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

@ -23,6 +23,17 @@
{{ $t('home.list.import') }} {{ $t('home.list.import') }}
</x-button> </x-button>
</template> </template>
<div v-if="listHistory.length > 0" class="is-max-width-desktop has-text-left">
<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"
:background-resolver="() => null"
/>
</div>
</div>
<ShowTasks :show-all="true" v-if="hasLists"/> <ShowTasks :show-all="true" v-if="hasLists"/>
</div> </div>
</template> </template>
@ -30,10 +41,13 @@
<script> <script>
import {mapState} from 'vuex' import {mapState} from 'vuex'
import ShowTasks from './tasks/ShowTasks' import ShowTasks from './tasks/ShowTasks'
import {getHistory} from '@/modules/listHistory'
import ListCard from '@/components/list/partials/list-card'
export default { export default {
name: 'Home', name: 'Home',
components: { components: {
ListCard,
ShowTasks, ShowTasks,
}, },
data() { data() {
@ -51,20 +65,26 @@ export default {
return 'Night' return 'Night'
} }
if(now.getHours() < 11) { if (now.getHours() < 11) {
return 'Morning' return 'Morning'
} }
if(now.getHours() < 18) { if (now.getHours() < 18) {
return 'Day' return 'Day'
} }
if(now.getHours() < 23) { if (now.getHours() < 23) {
return 'Evening' return 'Evening'
} }
return 'Night' return 'Night'
}, },
listHistory() {
const history = getHistory()
return history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
})
},
...mapState({ ...mapState({
migratorsEnabled: state => state.config.availableMigrators !== null && state.config.availableMigrators.length > 0, migratorsEnabled: state => state.config.availableMigrators !== null && state.config.availableMigrators.length > 0,
authenticated: state => state.auth.authenticated, authenticated: state => state.auth.authenticated,

View file

@ -44,6 +44,7 @@ import ListModel from '../../models/list'
import ListService from '../../services/list' import ListService from '../../services/list'
import {CURRENT_LIST} from '@/store/mutation-types' import {CURRENT_LIST} from '@/store/mutation-types'
import {getListView} from '@/helpers/saveListView' import {getListView} from '@/helpers/saveListView'
import {saveListToHistory} from '@/modules/listHistory'
export default { export default {
data() { data() {
@ -92,6 +93,10 @@ export default {
return return
} }
const listData = {id: this.$route.params.listId}
saveListToHistory(listData)
this.setTitle(this.currentList.title) this.setTitle(this.currentList.title)
// This invalidates the loaded list at the kanban board which lets it reload its content when // This invalidates the loaded list at the kanban board which lets it reload its content when
@ -134,7 +139,7 @@ export default {
console.debug(`Loading list, $route.name = ${this.$route.name}, $route.params =`, this.$route.params, `, listLoaded = ${this.listLoaded}, currentList = `, this.currentList) console.debug(`Loading list, $route.name = ${this.$route.name}, $route.params =`, this.$route.params, `, listLoaded = ${this.listLoaded}, currentList = `, this.currentList)
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux. // We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux.
let list = new ListModel({id: this.$route.params.listId}) const list = new ListModel(listData)
this.listService.get(list) this.listService.get(list)
.then(r => { .then(r => {
this.$set(this, 'list', r) this.$set(this, 'list', r)

View file

@ -53,37 +53,13 @@
</p> </p>
<div class="lists"> <div class="lists">
<template v-for="l in n.lists"> <list-card
<router-link v-for="l in n.lists"
:class="{ :key="`l${l.id}`"
'has-light-text': !colorIsDark(l.hexColor), :list="l"
'has-background': typeof backgrounds[l.id] !== 'undefined', :show-archived="showArchived"
}" :background-resolver="lId => typeof backgrounds[lId] !== 'undefined' ? backgrounds[lId] : null"
:key="`l${l.id}`" />
:style="{
'background-color': l.hexColor,
'background-image': typeof backgrounds[l.id] !== 'undefined' ? `url(${backgrounds[l.id]})` : false,
}"
:to="{ name: 'list.index', params: { listId: l.id} }"
class="list"
tag="span"
v-if="showArchived ? true : !l.isArchived"
>
<div class="is-archived-container">
<span class="is-archived" v-if="l.isArchived">
{{ $t('namespace.archived') }}
</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 class="title">{{ l.title }}</div>
</router-link>
</template>
</div> </div>
</div> </div>
</div> </div>
@ -94,10 +70,12 @@ import {mapState} from 'vuex'
import ListService from '../../services/list' import ListService from '../../services/list'
import Fancycheckbox from '../../components/input/fancycheckbox' import Fancycheckbox from '../../components/input/fancycheckbox'
import {LOADING} from '@/store/mutation-types' import {LOADING} from '@/store/mutation-types'
import ListCard from '@/components/list/partials/list-card'
export default { export default {
name: 'ListNamespaces', name: 'ListNamespaces',
components: { components: {
ListCard,
Fancycheckbox, Fancycheckbox,
}, },
data() { data() {
@ -137,15 +115,6 @@ 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))
},
saveShowArchivedState() { saveShowArchivedState() {
localStorage.setItem('showArchived', JSON.stringify(this.showArchived)) localStorage.setItem('showArchived', JSON.stringify(this.showArchived))
}, },