Compare commits

..

73 Commits

Author SHA1 Message Date
badbae0e9a
fix: mark query parameter as string 2022-02-09 17:52:27 +01:00
4ac7d6b9df
fix: don't reset flatpickr date 2022-02-06 23:10:35 +01:00
7cd89b7bf1
chore: rename el 2022-02-06 23:08:34 +01:00
a22792a4b4
fix: styling 2022-02-06 23:05:40 +01:00
b1ec5b58ee
chore: use BaseButton 2022-02-06 23:05:34 +01:00
77bf347155
chore: move styling to the correct component 2022-02-06 23:03:58 +01:00
204e94aa74
chore: programmatically convert filter values to snake_case 2022-02-06 20:48:37 +01:00
aac777e286
fix: lint 2022-02-06 20:32:21 +01:00
84f177c80e
feat: reduce dependency on router and move everything to route props instead 2022-02-06 20:11:13 +01:00
1e4ef96150
chore: cleanup unnecessary css 2022-02-06 19:41:20 +01:00
4268eee1f2
chore: simplify nesting 2022-02-06 19:40:11 +01:00
436c0416d7
chore: use a primary button to select range in upcoming 2022-02-06 19:39:05 +01:00
a78ca6fad3
chore: fix nesting and positioning 2022-02-06 19:38:36 +01:00
356b291a57
chore: change return 2022-02-06 19:35:30 +01:00
60be8b428e
chore: rename date ranges export 2022-02-06 19:33:52 +01:00
f435ca99f4
chore: change import order and useStore 2022-02-06 19:31:48 +01:00
eefe6bd413
chore: move date math explanation to separate component 2022-02-06 19:29:15 +01:00
204136266f
chore: watch values instead of listening to changes 2022-02-06 18:56:45 +01:00
18f7adf420
chore: use more BaseButtons 2022-02-06 18:51:47 +01:00
c5d598cac4
chore: refactor trigger to slot 2022-02-06 18:46:53 +01:00
eeee1c842a
fix: correctly send filter values 2022-02-06 16:58:23 +01:00
6d6f2b4e33
chore: cleanup 2022-02-06 16:48:28 +01:00
cbbcb7ef23
fix: setTitle import 2022-02-06 16:36:12 +01:00
bcd34efe91
chore: convert ShowTasks component to script setup and ts 2022-02-06 16:04:49 +01:00
6c0d091e36
Merge branch 'fix/upcoming' into feature/date-math 2022-02-06 15:29:38 +01:00
1abd36ef6e
Merge branch 'main' into fix/upcoming 2022-02-06 12:45:17 +01:00
9e7c258347
fix: sort tasks correctly by due date 2022-02-06 12:43:42 +01:00
7135288800
fix: lint 2022-02-06 12:43:42 +01:00
7aa2cfc8d4
feat: add date range filter to task filters 2022-02-06 12:43:40 +01:00
3a12be505d
feat: add prop to maybe show selected date 2022-02-06 12:42:50 +01:00
a74fc47335
fix: llama position 2022-02-06 12:42:13 +01:00
0ae8a0e6ef
feat: add more pre-defined ranges 2022-02-06 12:42:13 +01:00
e7fa1d3383
feat: add explanation of how date math works 2022-02-06 12:42:12 +01:00
6c55411f71
fix: now correctly showing the title of predefined ranges 2022-02-06 12:41:41 +01:00
4d23fae9ad
fix: reset the flatpickr range when setting a date either manually or through a quick setting 2022-02-06 12:41:40 +01:00
16f48bcc2d
fix: custom date range with nothing specified 2022-02-06 12:41:40 +01:00
1e46849c78
feat: make sure date ranges work with date picker and vice-versa 2022-02-06 12:41:39 +01:00
8d5bfbe828
feat: add two inputs to toggle flatpickr 2022-02-06 12:41:20 +01:00
dabe87af4b
feat: make sure showTasks can handle dynamic dates 2022-02-06 12:41:19 +01:00
6667df5f1f
feat: move everything to fancy date math ranges 2022-02-06 12:40:23 +01:00
32bdf16892
feat: use object and loop to set date options 2022-02-06 12:40:23 +01:00
210a78be86
fix: don't try to load a langauge if there's none provided 2022-02-05 21:41:22 +01:00
ecf679d8e1
chore: completely move logic of ShowTasksInRange component to ShowTasks and remove it 2022-02-05 21:14:40 +01:00
43e83350bd
feat: move logic of ShowTasksInRange component to ShowTasks 2022-02-05 21:12:35 +01:00
c41397f5db
feat: add slot for trigger button in <datepicker-with-range> component 2022-02-05 20:29:57 +01:00
ccd8602bfd
chore: remove unused style 2022-02-05 20:18:42 +01:00
4e8a03066e
chore: use v-else 2022-02-05 20:17:34 +01:00
8d13b979ec
chore: remove unrequired type 2022-02-05 20:03:16 +01:00
d272eb2a7a
Merge branch 'main' into fix/upcoming
# Conflicts:
#	src/views/tasks/ShowTasks.vue
2022-02-05 20:02:30 +01:00
01323a1b45
Merge branch 'main' into fix/upcoming
# Conflicts:
#	src/views/tasks/ShowTasks.vue
2022-01-09 11:48:37 +01:00
7dddfea79e
fix: test 2021-12-30 16:14:51 +01:00
1648bcdb70
chore: make select date button actually a button 2021-12-30 16:12:44 +01:00
0710cea9e5
fix: lint 2021-12-29 21:33:55 +01:00
294e89b6f7
fix: z-index 2021-12-29 21:12:43 +01:00
75cbc73b33
fix: loading spinner 2021-12-29 20:59:30 +01:00
950fdce111
chore: move datepicker popup to real popup component 2021-12-29 20:54:01 +01:00
932f1774ec
feat: move date filter to popup and improve styling 2021-12-29 20:36:53 +01:00
d825960836
chore: make showNulls and showOverdue computed 2021-12-29 18:12:43 +01:00
f691e96e22
fix: checkboxes 2021-12-29 18:06:12 +01:00
0d6ef8f18a
chore: move task sorting to computed 2021-12-29 17:24:56 +01:00
d6dd1fc0e3
fix: date range 2021-12-29 17:22:14 +01:00
729aa7d4cc
fix: date format 2021-12-29 17:08:33 +01:00
c24b8af00d
fix: pay attention to week start setting 2021-12-29 16:51:21 +01:00
8f8d25ece1
feat: add more date ranges and make sure they actually make sense 2021-12-29 16:48:34 +01:00
e93be0d04c
chore: cleanup old stuff 2021-12-29 16:06:05 +01:00
a5b23a7048
feat: disable time 2021-12-29 16:03:29 +01:00
858e7d60a6
docs: add fixmes 2021-12-29 16:03:05 +01:00
12317c56b3
feat: make the custom button actually do stuff 2021-12-29 16:01:59 +01:00
378f782d44
fix: disable broken stuff 2021-12-29 16:01:23 +01:00
b274a796d4
chore: use ts 2021-12-29 16:00:06 +01:00
3d1c1e41c7
feat: make active class work 2021-12-29 15:56:50 +01:00
8115563d67
feat: add new component for a datepicker with range 2021-12-29 15:44:13 +01:00
7408c37dec
chore: cleanup and reorganize the date selection 2021-12-28 23:50:04 +01:00
58 changed files with 3684 additions and 3487 deletions

View File

@ -132,7 +132,7 @@ describe('List View Kanban', () => {
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)

View File

@ -72,7 +72,7 @@ describe('Lists', () => {
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content .tasks')
cy.get('.card-content')
.should('contain', newListName)
.should('not.contain', lists[0].title)
})

View File

@ -6,7 +6,7 @@ describe('Log out', () => {
cy.get('.navbar .user .username')
.click()
cy.get('.navbar .user .dropdown-menu .dropdown-item')
cy.get('.navbar .user .dropdown-menu a.dropdown-item')
.contains('Logout')
.click()

View File

@ -20,12 +20,12 @@
"dependencies": {
"@github/hotkey": "2.0.0",
"@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.17.7",
"@sentry/vue": "6.17.7",
"@sentry/tracing": "6.17.4",
"@sentry/vue": "6.17.4",
"@types/is-touch-device": "1.0.0",
"@vue/compat": "3.2.30",
"@vueuse/core": "7.6.0",
"@vueuse/router": "7.6.1",
"@vue/compat": "3.2.29",
"@vueuse/core": "7.5.5",
"@vueuse/router": "7.5.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.1",
@ -44,7 +44,7 @@
"snake-case": "3.0.4",
"ufo": "0.7.10",
"v-tooltip": "4.0.0-beta.17",
"vue": "3.2.30",
"vue": "3.2.29",
"vue-advanced-cropper": "2.8.0",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.5",
@ -56,31 +56,31 @@
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.1.0",
"@faker-js/faker": "6.0.0-alpha.6",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@faker-js/faker": "6.0.0-alpha.5",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/vue-fontawesome": "3.0.0-5",
"@types/flexsearch": "0.7.2",
"@typescript-eslint/eslint-plugin": "5.11.0",
"@typescript-eslint/parser": "5.11.0",
"@vitejs/plugin-legacy": "1.7.1",
"@vitejs/plugin-vue": "2.2.0",
"@typescript-eslint/eslint-plugin": "5.10.2",
"@typescript-eslint/parser": "5.10.2",
"@vitejs/plugin-legacy": "1.6.4",
"@vitejs/plugin-vue": "2.1.0",
"@vue/eslint-config-typescript": "10.0.0",
"autoprefixer": "10.4.2",
"axios": "0.25.0",
"browserslist": "4.19.1",
"caniuse-lite": "1.0.30001311",
"caniuse-lite": "1.0.30001307",
"cypress": "9.4.1",
"esbuild": "0.14.21",
"eslint": "8.9.0",
"esbuild": "0.14.18",
"eslint": "8.8.0",
"eslint-plugin-vue": "8.4.1",
"express": "4.17.2",
"happy-dom": "2.31.1",
"netlify-cli": "8.16.1",
"netlify-cli": "8.15.0",
"postcss": "8.4.6",
"postcss-preset-env": "7.3.1",
"rollup": "2.67.2",
"rollup": "2.67.0",
"rollup-plugin-visualizer": "5.5.4",
"sass": "1.49.7",
"slugify": "1.6.5",
@ -88,9 +88,9 @@
"vite": "2.7.13",
"vite-plugin-pwa": "0.11.13",
"vite-svg-loader": "3.1.2",
"vitest": "0.3.2",
"vue-tsc": "0.31.2",
"wait-on": "6.0.1",
"vitest": "0.2.7",
"vue-tsc": "0.31.1",
"wait-on": "6.0.0",
"workbox-cli": "6.4.2"
},
"eslintConfig": {

View File

@ -1,7 +1,7 @@
<template>
<ready>
<template v-if="authUser">
<TheNavigation/>
<top-navigation/>
<content-auth/>
</template>
<content-link-share v-else-if="authLinkShare"/>
@ -27,7 +27,7 @@ import {success} from '@/message'
import Notification from '@/components/misc/notification.vue'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
import TheNavigation from '@/components/home/TheNavigation.vue'
import TopNavigation from './components/home/topNavigation.vue'
import ContentAuth from './components/home/contentAuth.vue'
import ContentLinkShare from './components/home/contentLinkShare.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'

View File

@ -69,10 +69,10 @@ watchEffect(() => {
}
// if there is a href we assume the user wants an external link via a link element
// we also set a predefined value for the attribute rel, but make it possible to overwrite this by the user.
// we also set the attribute rel to "noopener" but make it possible to overwrite this by the user.
if ('href' in attrs) {
nodeName = 'a'
bindings = {rel: 'noreferrer noopener nofollow'}
bindings = {rel: 'noopener'}
}
componentNodeName.value = nodeName

View File

@ -0,0 +1,21 @@
export const DATE_RANGES = {
// Format:
// Key is the title, as a translation string, the first entry of the value array
// is the "from" date, the second one is the "to" date.
'today': ['now/d', 'now/d+1d'],
'lastWeek': ['now/w-1w', 'now/w-2w'],
'thisWeek': ['now/w', 'now/w+1w'],
'restOfThisWeek': ['now', 'now/w+1w'],
'nextWeek': ['now/w+1w', 'now/w+2w'],
'next7Days': ['now', 'now+7d'],
'lastMonth': ['now/M-1M', 'now/M-2M'],
'thisMonth': ['now/M', 'now/M+1M'],
'restOfThisMonth': ['now', 'now/M+1M'],
'nextMonth': ['now/M+1M', 'now/M+2M'],
'next30Days': ['now', 'now+30d'],
'thisYear': ['now/y', 'now/y+1y'],
'restOfThisYear': ['now', 'now/y+1y'],
}

View File

@ -0,0 +1,131 @@
<template>
<card
class="has-no-shadow how-it-works-modal"
:title="$t('input.datepickerRange.math.title')">
<p>
{{ $t('input.datepickerRange.math.intro') }}
</p>
<p>
<i18n-t keypath="input.datepickerRange.math.expression">
<code>now</code>
<code>||</code>
</i18n-t>
</p>
<p>
<i18n-t keypath="input.datepickerRange.math.similar">
<BaseButton
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
rel="noreferrer noopener nofollow" target="_blank">
Grafana
</BaseButton>
<BaseButton
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
rel="noreferrer noopener nofollow" target="_blank">
Elasticsearch
</BaseButton>
</i18n-t>
</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<li><code>+1d</code>{{ $t('input.datepickerRange.math.add1Day') }}</li>
<li><code>-1d</code>{{ $t('input.datepickerRange.math.minus1Day') }}</li>
<li><code>/d</code>{{ $t('input.datepickerRange.math.roundDay') }}</li>
</ul>
<p>{{ $t('input.datepickerRange.math.supportedUnits') }}</p>
<table class="table">
<tbody>
<tr>
<td><code>s</code></td>
<td>{{ $t('input.datepickerRange.math.units.seconds') }}</td>
</tr>
<tr>
<td><code>m</code></td>
<td>{{ $t('input.datepickerRange.math.units.minutes') }}</td>
</tr>
<tr>
<td><code>h</code></td>
<td>{{ $t('input.datepickerRange.math.units.hours') }}</td>
</tr>
<tr>
<td><code>H</code></td>
<td>{{ $t('input.datepickerRange.math.units.hours') }}</td>
</tr>
<tr>
<td><code>d</code></td>
<td>{{ $t('input.datepickerRange.math.units.days') }}</td>
</tr>
<tr>
<td><code>w</code></td>
<td>{{ $t('input.datepickerRange.math.units.weeks') }}</td>
</tr>
<tr>
<td><code>M</code></td>
<td>{{ $t('input.datepickerRange.math.units.months') }}</td>
</tr>
<tr>
<td><code>y</code></td>
<td>{{ $t('input.datepickerRange.math.units.years') }}</td>
</tr>
</tbody>
</table>
<p>{{ $t('input.datepickerRange.math.someExamples') }}</p>
<table class="table">
<tbody>
<tr>
<td><code>now</code></td>
<td>{{ $t('input.datepickerRange.math.examples.now') }}</td>
</tr>
<tr>
<td><code>now+24h</code></td>
<td>{{ $t('input.datepickerRange.math.examples.in24h') }}</td>
</tr>
<tr>
<td><code>now/d</code></td>
<td>{{ $t('input.datepickerRange.math.examples.today') }}</td>
</tr>
<tr>
<td><code>now/w</code></td>
<td>{{ $t('input.datepickerRange.math.examples.beginningOfThisWeek') }}</td>
</tr>
<tr>
<td><code>now/w+1w</code></td>
<td>{{ $t('input.datepickerRange.math.examples.endOfThisWeek') }}</td>
</tr>
<tr>
<td><code>now+30d</code></td>
<td>{{ $t('input.datepickerRange.math.examples.in30Days') }}</td>
</tr>
<tr>
<td><code>{{ exampleDate }}||+1M/d</code></td>
<td>
<i18n-t keypath="input.datepickerRange.math.examples.datePlusMonth">
<code>{{ exampleDate }}</code>
</i18n-t>
</td>
</tr>
</tbody>
</table>
</card>
</template>
<script lang="ts" setup>
import {format} from 'date-fns'
import BaseButton from '@/components/base/BaseButton.vue'
const exampleDate = format(new Date(), 'yyyy-MM-dd')
</script>
<style scoped>
.how-it-works-modal {
font-size: 1rem;
}
p {
display: inline-block !important;
}
.base-button {
display: inline;
}
</style>

View File

@ -0,0 +1,254 @@
<template>
<div class="datepicker-with-range-container">
<popup>
<template #trigger="{toggle}">
<slot name="trigger" :toggle="toggle" :buttonText="buttonText"></slot>
</template>
<template #content="{isOpen}">
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
<div class="selections">
<BaseButton @click="setDateRange(null)" :class="{'is-active': customRangeActive}">
{{ $t('misc.custom') }}
</BaseButton>
<BaseButton
v-for="(value, text) in DATE_RANGES"
:key="text"
@click="setDateRange(value)"
:class="{'is-active': from === value[0] && to === value[1]}">
{{ $t(`input.datepickerRange.ranges.${text}`) }}
</BaseButton>
</div>
<div class="flatpickr-container input-group">
<label class="label">
{{ $t('input.datepickerRange.from') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="from"/>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
</div>
</div>
</label>
<label class="label">
{{ $t('input.datepickerRange.to') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="to"/>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
</div>
</div>
</label>
<flat-pickr
:config="flatPickerConfig"
v-model="flatpickrRange"
/>
<p>
{{ $t('input.datepickerRange.math.canuse') }}
<BaseButton class="has-text-primary" @click="showHowItWorks = true">
{{ $t('input.datepickerRange.math.learnhow') }}
</BaseButton>
</p>
<modal
@close="() => showHowItWorks = false"
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
>
<DatemathHelp/>
</modal>
</div>
</div>
</template>
</popup>
</div>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
const store = useStore()
const {t} = useI18n()
const emit = defineEmits(['dateChanged'])
// FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: false,
wrap: true,
mode: 'range',
locale: {
firstDayOf7Days: weekStart.value,
},
}))
const showHowItWorks = ref(false)
const flatpickrRange = ref('')
const from = ref('')
const to = ref('')
function emitChanged() {
emit('dateChanged', {
dateFrom: from.value === '' ? null : from.value,
dateTo: to.value === '' ? null : to.value,
})
}
watch(
() => flatpickrRange.value,
(newVal: string | null) => {
if (newVal === null) {
return
}
const [fromDate, toDate] = newVal.split(' to ')
if (typeof fromDate === 'undefined' || typeof toDate === 'undefined') {
return
}
from.value = fromDate
to.value = toDate
emitChanged()
},
)
watch(() => from.value, emitChanged)
watch(() => to.value, emitChanged)
function setDateRange(range: string[] | null) {
if (range === null) {
from.value = ''
to.value = ''
return
}
from.value = range[0]
to.value = range[1]
}
const customRangeActive = computed<boolean>(() => {
return !Object.values(DATE_RANGES).some(range => from.value === range[0] && to.value === range[1])
})
const buttonText = computed<string>(() => {
if (from.value !== '' && to.value !== '') {
return t('input.datepickerRange.fromto', {
from: from.value,
to: to.value,
})
}
return t('task.show.select')
})
</script>
<style lang="scss" scoped>
.datepicker-with-range-container {
position: relative;
}
:deep(.popup) {
z-index: 10;
margin-top: 1rem;
border-radius: $radius;
border: 1px solid var(--grey-200);
background-color: var(--white);
box-shadow: $shadow;
&.is-open {
width: 500px;
height: 320px;
}
}
.datepicker-with-range {
display: flex;
width: 100%;
height: 100%;
position: absolute;
}
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}
.flatpickr-container {
width: 70%;
border-left: 1px solid var(--grey-200);
padding: 1rem;
font-size: .9rem;
// Flatpickr has no option to use it without an input field so we're hiding it instead
:deep(input.form-control.input) {
height: 0;
padding: 0;
border: 0;
}
.field .control :deep(.button) {
border: 1px solid var(--input-border-color);
height: 2.25rem;
&:hover {
border: 1px solid var(--input-hover-border-color);
}
}
.label, .input, :deep(.button) {
font-size: .9rem;
}
}
.selections {
width: 30%;
display: flex;
flex-direction: column;
padding-top: .5rem;
overflow-y: scroll;
button {
display: block;
width: 100%;
text-align: left;
padding: .5rem 1rem;
transition: $transition;
font-size: .9rem;
color: var(--text);
background: transparent;
border: 0;
cursor: pointer;
&.is-active {
color: var(--primary);
}
&:hover, &.is-active {
background-color: var(--grey-100);
}
}
}
</style>

View File

@ -1,12 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useNow } from '@vueuse/core'
import LogoFull from '@/assets/logo-full.svg?component'
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
const now = useNow()
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
const Logo = computed(() => new Date().getMonth() === 5 ? LogoFullPride : LogoFull)
</script>
<template>

View File

@ -1,7 +1,8 @@
<template>
<BaseButton
class="menu-show-button"
<button
type="button"
@click="$store.commit('toggleMenu')"
class="menu-show-button"
@shortkey="() => $store.commit('toggleMenu')"
v-shortcut="'Control+e'"
:title="$t('keyboardShortcuts.toggleMenu')"
@ -9,14 +10,11 @@
/>
</template>
<script setup lang="ts">
<script setup>
import {computed} from 'vue'
import {useStore} from 'vuex'
import {store} from '@/store'
import BaseButton from '@/components/base/BaseButton.vue'
const store = useStore()
const menuActive = computed(() => store.state.menuActive)
const menuActive = computed(() => store.menuActive)
</script>
<style lang="scss" scoped>
@ -24,6 +22,11 @@ $lineWidth: 2rem;
$size: $lineWidth + 1rem;
.menu-show-button {
// FIXME: create general button component
appearance: none;
background-color: transparent;
border: 0;
min-height: $size;
width: $size;

View File

@ -32,13 +32,12 @@
</a>
<notifications/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<dropdown class="is-right" ref="usernameDropdown">
<template #trigger>
<x-button
variant="secondary"
:shadow="false"
>
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
:shadow="false">
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
@ -46,96 +45,92 @@
</x-button>
</template>
<BaseButton
:to="{name: 'user.settings'}"
class="dropdown-item"
>
<router-link :to="{name: 'user.settings'}" class="dropdown-item">
{{ $t('user.settings.title') }}
</BaseButton>
<BaseButton
v-if="imprintUrl"
</router-link>
<a
:href="imprintUrl"
class="dropdown-item"
>
target="_blank"
rel="noreferrer noopener nofollow"
v-if="imprintUrl">
{{ $t('navigation.imprint') }}
</BaseButton>
<BaseButton
v-if="privacyPolicyUrl"
</a>
<a
:href="privacyPolicyUrl"
class="dropdown-item"
>
target="_blank"
rel="noreferrer noopener nofollow"
v-if="privacyPolicyUrl">
{{ $t('navigation.privacy') }}
</BaseButton>
<BaseButton
@click="$store.commit('keyboardShortcutsActive', true)"
class="dropdown-item"
>
</a>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">
{{ $t('keyboardShortcuts.title') }}
</BaseButton>
<BaseButton
:to="{name: 'about'}"
class="dropdown-item"
>
</a>
<router-link :to="{name: 'about'}" class="dropdown-item">
{{ $t('about.title') }}
</BaseButton>
<BaseButton
@click="logout()"
class="dropdown-item"
>
</router-link>
<a @click="logout()" class="dropdown-item">
{{ $t('user.auth.logout') }}
</BaseButton>
</a>
</dropdown>
</div>
</div>
</header>
</template>
<script setup langs="ts">
import {ref, computed, onMounted, nextTick} from 'vue'
import {useStore} from 'vuex'
import {useRouter} from 'vue-router'
import {QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import Rights from '@/models/constants/rights.json'
import Update from '@/components/home/update.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import Notifications from '@/components/notifications/notifications.vue'
import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
const store = useStore()
export default {
name: 'topNavigation',
components: {
Notifications,
Dropdown,
ListSettingsDropdown,
Update,
Logo,
MenuButton,
},
computed: {
...mapState({
userInfo: state => state.auth.info,
userAvatar: state => state.auth.avatarUrl,
userAuthenticated: state => state.auth.authenticated,
currentList: CURRENT_LIST,
background: 'background',
imprintUrl: state => state.config.legal.imprintUrl,
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
canWriteCurrentList: state => state.currentList.maxRight > Rights.READ,
}),
},
mounted() {
this.$nextTick(() => {
if (typeof this.$refs.usernameDropdown === 'undefined' || typeof this.$refs.listTitle === 'undefined') {
return
}
const userInfo = computed(() => store.state.auth.info)
const userAvatar = computed(() => store.state.auth.avatarUrl)
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
const canWriteCurrentList = computed(() => store.state.currentList.maxRight > Rights.READ)
const usernameDropdown = ref()
const listTitle = ref()
onMounted(async () => {
await nextTick()
if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
return
}
const usernameWidth = usernameDropdown.value.$el.clientWidth
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
const router = useRouter()
function logout() {
store.dispatch('auth/logout')
router.push({name: 'user.login'})
}
function openQuickActions() {
store.commit(QUICK_ACTIONS_ACTIVE, true)
const usernameWidth = this.$refs.usernameDropdown.$el.clientWidth
this.$refs.listTitle.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
},
methods: {
logout() {
this.$store.dispatch('auth/logout')
this.$router.push({name: 'user.login'})
},
openQuickActions() {
this.$store.commit(QUICK_ACTIONS_ACTIVE, true)
},
},
}
</script>
@ -251,7 +246,6 @@ $hamburger-menu-icon-width: 28px;
border-radius: 100%;
vertical-align: middle;
height: 40px;
margin-right: var(--button-padding-horizontal);
}
:deep(.dropdown-trigger .button) {

View File

@ -66,7 +66,7 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
min-height: $button-height;
height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;

View File

@ -6,7 +6,7 @@
>
{{ $t('filters.clear') }}
</x-button>
<popup>
<popup :has-overflow="true">
<template #trigger="{toggle}">
<x-button
@click.prevent.stop="toggle()"

View File

@ -67,49 +67,49 @@
<div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setDueDateFilter"
class="input"
:placeholder="$t('filters.attributes.dueDateRange')"
v-model="filters.dueDate"
/>
<datepicker-with-range @dateChanged="setDueDateFilter">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setStartDateFilter"
class="input"
:placeholder="$t('filters.attributes.startDateRange')"
v-model="filters.startDate"
/>
<datepicker-with-range @dateChanged="setStartDateFilter">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setEndDateFilter"
class="input"
:placeholder="$t('filters.attributes.endDateRange')"
v-model="filters.endDate"
/>
<datepicker-with-range @dateChanged="setEndDateFilter">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setReminderFilter"
class="input"
:placeholder="$t('filters.attributes.reminderRange')"
v-model="filters.reminders"
/>
<datepicker-with-range @dateChanged="setReminderFilter">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
@ -175,15 +175,14 @@
</template>
<script>
import DatepickerWithRange from '@/components/date/datepickerWithRange'
import Fancycheckbox from '../../input/fancycheckbox'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {includesById} from '@/helpers/utils'
import {formatISO} from 'date-fns'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import Multiselect from '@/components/input/multiselect.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import UserService from '@/services/user'
import ListService from '@/services/list'
@ -222,15 +221,15 @@ const DEFAULT_FILTERS = {
namespace: '',
}
export const ALPHABETICAL_SORT = 'title'
export const ALPHABETICAL_SORT = 'title'
export default {
name: 'filters',
components: {
DatepickerWithRange,
EditLabels,
PrioritySelect,
Fancycheckbox,
flatPickr,
PercentDoneSelect,
Multiselect,
},
@ -281,7 +280,7 @@ export default {
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
this.params.sort_by = sortAlphabetically
this.params.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
@ -291,19 +290,6 @@ export default {
foundLabels() {
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
},
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
mode: 'range',
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
methods: {
change() {
@ -343,19 +329,12 @@ export default {
}
}
},
setDateFilter(filterName, variableName = null) {
if (variableName === null) {
variableName = filterName
}
// Only filter if we have a start and end due date
if (this.filters[variableName] !== '') {
setDateFilter(filterName, {dateFrom, dateTo}) {
dateFrom = parseDateOrString(dateFrom, null)
dateTo = parseDateOrString(dateTo, null)
const parts = this.filters[variableName].split(' to ')
if (parts.length < 2) {
return
}
// Only filter if we have a date
if (dateFrom !== null && dateTo !== null) {
// Check if we already have values in params and only update them if we do
let foundStart = false
@ -363,23 +342,23 @@ export default {
this.params.filter_by.forEach((f, i) => {
if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') {
foundStart = true
this.params.filter_value[i] = formatISO(new Date(parts[0]))
this.params.filter_value[i] = dateFrom
}
if (f === filterName && this.params.filter_comparator[i] === 'less_equals') {
foundEnd = true
this.params.filter_value[i] = formatISO(new Date(parts[1]))
this.params.filter_value[i] = dateTo
}
})
if (!foundStart) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push('greater_equals')
this.params.filter_value.push(formatISO(new Date(parts[0])))
this.params.filter_value.push(dateFrom)
}
if (!foundEnd) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push('less_equals')
this.params.filter_value.push(formatISO(new Date(parts[1])))
this.params.filter_value.push(dateTo)
}
this.change()
return
@ -513,23 +492,23 @@ export default {
this.params.filter_concat = 'or'
}
},
setDueDateFilter() {
this.setDateFilter('due_date', 'dueDate')
},
setPriority() {
this.setSingleValueFilter('priority', 'priority', 'usePriority')
},
setStartDateFilter() {
this.setDateFilter('start_date', 'startDate')
},
setEndDateFilter() {
this.setDateFilter('end_date', 'endDate')
},
setPercentDoneFilter() {
this.setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
},
setReminderFilter() {
this.setDateFilter('reminders')
setDueDateFilter(values) {
this.setDateFilter('due_date', values)
},
setStartDateFilter(values) {
this.setDateFilter('start_date', values)
},
setEndDateFilter(values) {
this.setDateFilter('end_date', values)
},
setReminderFilter(values) {
this.setDateFilter('reminders', values)
},
clear(kind) {
this[`found${kind}`] = []
@ -609,7 +588,7 @@ export default {
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.single-value-control {
display: flex;
align-items: center;
@ -618,4 +597,8 @@ export default {
margin-left: .5rem;
}
}
:deep(.datepicker-with-range-container .popup) {
right: 0;
}
</style>

View File

@ -39,66 +39,79 @@
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import { useI18n } from 'vue-i18n'
<script>
import Message from '@/components/misc/message'
import {parseURL} from 'ufo'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import {success} from '@/message'
import Message from '@/components/misc/message.vue'
const props = defineProps({
configureOpen: {
type: Boolean,
required: false,
default: false,
export default {
name: 'apiConfig',
components: {
Message,
},
})
const emit = defineEmits(['foundApi'])
const apiUrl = ref(window.API_URL)
const configureApi = ref(apiUrl.value === '')
const apiDomain = computed(() => parseURL(apiUrl.value).host || parseURL(window.location.href).host)
watch(() => props.configureOpen, (value) => {
configureApi.value = value
}, { immediate: true })
const {t} = useI18n()
const errorMsg = ref('')
const successMsg = ref('')
async function setApiUrl() {
if (apiUrl.value === '') {
// Don't try to check and set an empty url
errorMsg.value = t('apiConfig.urlRequired')
return
}
try {
const url = await checkAndSetApiUrl(apiUrl.value)
if (url === '') {
// If the config setter function could not figure out a url
throw new Error('URL cannot be empty.')
data() {
return {
configureApi: false,
apiUrl: window.API_URL,
errorMsg: '',
successMsg: '',
}
},
emits: ['foundApi'],
created() {
if (this.apiUrl === '') {
this.configureApi = true
}
},
computed: {
apiDomain() {
return parseURL(this.apiUrl).host || parseURL(window.location.href).host
},
},
props: {
configureOpen: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
configureOpen: {
handler(value) {
this.configureApi = value
},
immediate: true,
},
},
methods: {
async setApiUrl() {
if (this.apiUrl === '') {
// Don't try to check and set an empty url
this.errorMsg = this.$t('apiConfig.urlRequired')
return
}
// Set it + save it to local storage to save us the hoops
errorMsg.value = ''
apiUrl.value = url
success({message: t('apiConfig.success', {domain: apiDomain.value})})
configureApi.value = false
emit('foundApi', apiUrl.value)
} catch (e) {
// Still not found, url is still invalid
successMsg.value = ''
errorMsg.value = t('apiConfig.error', {domain: apiDomain.value})
}
try {
const url = await checkAndSetApiUrl(this.apiUrl)
if (url === '') {
// If the config setter function could not figure out a url
throw new Error('URL cannot be empty.')
}
// Set it + save it to local storage to save us the hoops
this.errorMsg = ''
this.$message.success({message: this.$t('apiConfig.success', {domain: this.apiDomain})})
this.configureApi = false
this.apiUrl = url
this.$emit('foundApi', this.apiUrl)
} catch (e) {
// Still not found, url is still invalid
this.successMsg = ''
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
}
},
},
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
<div class="popup" :class="{'is-open': open}" ref="popup">
<div class="popup" :class="{'is-open': open, 'has-overflow': props.hasOverflow}" ref="popup">
<slot name="content" :isOpen="open"/>
</div>
</template>
@ -16,6 +16,13 @@ const toggle = () => {
open.value = !open.value
}
const props = defineProps({
hasOverflow: {
type: Boolean,
default: false,
},
})
function hidePopup(e) {
if (!open.value) {
return

View File

@ -23,7 +23,7 @@
}"
>
<BaseButton
@click="$emit('close')"
@click="emit('close')"
class="close"
>
<icon icon="times"/>
@ -207,7 +207,6 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}

View File

@ -1,47 +0,0 @@
<template>
<p class="created">
<time :datetime="formatISO(task.created)" v-tooltip="formatDate(task.created)">
<i18n-t keypath="task.detail.created">
<span>{{ formatDateSince(task.created) }}</span>
{{ task.createdBy.getDisplayName() }}
</i18n-t>
</time>
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
<br/>
<!-- Computed properties to show the actual date every time it gets updated -->
<time :datetime="formatISO(task.updated)" v-tooltip="updatedFormatted">
<i18n-t keypath="task.detail.updated">
<span>{{ updatedSince }}</span>
</i18n-t>
</time>
</template>
<template v-if="task.done">
<br/>
<time :datetime="formatISO(task.doneAt)" v-tooltip="doneFormatted">
<i18n-t keypath="task.detail.doneAt">
<span>{{ doneSince }}</span>
</i18n-t>
</time>
</template>
</p>
</template>
<script lang="ts" setup>
import {computed, toRefs} from 'vue'
import TaskModel from '@/models/task'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
const props = defineProps({
task: {
type: TaskModel,
required: true,
},
})
const {task} = toRefs(props)
const updatedSince = computed(() => formatDateSince(task.value.updated))
const updatedFormatted = computed(() => formatDateLong(task.value.updated))
const doneSince = computed(() => formatDateSince(task.value.doneAt))
const doneFormatted = computed(() => formatDateLong(task.value.doneAt))
</script>

View File

@ -138,6 +138,7 @@ $task-background: var(--white);
border: 3px solid transparent;
font-size: .9rem;
margin: .5rem;
padding: .4rem;
border-radius: $radius;
background: $task-background;

View File

@ -1,7 +1,7 @@
<template>
<div class="task-relations">
<x-button
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
v-if="Object.keys(relatedTasks).length > 0"
@click="showNewRelationForm = !showNewRelationForm"
class="is-pulled-right add-task-relation-button"
:class="{'is-active': showNewRelationForm}"

View File

@ -45,6 +45,10 @@ export function objectToCamelCase(object) {
*/
export function objectToSnakeCase(object) {
if (object instanceof Date) {
return object.toISOString()
}
// When calling recursively, this can be called without being and object or array in which case we just return the value
if (typeof object !== 'object') {
return object

View File

@ -0,0 +1,3 @@
export function getNextWeekDate(): Date {
return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
}

View File

@ -0,0 +1,12 @@
export function parseDateOrString(rawValue: string | undefined, fallback: any): string | Date {
if (typeof rawValue === 'undefined') {
return fallback
}
const d = new Date(rawValue)
// @ts-ignore if rawValue is an invalid date, isNan will return false.
return !isNaN(d)
? d
: rawValue
}

View File

@ -1,18 +1,7 @@
import axios from 'axios'
import {getToken} from '@/helpers/auth'
export function HTTPFactory() {
export const HTTPFactory = () => {
return axios.create({
baseURL: window.API_URL,
})
}
export function AuthenticatedHTTPFactory(token = getToken()) {
return axios.create({
baseURL: window.API_URL,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
}

View File

@ -1,4 +1,4 @@
import {createI18n} from 'vue-i18n'
import { createI18n } from 'vue-i18n'
import langEN from './lang/en.json'
export const i18n = createI18n({
@ -19,9 +19,6 @@ export const availableLanguages = {
'vi-VN': 'Tiếng Việt',
'it-IT': 'Italiano',
'cs-CZ': 'Čeština',
'pl-PL': 'Polski',
'nl-NL': 'Nederlands',
'pt-PT': 'Português',
}
const loadedLanguages = ['en'] // our default language that is preloaded
@ -33,10 +30,10 @@ const setI18nLanguage = lang => {
}
export const loadLanguageAsync = lang => {
if (!lang) {
return
if(!lang) {
return
}
if (
// If the same language
i18n.global.locale === lang ||

View File

@ -31,9 +31,10 @@
"username": "Uživatelské jméno",
"usernameEmail": "Uživatelské jméno nebo e-mail",
"usernamePlaceholder": "např. Jarmil",
"email": "Email address",
"email": "E-mailová adresa",
"emailPlaceholder": "např. jarmil{'@'}vikunja.io",
"password": "Heslo",
"passwordRepeat": "Zopakovat heslo",
"passwordPlaceholder": "např. • • • • • • • •",
"forgotPassword": "Zapomenuté heslo?",
"resetPassword": "Obnovit heslo",
@ -44,20 +45,12 @@
"totpTitle": "Kód dvoufaktorového ověření",
"totpPlaceholder": "např. 123456",
"login": "Přihlásit se",
"createAccount": "Create account",
"register": "Registrovat",
"loginWith": "Přihlásit se pomocí {provider}",
"authenticating": "Ověřování…",
"openIdStateError": "Stav neodpovídá, odmítám pokračovat!",
"openIdGeneralError": "Došlo k chybě při ověřování proti třetí straně.",
"logout": "Odhlásit se",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"logout": "Odhlásit se"
},
"settings": {
"title": "Nastavení",
@ -68,7 +61,7 @@
"currentPasswordPlaceholder": "Vaše současné heslo",
"passwordsDontMatch": "Nové heslo se neshoduje s potvrzením hesla.",
"passwordUpdateSuccess": "Heslo bylo úspěšně změněno.",
"updateEmailTitle": "Update Your Email Address",
"updateEmailTitle": "Aktualizovat Vaši e-mailovou adresu",
"updateEmailNew": "Nová e-mailová adresa",
"updateEmailSuccess": "Vaše e-mailová adresa byla úspěšně aktualizována. Poslali jsme vám odkaz pro její potvrzení.",
"general": {
@ -85,8 +78,7 @@
"weekStartSunday": "Neděle",
"weekStartMonday": "Pondělí",
"language": "Jazyk",
"defaultList": "Výchozí seznam",
"timezone": "Time Zone"
"defaultList": "Výchozí seznam"
},
"totp": {
"title": "Dvoufaktorové ověření",
@ -335,7 +327,6 @@
"archiveText": "Nebudete moci upravovat tento jmenný prostor ani vytvářet nové seznamy, dokud jej neodarchivujete. Všechny seznamy v tomto prostoru budou také archivovány.",
"unarchiveText": "Budete moci vytvářet nové úkoly nebo je upravovat.",
"success": "Prostor byl úspěšně archivován.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "Pokud je prostor archivován, nelze vytvořit nové seznamy nebo je upravit."
},
"delete": {

View File

@ -31,9 +31,10 @@
"username": "Anmeldename",
"usernameEmail": "Anmeldename oder E-Mail-Adresse",
"usernamePlaceholder": "z.B. frederick",
"email": "E-Mail-Adresse",
"email": "Email address",
"emailPlaceholder": "z.B. frederic{'@'}vikunja.io",
"password": "Passwort",
"passwordRepeat": "Gib dein Passwort erneut ein",
"passwordPlaceholder": "z.B. •••••••••••",
"forgotPassword": "Passwort vergessen?",
"resetPassword": "Setze dein Passwort zurück",
@ -44,20 +45,12 @@
"totpTitle": "Zwei-Faktor-Authentifizierungscode",
"totpPlaceholder": "z.B. 123456",
"login": "Anmelden",
"createAccount": "Account erstellen",
"register": "Registrieren",
"loginWith": "Mit {provider} anmelden",
"authenticating": "Authentifizierung…",
"openIdStateError": "Zustand stimmt nicht überein, fahre nicht fort!",
"openIdGeneralError": "Es ist ein Fehler bei der externen Authentisierung aufgetreten.",
"logout": "Abmelden",
"emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.",
"usernameRequired": "Bitte gib einen Anmeldenamen ein.",
"passwordRequired": "Bitte gib ein Passwort ein.",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"noAccountYet": "Noch kein Account?",
"alreadyHaveAnAccount": "Hast du bereits einen Account?",
"remember": "Stay logged in"
"logout": "Abmelden"
},
"settings": {
"title": "Einstellungen",
@ -85,8 +78,7 @@
"weekStartSunday": "Sonntag",
"weekStartMonday": "Montag",
"language": "Sprache",
"defaultList": "Standard-Liste",
"timezone": "Time Zone"
"defaultList": "Standard-Liste"
},
"totp": {
"title": "Zwei-Faktor-Authentifizierung",
@ -335,7 +327,6 @@
"archiveText": "Du kannst diesen Namespace nicht mehr bearbeiten oder neue Listen erstellen, bis du die Archivierung rückgängig machst. Das gilt auch für alle Listen in diesem Namespace.",
"unarchiveText": "Du kannst neue Aufgaben erstellen oder diese bearbeiten.",
"success": "Der Namespace wurde erfolgreich archiviert.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "In einem archivierten Namespace können Listen weder angelegt noch editiert werden."
},
"delete": {

View File

@ -31,7 +31,7 @@
"username": "Benutzernamä",
"usernameEmail": "Benutzernamä oder E-Mail Adrässe",
"usernamePlaceholder": "z.B. Hansruedi",
"email": "E-Mail-Adresse",
"email": "Email address",
"emailPlaceholder": "z.B. frederic{'@'}vikunja.io",
"password": "Passwort",
"passwordPlaceholder": "z.B. •••••••••••",
@ -44,20 +44,19 @@
"totpTitle": "Zweifaktor Authentifizierigs Ziffere",
"totpPlaceholder": "z.B. 123456",
"login": "Iihlogge",
"createAccount": "Account erstellen",
"createAccount": "Create account",
"loginWith": "Iihlogge mit {provider}",
"authenticating": "Authentifiziere…",
"openIdStateError": "Status stimmt nid überiih, ich verweigerä wiiter zmache!",
"openIdGeneralError": "Es ist ein Fehler bei der externen Authentisierung aufgetreten.",
"logout": "Uuslogge",
"emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.",
"usernameRequired": "Bitte gib einen Anmeldenamen ein.",
"passwordRequired": "Bitte gib ein Passwort ein.",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"noAccountYet": "Noch kein Account?",
"alreadyHaveAnAccount": "Hast du bereits einen Account?",
"remember": "Stay logged in"
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Iihstellige",
@ -68,7 +67,7 @@
"currentPasswordPlaceholder": "Diis jetzige Passwort",
"passwordsDontMatch": "Dis neue Passwort und siini Bestätigung stimmed nid überiih.",
"passwordUpdateSuccess": "Dis Passwort isch erfolgriich aktualisiert wordä.",
"updateEmailTitle": "Aktualisiere deine E-Mail-Adresse",
"updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "Neui E-Mail Adrässä",
"updateEmailSuccess": "Dini E-Mail Adrässä isch erfolgriich gänderet worde. Mir hend dir en Link gschickt, um si zu bestätigä.",
"general": {
@ -85,8 +84,7 @@
"weekStartSunday": "Sunntig",
"weekStartMonday": "Määntig",
"language": "Sproch",
"defaultList": "Standard Liste",
"timezone": "Time Zone"
"defaultList": "Standard Liste"
},
"totp": {
"title": "Zweifaktor Authentifizierig",
@ -103,9 +101,9 @@
"disableSuccess": "Zweifaktor Authentifizierig isch erfolgriich uusgschalte wore."
},
"caldav": {
"title": "CalDAV",
"howTo": "Du chasch Vikunja zu CalDAV Applikatione verbinde, um dini Uufgabe vo verschidene Gräät zgseh. Gib die Url i dim Client iih:",
"more": "Meh Informatione über CalDAV in Vikunja"
"title": "Caldav",
"howTo": "Du chasch Vikunja zu Caldav Applikatione verbinde, um dini Uufgabe vo verschidene Gräät zgseh. Gib die Url i dim Client iih:",
"more": "Meh Informatione über Caldav in Vikunja"
},
"avatar": {
"title": "Herr Der Elemente",
@ -335,7 +333,6 @@
"archiveText": "Du hesch kei möglichkeit meh de Namensruum z'bearbeite oder neui Listene drin z'erstelle, bis du si wider ent-archiviert hesch. Das archiviert au grad alli Liste im Namensruum.",
"unarchiveText": "Du chasch neui Liste erstelle oder bearbeite.",
"success": "De Namensruum isch erfolgriich archiviert worde.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "Wenn en Namensruum archiviert isch, chasch du kei neui Liste erstelle oder die bearbeite."
},
"delete": {

View File

@ -56,8 +56,7 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Settings",
@ -85,8 +84,7 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List",
"timezone": "Time Zone"
"defaultList": "Default List"
},
"totp": {
"title": "Two Factor Authentication",
@ -335,7 +333,6 @@
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {
@ -485,7 +482,8 @@
"showMenu": "Show the menu",
"hideMenu": "Hide the menu",
"forExample": "For example:",
"welcomeBack": "Welcome Back!"
"welcomeBack": "Welcome Back!",
"custom": "Custom"
},
"input": {
"resetColor": "Reset Color",
@ -524,6 +522,60 @@
"multiselect": {
"createPlaceholder": "Create new",
"selectPlaceholder": "Click or press enter to select"
},
"datepickerRange": {
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"math": {
"canuse": "You can use date math to filter for relative dates.",
"learnhow": "Check out how it works",
"title": "Date Math",
"intro": "Date Math allows you to specifiy relative dates which are resolved on the fly by Vikunja when applying the filter.",
"expression": "Each Date Math expression starts with an anchor date, which can either be {0}, or a date string ending with {1}. This anchor date can optionally be followed by one or more maths expressions.",
"similar": "These expressions are similar to the ones provided by {0} and {1}.",
"add1Day": "Add one day",
"minus1Day": "Subtract one day",
"roundDay": "Round down to the nearest day",
"supportedUnits": "Supported time units are:",
"someExamples": "Some examples of time expressions:",
"units": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"examples": {
"now": "Right now",
"in24h": "In 24h",
"today": "Today at 00:00",
"beginningOfThisWeek": "The beginning of this week at 00:00",
"endOfThisWeek": "The end of this week",
"in30Days": "In 30 days",
"datePlusMonth": "{0} plus one month at 00:00 of that day"
}
},
"ranges": {
"today": "Today",
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"restOfThisMonth": "The Rest of This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
}
}
},
"task": {
@ -541,12 +593,9 @@
"titleCurrent": "Current Tasks",
"titleDates": "Tasks from {from} until {to}",
"noDates": "Show tasks without dates",
"current": "Current tasks",
"from": "Tasks from",
"until": "until",
"today": "Today",
"nextWeek": "Next Week",
"nextMonth": "Next Month",
"overdue": "Show overdue tasks",
"fromuntil": "Tasks from {from} until {until}",
"select": "Select a date range",
"noTasks": "Nothing to do — Have a nice day!"
},
"detail": {

View File

@ -56,8 +56,7 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Settings",
@ -85,8 +84,7 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List",
"timezone": "Time Zone"
"defaultList": "Default List"
},
"totp": {
"title": "Two Factor Authentication",
@ -335,7 +333,6 @@
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {

View File

@ -34,6 +34,7 @@
"email": "Email address",
"emailPlaceholder": "p. ex. frederic{'@'}vikunja.io",
"password": "Mot de passe",
"passwordRepeat": "Retape ton mot de passe",
"passwordPlaceholder": "p. ex. •••••••••••",
"forgotPassword": "Forgot your password?",
"resetPassword": "Réinitialiser ton mot de passe",
@ -44,20 +45,12 @@
"totpTitle": "Code dauthentification à deux facteurs",
"totpPlaceholder": "p. ex. 123456",
"login": "Se connecter",
"createAccount": "Create account",
"register": "Sinscrire",
"loginWith": "Se connecter avec {provider}",
"authenticating": "Authentification…",
"openIdStateError": "Létat ne correspond pas, impossible de continuer !",
"openIdGeneralError": "Une erreur s'est produite lors de l'authentification contre un tiers.",
"logout": "Se déconnecter",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"logout": "Se déconnecter"
},
"settings": {
"title": "Paramètres",
@ -68,7 +61,7 @@
"currentPasswordPlaceholder": "Ton mot de passe actuel",
"passwordsDontMatch": "Le nouveau mot de passe et sa confirmation ne correspondent pas.",
"passwordUpdateSuccess": "Mot de passe mis à jour.",
"updateEmailTitle": "Update Your Email Address",
"updateEmailTitle": "Mets à jour ton adresse électronique",
"updateEmailNew": "Nouvelle adresse courriel",
"updateEmailSuccess": "Mise à jour de ladresse électronique. Clique sur le lien dans le courriel qui ta été envoyé pour le confirmer.",
"general": {
@ -85,8 +78,7 @@
"weekStartSunday": "dimanche",
"weekStartMonday": "lundi",
"language": "Langue",
"defaultList": "Liste par défaut",
"timezone": "Time Zone"
"defaultList": "Liste par défaut"
},
"totp": {
"title": "Authentification à deux facteurs",
@ -335,7 +327,6 @@
"archiveText": "Tu ne pourras pas modifier cet espace de noms ou créer de nouvelles listes tant que tu ne lauras pas désarchivé. Ceci archivera également toutes les listes de cet espace de noms.",
"unarchiveText": "Tu pourras créer de nouvelles listes ou les modifier.",
"success": "Espace de noms archivé.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "Larchivage dun espace de noms signifie quon ne peut pas créer de nouvelles listes dans cet espace, ni le modifier."
},
"delete": {

View File

@ -31,9 +31,10 @@
"username": "Nome utente",
"usernameEmail": "Nome utente o indirizzo e-mail",
"usernamePlaceholder": "es. frederick",
"email": "Indirizzo e-mail",
"email": "Email address",
"emailPlaceholder": "per es. frederic{'@'}vikunja.io",
"password": "Password",
"passwordRepeat": "Digita di nuovo la tua password",
"passwordPlaceholder": "es. ••••••••••••",
"forgotPassword": "Password dimenticata?",
"resetPassword": "Reimposta la tua password",
@ -44,20 +45,12 @@
"totpTitle": "Codice di autenticazione a due fattori",
"totpPlaceholder": "es. 123456",
"login": "Accedi",
"createAccount": "Crea account",
"register": "Registrati",
"loginWith": "Accedi con {provider}",
"authenticating": "Autenticazione…",
"openIdStateError": "Stato non corrispondente, impossibile continuare!",
"openIdGeneralError": "Si è verificato un errore durante l'autenticazione con terze parti.",
"logout": "Esci",
"emailInvalid": "Inserisci un indirizzo e-mail valido.",
"usernameRequired": "Inserisci un nome utente.",
"passwordRequired": "Inserisci una password.",
"showPassword": "Mostra la password",
"hidePassword": "Nascondi la password",
"noAccountYet": "Non hai un account?",
"alreadyHaveAnAccount": "Hai già un account?",
"remember": "Resta connesso"
"logout": "Esci"
},
"settings": {
"title": "Impostazioni",
@ -68,7 +61,7 @@
"currentPasswordPlaceholder": "La tua password attuale",
"passwordsDontMatch": "La nuova password e la conferma non coincidono.",
"passwordUpdateSuccess": "Password aggiornata con successo.",
"updateEmailTitle": "Aggiorna l'indirizzo e-mail",
"updateEmailTitle": "Inserisci il tuo indirizzo e-mail",
"updateEmailNew": "Nuovo indirizzo e-mail",
"updateEmailSuccess": "Il tuo indirizzo e-mail è stato aggiornato correttamente. Ti abbiamo inviato un collegamento per confermarlo.",
"general": {
@ -85,8 +78,7 @@
"weekStartSunday": "Domenica",
"weekStartMonday": "Lunedì",
"language": "Lingua",
"defaultList": "Lista predefinita",
"timezone": "Fuso Orario"
"defaultList": "Lista predefinita"
},
"totp": {
"title": "Autenticazione a due fattori",
@ -103,9 +95,9 @@
"disableSuccess": "L'autenticazione a due fattori è stata disattivata."
},
"caldav": {
"title": "CalDAV",
"title": "CalDav",
"howTo": "Puoi connettere Vikunja ai client caldav per visualizzare e gestire tutte le attività da diversi client. Inserisci questo URL nel tuo client:",
"more": "Ulteriori informazioni su CalDAV in Vikunja"
"more": "Ulteriori informazioni su caldav in Vikunja"
},
"avatar": {
"title": "Avatar",
@ -335,7 +327,6 @@
"archiveText": "Non sarà possibile modificare questo namespace o creare nuove liste fino a quando non verrà disarchiviato. Questo archivierà anche tutte le liste in questo namespace.",
"unarchiveText": "Potrai creare nuove liste o modificarle.",
"success": "Namespace creato.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "Se un namespace è archiviato, non è possibile creare nuove liste o modificarlo."
},
"delete": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -56,8 +56,7 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Settings",
@ -85,8 +84,7 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List",
"timezone": "Time Zone"
"defaultList": "Default List"
},
"totp": {
"title": "Two Factor Authentication",
@ -335,7 +333,6 @@
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {

File diff suppressed because it is too large Load Diff

View File

@ -56,8 +56,7 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Settings",
@ -85,8 +84,7 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List",
"timezone": "Time Zone"
"defaultList": "Default List"
},
"totp": {
"title": "Two Factor Authentication",
@ -335,7 +333,6 @@
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {

View File

@ -56,8 +56,7 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Настройки",
@ -85,8 +84,7 @@
"weekStartSunday": "Воскресенье",
"weekStartMonday": "Понедельник",
"language": "Язык",
"defaultList": "Список по умолчанию",
"timezone": "Time Zone"
"defaultList": "Список по умолчанию"
},
"totp": {
"title": "Двухфакторная аутентификация",
@ -105,7 +103,7 @@
"caldav": {
"title": "CalDAV",
"howTo": "Ты можешь подключить Vikunja к клиентам CalDAV, чтобы просматривать и управлять всеми задачами из разных клиентов. Введи этот URL в свой клиент:",
"more": "Подробнее о CalDAV в Vikunja"
"more": "Подробнее о caldav в Vikunja"
},
"avatar": {
"title": "Аватар",
@ -335,7 +333,6 @@
"archiveText": "Ты не сможешь изменять это пространство имён, пока не вернёшь его из архива. Это также касается всех списков в этом пространстве имён.",
"unarchiveText": "Ты сможешь создавать новые списки или изменять их.",
"success": "Пространство имён архивировано.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "Архивирование пространства имён означает, что ты не сможешь создавать в нём новые списки или изменять их."
},
"delete": {

View File

@ -56,8 +56,7 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Settings",
@ -85,8 +84,7 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List",
"timezone": "Time Zone"
"defaultList": "Default List"
},
"totp": {
"title": "Two Factor Authentication",
@ -335,7 +333,6 @@
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {

View File

@ -56,8 +56,7 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Settings",
@ -85,8 +84,7 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List",
"timezone": "Time Zone"
"defaultList": "Default List"
},
"totp": {
"title": "Two Factor Authentication",
@ -335,7 +333,6 @@
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {

View File

@ -56,8 +56,7 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Cài đặt",
@ -85,8 +84,7 @@
"weekStartSunday": "Chủ nhật",
"weekStartMonday": "Thứ hai",
"language": "Ngôn ngữ",
"defaultList": "Danh sách mặc định",
"timezone": "Time Zone"
"defaultList": "Danh sách mặc định"
},
"totp": {
"title": "Xác thực hai lớp",
@ -103,7 +101,7 @@
"disableSuccess": "Xác thực hai lớp đã bị vô hiệu hóa thành công."
},
"caldav": {
"title": "Giao thức CalDAV",
"title": "Giao thức Caldav",
"howTo": "Bạn có thể kết nối Vikunja tới các máy khách CalDAV để xem và quản lý tất cả các công việc từ nhiều máy khách khác nhau. Nhập URL này vào ứng dụng khách của bạn:",
"more": "Tìm hiểu thêm về CalDAV"
},
@ -335,7 +333,6 @@
"archiveText": "Bạn sẽ không thể chỉnh sửa góc làm việc này hoặc tạo danh sách mới cho đến khi bạn bỏ lưu trữ nó. Điều này cũng sẽ lưu trữ tất cả các danh sách trong góc làm việc này.",
"unarchiveText": "Bạn có thể tạo danh sách mới hoặc chỉnh sửa nó.",
"success": "Góc làm việc đã lưu trữ thành công.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "Nếu một góc làm việc được lưu trữ, bạn không thể tạo thêm danh sách hoặc chỉnh sửa nó."
},
"delete": {

View File

@ -11,7 +11,6 @@ export default class UserSettingsModel extends AbstractModel {
overdueTasksRemindersEnabled: true,
defaultListId: undefined,
weekStart: 0,
timezone: '',
}
}
}

View File

@ -3,6 +3,8 @@ import {saveLastVisited} from '@/helpers/saveLastVisited'
import {store} from '@/store'
import {saveListView, getListView} from '@/helpers/saveListView'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
import HomeComponent from '../views/Home.vue'
import NotFoundComponent from '../views/404.vue'
@ -13,7 +15,7 @@ import RegisterComponent from '../views/user/Register.vue'
import OpenIdAuth from '../views/user/OpenIdAuth.vue'
import DataExportDownload from '../views/user/DataExportDownload.vue'
// Tasks
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange.vue'
import UpcomingTasksComponent from '../views/tasks/ShowTasks.vue'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
import TaskDetailView from '../views/tasks/TaskDetailView.vue'
@ -248,7 +250,13 @@ const router = createRouter({
{
path: '/tasks/by/upcoming',
name: 'tasks.range',
component: ShowTasksInRangeComponent,
component: UpcomingTasksComponent,
props: route => ({
dateFrom: parseDateOrString(route.query.from as string, new Date()),
dateTo: parseDateOrString(route.query.to as string, getNextWeekDate()),
showNulls: route.query.showNulls === 'true',
showOverdue: route.query.showOverdue === 'true',
}),
},
{
path: '/lists/new/:namespaceId/',

View File

@ -1,6 +1,5 @@
import {HTTPFactory, AuthenticatedHTTPFactory} from '@/http-common'
import {HTTPFactory} from '@/http-common'
import {i18n, getCurrentLanguage, saveLanguage} from '@/i18n'
import {objectToSnakeCase} from '@/helpers/case'
import {LOADING} from '../mutation-types'
import UserModel from '@/models/user'
import UserSettingsService from '@/services/userSettings'
@ -91,8 +90,17 @@ export default {
// Delete an eventually preexisting old token
removeToken()
const data = {
username: credentials.username,
password: credentials.password,
}
if (credentials.totpPasscode) {
data.totp_passcode = credentials.totpPasscode
}
try {
const response = await HTTP.post('login', objectToSnakeCase(credentials))
const response = await HTTP.post('login', data)
// Save the token to local storage for later use
saveToken(response.data.token, true)
@ -215,9 +223,13 @@ export default {
return
}
const HTTP = AuthenticatedHTTPFactory(jwt)
const HTTP = HTTPFactory()
try {
const response = await HTTP.get('user')
const response = await HTTP.get('user', {
headers: {
Authorization: `Bearer ${jwt}`,
},
})
const info = new UserModel(response.data)
info.type = state.info.type
info.email = state.info.email

View File

@ -44,15 +44,35 @@ export default {
},
mutations: {
[CONFIG](state, config) {
Object.assign(state, config)
state.version = config.version
state.frontendUrl = config.frontend_url
state.motd = config.motd
state.linkSharingEnabled = config.link_sharing_enabled
state.maxFileSize = config.max_file_size
state.registrationEnabled = config.registration_enabled
state.availableMigrators = config.available_migrators
state.taskAttachmentsEnabled = config.task_attachments_enabled
state.totpEnabled = config.totp_enabled
state.enabledBackgroundProviders = config.enabled_background_providers
state.legal.imprintUrl = config.legal.imprint_url
state.legal.privacyPolicyUrl = config.legal.privacy_policy_url
state.caldavEnabled = config.caldav_enabled
state.userDeletionEnabled = config.user_deletion_enabled
state.taskCommentsEnabled = config.task_comments_enabled
const auth = objectToCamelCase(config.auth)
state.auth.local.enabled = auth.local.enabled
state.auth.openidConnect.enabled = auth.openidConnect.enabled
state.auth.openidConnect.redirectUrl = auth.openidConnect.redirectUrl
state.auth.openidConnect.providers = auth.openidConnect.providers
},
},
actions: {
async update(ctx) {
const HTTP = HTTPFactory()
const {data: config} = await HTTP.get('info')
ctx.commit(CONFIG, objectToCamelCase(config))
return config
const {data: info} = await HTTP.get('info')
ctx.commit(CONFIG, info)
return info
},
},
}

View File

@ -65,7 +65,7 @@ h6 {
}
.has-overflow {
overflow: visible;
overflow: visible !important;
}
.has-horizontal-overflow {

View File

@ -50,7 +50,12 @@
/>
</div>
</div>
<ShowTasks class="mt-4" :show-all="true" v-if="hasLists" :key="showTasksKey"/>
<ShowTasks
v-if="hasLists"
class="mt-4"
:show-all="true"
:key="showTasksKey"
/>
</div>
</template>
@ -83,13 +88,14 @@ const userInfo = computed(() => store.state.auth.info)
const hasTasks = computed(() => store.state.hasTasks)
const defaultListId = computed(() => store.state.auth.defaultListId)
const defaultNamespaceId = computed(() => store.state.namespaces.namespaces?.[0]?.id || 0)
const hasLists = computed (() => store.state.namespaces.namespaces?.[0]?.lists.length > 0)
const hasLists = computed(() => store.state.namespaces.namespaces?.[0]?.lists.length > 0)
const loading = computed(() => store.state.loading && store.state.loadingModule === 'tasks')
const deletionScheduledAt = computed(() => parseDateOrNull(store.state.auth.info?.deletionScheduledAt))
// This is to reload the tasks list after adding a new task through the global task add.
// FIXME: Should use vuex (somehow?)
const showTasksKey = ref(0)
function updateTaskList() {
showTasksKey.value++
}

View File

@ -2,12 +2,12 @@
<ListWrapper class="list-kanban" :list-id="listId" viewName="kanban">
<template #header>
<div class="filter-container" v-if="isSavedFilter">
<div class="items">
<filter-popup
v-model="params"
@update:modelValue="loadBuckets"
/>
</div>
<div class="items">
<filter-popup
v-model="params"
@update:modelValue="loadBuckets"
/>
</div>
</div>
</template>
@ -123,59 +123,61 @@
</a>
</dropdown>
</div>
<draggable
v-bind="dragOptions"
:modelValue="bucket.tasks"
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
@start="() => dragstart(bucket)"
@end="updateTaskPosition"
:group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
:disabled="!canWrite"
:data-bucket-index="bucketIndex"
tag="transition-group"
:item-key="(task) => `bucket${bucket.id}-task${task.id}`"
:component-data="getTaskDraggableTaskComponentData(bucket)"
<div
:ref="(el) => setTaskContainerRef(bucket.id, el)"
@scroll="($event) => handleTaskContainerScroll(bucket.id, bucket.listId, $event.target)"
class="tasks"
>
<template #footer>
<div class="bucket-footer" v-if="canWrite">
<div class="field" v-if="showNewTaskInput[bucket.id]">
<div class="control" :class="{'is-loading': loading}">
<input
class="input"
:disabled="loading || undefined"
@focusout="toggleShowNewTaskInput(bucket.id)"
@keyup.enter="addTaskToBucket(bucket.id)"
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
:placeholder="$t('list.kanban.addTaskPlaceholder')"
type="text"
v-focus.always
v-model="newTaskText"
/>
</div>
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText === ''">
{{ $t('list.create.addTitleRequired') }}
</p>
</div>
<x-button
@click="toggleShowNewTaskInput(bucket.id)"
class="is-fullwidth has-text-centered"
:shadow="false"
v-else
icon="plus"
variant="secondary"
>
{{ bucket.tasks.length === 0 ? $t('list.kanban.addTask') : $t('list.kanban.addAnotherTask') }}
</x-button>
<draggable
v-bind="dragOptions"
:modelValue="bucket.tasks"
@update:modelValue="(tasks) => updateTasks(bucket.id, tasks)"
@start="() => dragstart(bucket)"
@end="updateTaskPosition"
:group="{name: 'tasks', put: shouldAcceptDrop(bucket) && !dragBucket}"
:disabled="!canWrite"
:data-bucket-index="bucketIndex"
tag="transition-group"
:item-key="(task) => `bucket${bucket.id}-task${task.id}`"
:component-data="taskDraggableTaskComponentData"
>
<template #item="{element: task}">
<kanban-card :task="task"/>
</template>
</draggable>
</div>
<div class="bucket-footer" v-if="canWrite">
<div class="field" v-if="showNewTaskInput[bucket.id]">
<div class="control" :class="{'is-loading': loading}">
<input
class="input"
:disabled="loading || null"
@focusout="toggleShowNewTaskInput(bucket.id)"
@keyup.enter="addTaskToBucket(bucket.id)"
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
:placeholder="$t('list.kanban.addTaskPlaceholder')"
type="text"
v-focus.always
v-model="newTaskText"
/>
</div>
</template>
<template #item="{element: task}">
<div class="task-item">
<kanban-card class="kanban-card" :task="task"/>
</div>
</template>
</draggable>
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText === ''">
{{ $t('list.create.addTitleRequired') }}
</p>
</div>
<x-button
@click="toggleShowNewTaskInput(bucket.id)"
class="is-transparent is-fullwidth has-text-centered"
:shadow="false"
v-if="!showNewTaskInput[bucket.id]"
icon="plus"
variant="secondary"
>
{{
bucket.tasks.length === 0 ? $t('list.kanban.addTask') : $t('list.kanban.addAnotherTask')
}}
</x-button>
</div>
</div>
</template>
</draggable>
@ -195,10 +197,10 @@
v-model="newBucketTitle"
/>
<x-button
v-else
@click="() => showNewBucketInput = true"
:shadow="false"
class="is-transparent is-fullwidth has-text-centered"
v-else
variant="secondary"
icon="plus"
>
@ -311,20 +313,6 @@ export default {
},
},
computed: {
getTaskDraggableTaskComponentData() {
return (bucket) => ({
ref: (el) => this.setTaskContainerRef(bucket.id, el),
onScroll: (event) => this.handleTaskContainerScroll(bucket.id, bucket.listId, event.target),
type: 'transition',
tag: 'div',
name: !this.drag ? 'move-card' : null,
class: [
'tasks',
{'dragging-disabled': !this.canWrite},
],
})
},
isSavedFilter() {
return this.list.isSavedFilter && !this.list.isSavedFilter()
},
@ -345,6 +333,17 @@ export default {
],
}
},
taskDraggableTaskComponentData() {
return {
type: 'transition',
tag: 'div',
name: !this.drag ? 'move-card' : null,
class: [
'dropper',
{'dragging-disabled': !this.canWrite},
],
}
},
buckets() {
return this.$store.state.kanban.buckets
},
@ -407,25 +406,10 @@ export default {
// of the drop target works all the time.
const bucketIndex = parseInt(e.to.dataset.bucketIndex)
const newBucket = this.buckets[bucketIndex]
// HACK:
// this is a hacky workaround for a known problem of vue.draggable.next when using the footer slot
// the problem: https://github.com/SortableJS/vue.draggable.next/issues/108
// This hack doesn't remove the problem that the ghost item is still displayed below the footer
// It just makes releasing the item possible.
// The newIndex of the event doesn't count in the elements of the footer slot.
// This is why in case the length of the tasks is identical with the newIndex
// we have to remove 1 to get the correct index.
const newTaskIndex = newBucket.tasks.length === e.newIndex
? e.newIndex - 1
: e.newIndex
const task = newBucket.tasks[newTaskIndex]
const taskBefore = newBucket.tasks[newTaskIndex - 1] ?? null
const taskAfter = newBucket.tasks[newTaskIndex + 1] ?? null
const task = newBucket.tasks[e.newIndex]
const taskBefore = newBucket.tasks[e.newIndex - 1] ?? null
const taskAfter = newBucket.tasks[e.newIndex + 1] ?? null
const newTask = cloneDeep(task) // cloning the task to avoid vuex store mutations
newTask.bucketId = newBucket.id,
@ -541,10 +525,7 @@ export default {
const updatedData = {
id: bucket.id,
position: calculateItemPosition(
bucketBefore !== null ? bucketBefore.position : null,
bucketAfter !== null ? bucketAfter.position : null,
),
position: calculateItemPosition(bucketBefore !== null ? bucketBefore.position : null, bucketAfter !== null ? bucketAfter.position : null),
}
this.$store.dispatch('kanban/updateBucket', updatedData)
@ -565,14 +546,9 @@ export default {
},
shouldAcceptDrop(bucket) {
return (
// When dragging from a bucket who has its limit reached, dragging should still be possible
bucket.id === this.sourceBucket ||
// If there is no limit set, dragging & dropping should always work
bucket.limit === 0 ||
// Disallow dropping to buckets which have their limit reached
bucket.tasks.length < bucket.limit
)
return bucket.id === this.sourceBucket || // When dragging from a bucket who has its limit reached, dragging should still be possible
bucket.limit === 0 || // If there is no limit set, dragging & dropping should always work
bucket.tasks.length < bucket.limit // Disallow dropping to buckets which have their limit reached
},
dragstart(bucket) {
@ -621,6 +597,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
}
.kanban {
overflow-x: auto;
overflow-y: hidden;
height: calc(#{$crazy-height-calculation});
@ -633,28 +610,21 @@ $filter-container-height: '1rem - #{$switch-view-height}';
&-bucket-container {
display: flex;
align-items: flex-start;
}
.ghost {
position: relative;
background: transparent !important;
border: 3px dashed var(--grey-300) !important;
box-shadow: none !important;
* {
opacity: 0;
}
&::after {
content: '';
position: absolute;
display: block;
top: 0.25rem;
right: 0.5rem;
bottom: 0.25rem;
left: 0.5rem;
border: 3px dashed var(--grey-300);
border-radius: $radius;
}
}
.bucket {
background-color: var(--grey-100);
border-radius: $radius;
position: relative;
@ -662,24 +632,24 @@ $filter-container-height: '1rem - #{$switch-view-height}';
max-height: 100%;
min-height: 20px;
width: $bucket-width;
display: flex;
flex-direction: column;
.tasks {
overflow: hidden auto;
height: 100%;
max-height: calc(#{$crazy-height-calculation-tasks});
overflow: auto;
@media screen and (max-width: $tablet) {
max-height: calc(#{$crazy-height-calculation-tasks} - #{$filter-container-height});
}
.dropper {
&, > div {
min-height: 40px;
}
}
}
.task-item {
background-color: var(--grey-100);
padding: .25rem .5rem;
&:first-of-type {
padding-top: .5rem;
}
&:last-of-type {
padding-bottom: .5rem;
}
.move-card-move {
transition: transform $transition-duration;
}
.no-move {
@ -712,11 +682,10 @@ $filter-container-height: '1rem - #{$switch-view-height}';
}
&.is-collapsed {
align-self: flex-start;
transform: rotate(90deg) translateY(-100%);
transform-origin: top left;
transform: rotate(90deg) translateX(math.div($bucket-width, 2) - math.div($bucket-header-height, 2));
// Using negative margins instead of translateY here to make all other buckets fill the empty space
margin-right: calc((#{$bucket-width} - #{$bucket-header-height} - #{$bucket-right-margin}) * -1);
margin-left: (math.div($bucket-width, 2) - math.div($bucket-header-height, 2)) * -1;
margin-right: calc(#{(math.div($bucket-width, 2) - math.div($bucket-header-height, 2)) * -1} + #{$bucket-right-margin});
cursor: pointer;
.tasks, .bucket-footer {
@ -726,8 +695,6 @@ $filter-container-height: '1rem - #{$switch-view-height}';
}
.bucket-header {
background-color: var(--grey-100);
height: min-content;
display: flex;
align-items: center;
justify-content: space-between;
@ -757,13 +724,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
}
.bucket-footer {
position: sticky;
bottom: 0;
height: min-content;
padding: .5rem;
background-color: var(--grey-100);
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
.button {
background-color: transparent;
@ -776,13 +737,8 @@ $filter-container-height: '1rem - #{$switch-view-height}';
}
.task-dragging {
transform: rotateZ(3deg);
transition: transform 0.18s ease;
}
.move-card-move {
transform: rotateZ(3deg);
transition: transform $transition-duration;
transform: rotateZ(3deg)
}
.move-card-leave-from,

View File

@ -37,13 +37,12 @@ export default {
methods: {
async archiveNamespace() {
try {
const isArchived = !this.namespace.isArchived
const namespace = await this.namespaceService.update({
...this.namespace,
isArchived,
isArchived: !this.namespace.isArchived,
})
this.$store.commit('namespaces/setNamespaceById', namespace)
this.$message.success({message: this.$t(isArchived ? 'namespace.archive.success' : 'namespace.archive.unarchiveSuccess')})
this.$message.success({message: this.$t('namespace.archive.success')})
} finally {
this.$router.back()
}

View File

@ -1,283 +1,239 @@
<template>
<div class="is-max-width-desktop show-tasks">
<fancycheckbox
@change="setDate"
class="is-pulled-right"
v-if="!showAll"
v-model="showNulls"
>
{{ $t('task.show.noDates') }}
</fancycheckbox>
<h3 v-if="showAll && tasks.length > 0">
{{ $t('task.show.current') }}
<div class="is-max-width-desktop has-text-left ">
<h3 class="mb-2 title">
{{ pageTitle }}
</h3>
<h3 v-else-if="!showAll" class="mb-2">
{{ $t('task.show.from') }}
<flat-pickr
:class="{ 'disabled': loading}"
:config="flatPickerConfig"
:disabled="loading"
@on-close="setDate"
class="input"
v-model="cStartDate"
/>
{{ $t('task.show.until') }}
<flat-pickr
:class="{ 'disabled': loading}"
:config="flatPickerConfig"
:disabled="loading"
@on-close="setDate"
class="input"
v-model="cEndDate"
/>
</h3>
<div v-if="!showAll" class="mb-4">
<x-button variant="secondary" @click="showTodaysTasks()" class="mr-2">{{ $t('task.show.today') }}</x-button>
<x-button variant="secondary" @click="setDatesToNextWeek()" class="mr-2">{{ $t('task.show.nextWeek') }}</x-button>
<x-button variant="secondary" @click="setDatesToNextMonth()">{{ $t('task.show.nextMonth') }}</x-button>
</div>
<p v-if="!showAll" class="show-tasks-options">
<datepicker-with-range @dateChanged="setDate">
<template #trigger="{toggle}">
<x-button @click.prevent.stop="toggle()" variant="primary" :shadow="false" class="mb-2">
{{ $t('task.show.select') }}
</x-button>
</template>
</datepicker-with-range>
<fancycheckbox @change="setShowNulls" class="mr-2">
{{ $t('task.show.noDates') }}
</fancycheckbox>
<fancycheckbox @change="setShowOverdue">
{{ $t('task.show.overdue') }}
</fancycheckbox>
</p>
<template v-if="!loading && (!tasks || tasks.length === 0) && showNothingToDo">
<h3 class="nothing">{{ $t('task.show.noTasks') }}</h3>
<LlamaCool class="llama-cool" />
<h3 class="has-text-centered mt-6">{{ $t('task.show.noTasks') }}</h3>
<LlamaCool class="llama-cool"/>
</template>
<div :class="{ 'is-loading': loading}" class="spinner"></div>
<card :padding="false" class="has-overflow" :has-content="false" v-if="tasks && tasks.length > 0">
<div class="tasks">
<card
v-if="hasTasks"
:padding="false"
class="has-overflow"
:has-content="false"
:loading="loading"
>
<div class="p-2">
<single-task-in-list
v-for="t in tasksSorted"
:key="t.id"
class="task"
v-for="t in tasks"
:show-list="true"
:the-task="t"
@taskUpdated="updateTasks"/>
</div>
</card>
<div v-else :class="{ 'is-loading': loading}" class="spinner"></div>
</div>
</template>
<script>
import SingleTaskInList from '../../components/tasks/partials/singleTaskInList'
import {mapState} from 'vuex'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import Fancycheckbox from '../../components/input/fancycheckbox'
import {LOADING, LOADING_MODULE} from '../../store/mutation-types'
<script setup lang="ts">
import {computed, ref, watchEffect} from 'vue'
import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import TaskModel from '@/models/task'
import {formatDate} from '@/helpers/time/formatDate'
import {setTitle} from '@/helpers/setTitle'
import {objectToSnakeCase} from '@/helpers/case'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SingleTaskInList from '@/components/tasks/partials/singleTaskInList.vue'
import DatepickerWithRange from '@/components/date/datepickerWithRange.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import LlamaCool from '@/assets/llama-cool.svg?component'
export default {
name: 'ShowTasks',
components: {
Fancycheckbox,
SingleTaskInList,
flatPickr,
LlamaCool,
},
data() {
return {
tasks: [],
showNulls: true,
showOverdue: false,
const store = useStore()
const route = useRoute()
const router = useRouter()
const {t} = useI18n()
cStartDate: null,
cEndDate: null,
const tasks = ref<TaskModel[]>([])
const showNothingToDo = ref<boolean>(false)
showNothingToDo: false,
}
},
props: {
startDate: Date,
endDate: Date,
showAll: Boolean,
},
created() {
this.cStartDate = this.startDate
this.cEndDate = this.endDate
this.loadPendingTasks()
},
mounted() {
setTimeout(() => this.showNothingToDo = true, 100)
},
watch: {
'$route': {
handler: 'loadPendingTasks',
deep: true,
},
startDate(newVal) {
this.cStartDate = newVal
},
endDate(newVal) {
this.cEndDate = newVal
},
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
...mapState({
userAuthenticated: state => state.auth.authenticated,
loading: state => state[LOADING] && state[LOADING_MODULE] === 'tasks',
}),
},
methods: {
setDate() {
this.$router.push({
name: this.$route.name,
query: {
from: +new Date(this.cStartDate),
to: +new Date(this.cEndDate),
showOverdue: this.showOverdue,
showNulls: this.showNulls,
},
setTimeout(() => showNothingToDo.value = true, 100)
// NOTE: You MUST provide either dateFrom and dateTo OR showAll for the component to actually show tasks.
// Linting disabled because we explicitely enabled destructuring in vite's config, this will work.
// eslint-disable-next-line vue/no-setup-props-destructure
const {
dateFrom,
dateTo,
showAll = false,
showNulls = false,
showOverdue = false,
} = defineProps<{
dateFrom?: Date | string,
dateTo?: Date | string,
showAll?: Boolean,
showNulls?: Boolean,
showOverdue?: Boolean,
}>()
const pageTitle = computed(() => {
let title = ''
// We need to define "key" because it is the first parameter in the array and we need the second
// eslint-disable-next-line no-unused-vars
const predefinedRange = Object.entries(DATE_RANGES).find(([key, value]) => dateFrom === value[0] && dateTo === value[1])
if (typeof predefinedRange !== 'undefined') {
title = t(`input.datepickerRange.ranges.${predefinedRange[0]}`)
} else {
title = showAll
? t('task.show.titleCurrent')
: t('task.show.fromuntil', {
from: formatDate(dateFrom, 'PPP'),
until: formatDate(dateTo, 'PPP'),
})
},
async loadPendingTasks() {
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
// Since this component is mounted as the home page before unauthenticated users get redirected
// to the login page, they will almost always see the error message.
if (!this.userAuthenticated) {
return
}
}
// Make sure all dates are date objects
if (typeof this.$route.query.from !== 'undefined' && typeof this.$route.query.to !== 'undefined') {
this.cStartDate = new Date(Number(this.$route.query.from))
this.cEndDate = new Date(Number(this.$route.query.to))
} else {
this.cStartDate = new Date(this.cStartDate)
this.cEndDate = new Date(this.cEndDate)
}
this.showOverdue = this.$route.query.showOverdue
this.showNulls = this.$route.query.showNulls
return title
})
const tasksSorted = computed(() => {
// Sort all tasks to put those with a due date before the ones without a due date, the
// soonest before the later ones.
// We can't use the api sorting here because that sorts tasks with a due date after
// ones without a due date.
if (this.showAll) {
this.setTitle(this.$t('task.show.titleCurrent'))
} else {
this.setTitle(this.$t('task.show.titleDates', {
from: this.cStartDate.toLocaleDateString(),
to: this.cEndDate.toLocaleDateString(),
}))
}
const tasksWithDueDate = [...tasks.value]
.filter(t => t.dueDate !== null)
.sort((a, b) => {
const sortByDueDate = a.dueDate - b.dueDate
return sortByDueDate === 0
? b.id - a.id
: sortByDueDate
})
const tasksWithoutDueDate = [...tasks.value]
.filter(t => t.dueDate === null)
const params = {
sort_by: ['due_date', 'id'],
order_by: ['desc', 'desc'],
filter_by: ['done'],
filter_value: [false],
filter_comparator: ['equals'],
filter_concat: 'and',
filter_include_nulls: this.showNulls,
}
if (!this.showAll) {
if (this.showNulls) {
params.filter_by.push('start_date')
params.filter_value.push(this.cStartDate)
params.filter_comparator.push('greater')
return [
...tasksWithDueDate,
...tasksWithoutDueDate,
]
})
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
const userAuthenticated = computed(() => store.state.auth.authenticated)
const loading = computed(() => store.state[LOADING] && store.state[LOADING_MODULE] === 'tasks')
params.filter_by.push('end_date')
params.filter_value.push(this.cEndDate)
params.filter_comparator.push('less')
}
params.filter_by.push('due_date')
params.filter_value.push(this.cEndDate)
params.filter_comparator.push('less')
if (!this.showOverdue) {
params.filter_by.push('due_date')
params.filter_value.push(this.cStartDate)
params.filter_comparator.push('greater')
}
}
const tasks = await this.$store.dispatch('tasks/loadTasks', params)
// FIXME: sort tasks in computed
// Sort all tasks to put those with a due date before the ones without a due date, the
// soonest before the later ones.
// We can't use the api sorting here because that sorts tasks with a due date after
// ones without a due date.
this.tasks = tasks.sort((a, b) => {
const sortByDueDate = b.dueDate - a.dueDate
return sortByDueDate === 0
? b.id - a.id
: sortByDueDate
})
},
// FIXME: this modification should happen in the store
updateTasks(updatedTask) {
for (const t in this.tasks) {
if (this.tasks[t].id === updatedTask.id) {
this.tasks[t] = updatedTask
// Move the task to the end of the done tasks if it is now done
if (updatedTask.done) {
this.tasks.splice(t, 1)
this.tasks.push(updatedTask)
}
break
}
}
},
setDatesToNextWeek() {
const now = new Date()
this.cStartDate = now
this.cEndDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
this.showOverdue = false
this.setDate()
},
setDatesToNextMonth() {
const now = new Date()
this.cStartDate = now
this.cEndDate = new Date((new Date()).setMonth(now.getMonth() + 1))
this.showOverdue = false
this.setDate()
},
showTodaysTasks() {
const now = new Date()
this.cStartDate = now
this.cEndDate = new Date((new Date()).setDate(now.getDate() + 1))
this.showOverdue = true
this.setDate()
},
},
interface dateStrings {
dateFrom: string,
dateTo: string,
}
function setDate(dates: dateStrings) {
router.push({
name: route.name as string,
query: {
from: dates.dateFrom ?? dateFrom,
to: dates.dateTo ?? dateTo,
showOverdue: showOverdue ? 'true' : 'false',
showNulls: showNulls ? 'true' : 'false',
},
})
}
function setShowOverdue(show: boolean) {
router.push({
name: route.name as string,
query: {
...route.query,
showOverdue: show ? 'true' : 'false',
},
})
}
function setShowNulls(show: boolean) {
router.push({
name: route.name as string,
query: {
...route.query,
showNulls: show ? 'true' : 'false',
},
})
}
async function loadPendingTasks(from: string, to: string) {
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
// Since this component is mounted as the home page before unauthenticated users get redirected
// to the login page, they will almost always see the error message.
if (!userAuthenticated.value) {
return
}
const params = {
sortBy: ['due_date', 'id'],
orderBy: ['desc', 'desc'],
filterBy: ['done'],
filterValue: ['false'],
filterComparator: ['equals'],
filterConcat: 'and',
filterIncludeNulls: showNulls,
}
if (!showAll) {
params.filterBy.push('due_date')
params.filterValue.push(to)
params.filterComparator.push('less')
// NOTE: Ideally we could also show tasks with a start or end date in the specified range, but the api
// is not capable (yet) of combining multiple filters with 'and' and 'or'.
if (!showOverdue) {
params.filterBy.push('due_date')
params.filterValue.push(from)
params.filterComparator.push('greater')
}
}
tasks.value = await store.dispatch('tasks/loadTasks', objectToSnakeCase(params))
}
// FIXME: this modification should happen in the store
function updateTasks(updatedTask: TaskModel) {
for (const t in tasks.value) {
if (tasks.value[t].id === updatedTask.id) {
tasks.value[t] = updatedTask
// Move the task to the end of the done tasks if it is now done
if (updatedTask.done) {
tasks.value.splice(t, 1)
tasks.value.push(updatedTask)
}
break
}
}
}
watchEffect(() => loadPendingTasks(dateFrom as string, dateTo as string))
watchEffect(() => setTitle(pageTitle.value))
</script>
<style lang="scss" scoped>
h3 {
text-align: left;
&.nothing {
text-align: center;
margin-top: 3rem;
}
:deep(.input) {
width: 190px;
vertical-align: middle;
margin: .5rem 0;
}
}
.tasks {
padding: .5rem;
.show-tasks-options {
display: flex;
flex-direction: column;
}
.llama-cool {
margin-top: 2rem;
margin: 3rem auto 0;
display: block;
}
</style>

View File

@ -1,20 +0,0 @@
<template>
<div class="content has-text-centered">
<ShowTasks
:end-date="endDate"
:start-date="startDate"
/>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import ShowTasks from './ShowTasks.vue'
function getNextWeekDate() {
return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
}
const startDate = ref(new Date())
const endDate = ref(getNextWeekDate())
</script>

View File

@ -246,11 +246,11 @@
<!-- Comments -->
<comments :can-write="canWrite" :task-id="taskId"/>
</div>
<div class="column is-one-third action-buttons" v-if="canWrite || shouldShowClosePopup">
<BaseButton @click="$router.back()" class="is-fullwidth is-block has-text-centered mb-4 has-text-primary" v-if="shouldShowClosePopup">
<div class="column is-one-third action-buttons">
<a @click="$router.back()" class="is-fullwidth is-block has-text-centered mb-4" v-if="shouldShowClosePopup">
<icon icon="arrow-left"/>
{{ $t('task.detail.closePopup') }}
</BaseButton>
</a>
<template v-if="canWrite">
<x-button
:class="{'is-success': !task.done}"
@ -386,11 +386,33 @@
</template>
<!-- Created / Updated [by] -->
<created-updated :task="task"/>
<p class="created">
<time :datetime="formatISO(task.created)" v-tooltip="formatDate(task.created)">
<i18n-t keypath="task.detail.created">
<span>{{ formatDateSince(task.created) }}</span>
{{ task.createdBy.getDisplayName() }}
</i18n-t>
</time>
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
<br/>
<!-- Computed properties to show the actual date every time it gets updated -->
<time :datetime="formatISO(task.updated)" v-tooltip="updatedFormatted">
<i18n-t keypath="task.detail.updated">
<span>{{ updatedSince }}</span>
</i18n-t>
</time>
</template>
<template v-if="task.done">
<br/>
<time :datetime="formatISO(task.doneAt)" v-tooltip="doneFormatted">
<i18n-t keypath="task.detail.doneAt">
<span>{{ doneSince }}</span>
</i18n-t>
</time>
</template>
</p>
</div>
</div>
<!-- Created / Updated [by] -->
<created-updated :task="task" v-if="!canWrite && !shouldShowClosePopup"/>
</div>
<transition name="modal">
@ -431,22 +453,18 @@ import description from '@/components/tasks/partials/description.vue'
import ColorPicker from '../../components/input/colorPicker'
import heading from '@/components/tasks/partials/heading.vue'
import Datepicker from '@/components/input/datepicker.vue'
import BaseButton from '@/components/base/BaseButton'
import {playPop} from '@/helpers/playPop'
import TaskSubscription from '@/components/misc/subscription.vue'
import {CURRENT_LIST} from '@/store/mutation-types'
import {uploadFile} from '@/helpers/attachments'
import ChecklistSummary from '../../components/tasks/partials/checklist-summary'
import CreatedUpdated from '@/components/tasks/partials/createdUpdated'
export default {
name: 'TaskDetailView',
compatConfig: { ATTR_FALSE_VALUE: false },
components: {
BaseButton,
CreatedUpdated,
ChecklistSummary,
TaskSubscription,
Datepicker,
@ -542,6 +560,18 @@ export default {
canWrite() {
return typeof this.task !== 'undefined' && typeof this.task.maxRight !== 'undefined' && this.task.maxRight > rights.READ
},
updatedSince() {
return this.formatDateSince(this.task.updated)
},
updatedFormatted() {
return this.formatDate(this.task.updated)
},
doneSince() {
return this.formatDateSince(this.task.doneAt)
},
doneFormatted() {
return this.formatDate(this.task.doneAt)
},
hasAttachments() {
return this.$store.state.attachments.attachments.length > 0
},
@ -693,7 +723,7 @@ $flash-background-duration: 750ms;
.subtitle {
color: var(--grey-500);
margin-bottom: 1rem;
margin-bottom: 1rem;
a {
color: var(--grey-800);
@ -721,15 +751,15 @@ $flash-background-duration: 750ms;
.title {
margin-bottom: 0;
}
}
.title.input {
// 1.8rem is the font-size, 1.125 is the line-height, .3rem padding everywhere, 1px border around the whole thing.
min-height: calc(1.8rem * 1.125 + .6rem + 2px);
.title.input {
// 1.8rem is the font-size, 1.125 is the line-height, .3rem padding everywhere, 1px border around the whole thing.
min-height: calc(1.8rem * 1.125 + .6rem + 2px);
@media screen and (max-width: $tablet) {
margin: 0 -.3rem .5rem -.3rem; // the title has 0.3rem padding - this make the text inside of it align with the rest
}
@media screen and (max-width: $tablet) {
margin: 0 -.3rem .5rem -.3rem; // the title has 0.3rem padding - this make the text inside of it align with the rest
}
}
.title.task-id {
@ -793,7 +823,7 @@ $flash-background-duration: 750ms;
}
&.labels-list,
.assignees {
.assignees {
:deep(.multiselect) {
.input-wrapper {
&:not(:focus-within):not(:hover) {
@ -878,7 +908,7 @@ $flash-background-duration: 750ms;
padding-bottom: 1rem;
@media screen and (max-width: $desktop) {
padding-bottom: 0;
padding-bottom: 0;
}
.task-view * {
@ -905,7 +935,7 @@ $flash-background-duration: 750ms;
}
.flash-background-enter-from,
.flash-background-enter-active {
.flash-background-enter-active {
animation: flash-background $flash-background-duration ease 1;
}

View File

@ -58,12 +58,6 @@
/>
</div>
</div>
<div class="field">
<label class="label">
<input type="checkbox" v-model="rememberMe" class="mr-1"/>
{{ $t('user.auth.remember') }}
</label>
</div>
<x-button
@click="submit"
@ -124,7 +118,6 @@ export default {
usernameValid: true,
password: '',
validatePasswordInitially: false,
rememberMe: false,
}
},
beforeMount() {
@ -204,7 +197,6 @@ export default {
const credentials = {
username: this.$refs.username.value,
password: this.password,
longToken: this.rememberMe,
}
if (credentials.username === '' || credentials.password === '') {

View File

@ -92,9 +92,9 @@
</div>
<div class="field">
<label class="is-flex is-align-items-center">
<span>
{{ $t('user.settings.appearance.title') }}
</span>
<span>
{{ $t('user.settings.appearance.title') }}
</span>
<div class="select ml-2">
<select v-model="activeColorSchemeSetting">
<!-- TODO: use the Vikunja logo in color scheme as option buttons -->
@ -105,20 +105,6 @@
</div>
</label>
</div>
<div class="field">
<label class="is-flex is-align-items-center">
<span>
{{ $t('user.settings.general.timezone') }}
</span>
<div class="select ml-2">
<select v-model="settings.timezone">
<option v-for="tz in availableTimezones" :key="tz">
{{ tz }}
</option>
</select>
</div>
</label>
</div>
<x-button
:loading="loading"
@ -132,7 +118,7 @@
</template>
<script>
import {computed, watch, ref} from 'vue'
import {computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {playSoundWhenDoneKey, playPop} from '@/helpers/playPop'
@ -143,7 +129,6 @@ import ListSearch from '@/components/tasks/partials/listSearch'
import {createRandomID} from '@/helpers/randomId'
import {useColorScheme} from '@/composables/useColorScheme'
import {success} from '@/message'
import {AuthenticatedHTTPFactory} from '@/http-common'
const DEFAULT_LIST_ID = 0
@ -170,18 +155,6 @@ function useColorSchemeSetting() {
}
}
function useAvailableTimezones() {
const availableTimezones = ref([])
const HTTP = AuthenticatedHTTPFactory()
HTTP.get('user/timezones')
.then(r => {
availableTimezones.value = r.data.sort()
})
return availableTimezones
}
function getPlaySoundWhenDoneSetting() {
return localStorage.getItem(playSoundWhenDoneKey) === 'true' || localStorage.getItem(playSoundWhenDoneKey) === null
}
@ -220,7 +193,6 @@ export default {
setup() {
return {
...useColorSchemeSetting(),
availableTimezones: useAvailableTimezones(),
}
},

View File

@ -13,7 +13,6 @@
"resolveJsonModule": true,
"sourceMap": true,
"baseUrl": ".",
"strictNullChecks": true,
"isolatedModules": true,
"types": [
"vite/client"

View File

@ -1,7 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import legacyFn from '@vitejs/plugin-legacy'
const {VitePWA} = require('vite-plugin-pwa')
const path = require('path')
const {visualizer} = require('rollup-plugin-visualizer')
@ -49,6 +50,7 @@ export default defineConfig({
},
},
},
reactivityTransform: true,
}),
legacy,
svgLoader({

1135
yarn.lock

File diff suppressed because it is too large Load Diff