Compare commits

..

1 Commits

Author SHA1 Message Date
e59fa41600
feat: add tests for finding the api url 2021-11-16 22:41:40 +01:00
190 changed files with 5468 additions and 6560 deletions

View File

@ -98,17 +98,8 @@ steps:
depends_on:
- dependencies
- name: typecheck
failure: ignore
image: node:16
pull: true
commands:
- yarn typecheck
depends_on:
- dependencies
- name: test-frontend
image: cypress/browsers:node16.5.0-chrome94-ff93
image: cypress/browsers:node14.17.0-chrome91-ff89
pull: true
environment:
CYPRESS_API_URL: http://api:3456/api/v1

View File

@ -9,13 +9,6 @@ All releases can be found on https://code.vikunja.io/frontend/releases.
The releases aim at the api versions which is why there are missing versions.
## [0.18.2] - 2021-11-23
### Fixed
* fix(docker): properly replace api url
* fix: edit saved filter title
## [0.18.1] - 2021-09-08
### Added

View File

@ -4,7 +4,7 @@
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.18.2-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.18.1-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js.

View File

@ -31,7 +31,7 @@ describe('Lists', () => {
cy.url()
.should('contain', '/namespaces/1/list')
cy.get('.card-header-title')
.contains('New list')
.contains('Create a new list')
cy.get('input.input')
.type('New List')
cy.get('.button')
@ -101,7 +101,7 @@ describe('Lists', () => {
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
@ -392,7 +392,7 @@ describe('Lists', () => {
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('[data-cy="setBucketLimit"]')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field a.button.is-primary')
.first()
.click()

View File

@ -15,7 +15,7 @@ describe('Namepaces', () => {
it('Should be all there', () => {
cy.visit('/namespaces')
cy.get('[data-cy="namespace-title"]')
cy.get('.namespace h1 span')
.should('contain', namespaces[0].title)
})
@ -23,14 +23,14 @@ describe('Namepaces', () => {
const newNamespaceTitle = 'New Namespace'
cy.visit('/namespaces')
cy.get('[data-cy="new-namespace"]')
.should('contain', 'New namespace')
cy.get('a.button')
.contains('Create a new namespace')
.click()
cy.url()
.should('contain', '/namespaces/new')
cy.get('.card-header-title')
.should('contain', 'New namespace')
.should('contain', 'Create a new namespace')
cy.get('input.input')
.type(newNamespaceTitle)
cy.get('.button')
@ -72,7 +72,7 @@ describe('Namepaces', () => {
cy.get('.namespace-container .menu.namespaces-lists')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
cy.get('[data-cy="namespaces-list"]')
cy.get('.content.namespaces-list')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
})
@ -89,7 +89,7 @@ describe('Namepaces', () => {
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
@ -116,30 +116,30 @@ describe('Namepaces', () => {
// Initial
cy.visit('/namespaces')
cy.get('.namespace')
cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived')
// Show archived
cy.get('[data-cy="show-archived-check"] label.check span')
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('be.checked')
cy.get('.namespace')
cy.get('.namespaces-list .namespace')
.should('contain', 'Archived')
// Don't show archived
cy.get('[data-cy="show-archived-check"] label.check span')
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
.should('be.visible')
.click()
cy.get('[data-cy="show-archived-check"] input')
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('not.be.checked')
// Second time visiting after unchecking
cy.visit('/namespaces')
cy.get('[data-cy="show-archived-check"] input')
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('not.be.checked')
cy.get('.namespace')
cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived')
})
})

View File

@ -0,0 +1,35 @@
import '../../support/authenticateUser'
const setHours = hours => {
const date = new Date()
date.setHours(hours)
cy.clock(+date)
}
describe('Home Page', () => {
it('shows the right salutation in the night', () => {
setHours(4)
cy.visit('/')
cy.get('h2').should('contain', 'Good Night')
})
it('shows the right salutation in the morning', () => {
setHours(8)
cy.visit('/')
cy.get('h2').should('contain', 'Good Morning')
})
it('shows the right salutation in the day', () => {
setHours(13)
cy.visit('/')
cy.get('h2').should('contain', 'Hi')
})
it('shows the right salutation in the night', () => {
setHours(20)
cy.visit('/')
cy.get('h2').should('contain', 'Good Evening')
})
it('shows the right salutation in the night again', () => {
setHours(23)
cy.visit('/')
cy.get('h2').should('contain', 'Good Night')
})
})

View File

@ -128,7 +128,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Mark task done!')
.contains('Done!')
.click()
cy.get('.task-view .heading .is-done')
@ -168,7 +168,7 @@ describe('Task', () => {
.click()
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.type('{selectall}New Description')
cy.get('[data-cy="saveEditor"]')
cy.get('.task-view .details.content.description .editor a')
.contains('Save')
.click()
@ -263,7 +263,8 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('[data-cy="taskDetail.assign"]')
cy.get('.task-view .action-buttons .button')
.contains('Assign this task to a user')
.click()
cy.get('.task-view .column.assignees .multiselect input')
.type(users[1].username)
@ -404,7 +405,7 @@ describe('Task', () => {
cy.get('.datepicker .datepicker-popup a')
.contains('Tomorrow')
.click()
cy.get('[data-cy="closeDatepicker"]')
cy.get('.datepicker .datepicker-popup a.button')
.contains('Confirm')
.click()

View File

@ -8,7 +8,7 @@ const testAndAssertFailed = fixture => {
cy.wait(5000) // It can take waaaayy too long to log the user in
cy.url().should('include', '/')
cy.get('div.message.danger').contains('Wrong username or password.')
cy.get('div.notification.is-danger').contains('Wrong username or password.')
}
context('Login', () => {

View File

@ -32,7 +32,7 @@ context('Registration', () => {
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})
it.only('Should fail', () => {
it('Should fail', () => {
const fixture = {
username: 'test',
password: '123456',
@ -45,6 +45,6 @@ context('Registration', () => {
cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click()
cy.get('div.message.danger').contains('A user with this username already exists.')
cy.get('div.notification.is-danger').contains('A user with this username already exists.')
})
})

View File

@ -18,7 +18,7 @@ describe('User Settings', () => {
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientY: 100})
.trigger('mouseup')
cy.get('[data-cy="uploadAvatar"]')
cy.get('a.button.is-primary')
.contains('Upload Avatar')
.click()
@ -33,7 +33,7 @@ describe('User Settings', () => {
cy.get('.general-settings .control input.input')
.first()
.type('Lorem Ipsum')
cy.get('[data-cy="saveGeneralSettings"]')
cy.get('.card.general-settings .button.is-primary')
.contains('Save')
.click()

View File

@ -49,7 +49,7 @@
inkscape:label="ink_ext_XXXXXX 1"
style="display:inline"
transform="translate(-92.67749,-674.48297)"><circle
style="fill:#196aff;fill-opacity:1;stroke:none;stroke-width:2.88757133;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
style="fill:#5974d9;fill-opacity:1;stroke:none;stroke-width:2.88757133;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
id="path920"
cx="242.67749"
cy="828.77881"

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@ -9,97 +9,91 @@
"build": "vite build && workbox copyLibraries dist/",
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
"build:dev": "vite build -m development --outDir dist-dev/",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
"lint:markup": "vue-tsc --noEmit",
"cypress:open": "cypress open",
"test:unit": "vitest run",
"test:unit": "jest",
"test:frontend": "cypress run",
"browserslist:update": "npx browserslist@latest --update-db"
},
"dependencies": {
"@github/hotkey": "1.6.1",
"@github/hotkey": "1.6.0",
"@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.16.1",
"@sentry/vue": "6.16.1",
"@types/is-touch-device": "1.0.0",
"@vue/compat": "3.2.26",
"@vueuse/core": "7.5.2",
"@vueuse/router": "7.5.3",
"bulma-css-variables": "0.9.33",
"@sentry/tracing": "6.15.0",
"@sentry/vue": "6.15.0",
"@vue/compat": "3.2.22",
"bulma": "0.9.3",
"camel-case": "4.1.2",
"codemirror": "5.65.0",
"codemirror": "5.63.3",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.28.0",
"dompurify": "2.3.4",
"date-fns": "2.25.0",
"dompurify": "2.3.3",
"easymde": "2.15.0",
"flatpickr": "4.6.9",
"flexsearch": "0.7.21",
"highlight.js": "11.4.0",
"highlight.js": "11.3.1",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"marked": "4.0.9",
"marked": "4.0.3",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"ufo": "0.7.9",
"v-tooltip": "4.0.0-beta.13",
"vue": "3.2.26",
"vue-advanced-cropper": "2.7.1",
"vue": "3.2.22",
"vue-advanced-cropper": "2.7.0",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.5",
"vue-i18n": "9.2.0-beta.26",
"vue-i18n": "9.2.0-beta.18",
"vue-router": "4.0.12",
"vuedraggable": "4.1.0",
"vuex": "4.0.2",
"workbox-precaching": "6.4.2"
"workbox-precaching": "6.4.1"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.1.0",
"@4tw/cypress-drag-drop": "2.0.0",
"@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.9.0",
"@typescript-eslint/parser": "5.9.0",
"@vitejs/plugin-legacy": "1.6.4",
"@vitejs/plugin-vue": "2.0.1",
"@vue/eslint-config-typescript": "10.0.0",
"autoprefixer": "10.4.2",
"@types/jest": "27.0.2",
"@typescript-eslint/eslint-plugin": "5.4.0",
"@typescript-eslint/parser": "5.4.0",
"@vitejs/plugin-legacy": "1.6.2",
"@vitejs/plugin-vue": "1.9.4",
"@vue/eslint-config-typescript": "9.1.0",
"autoprefixer": "10.4.0",
"axios": "0.24.0",
"browserslist": "4.19.1",
"caniuse-lite": "1.0.30001298",
"cypress": "9.2.0",
"browserslist": "4.18.1",
"cypress": "9.0.0",
"cypress-file-upload": "5.0.8",
"esbuild": "0.14.10",
"eslint": "8.6.0",
"eslint-plugin-vue": "8.2.0",
"express": "4.17.2",
"esbuild": "0.13.14",
"eslint": "8.2.0",
"eslint-plugin-vue": "8.0.3",
"express": "4.17.1",
"faker": "5.5.3",
"netlify-cli": "8.6.15",
"happy-dom": "2.25.1",
"postcss": "8.4.5",
"postcss-preset-env": "7.2.0",
"rollup": "2.63.0",
"jest": "27.3.1",
"netlify-cli": "6.14.25",
"postcss": "8.3.11",
"rollup": "2.60.0",
"rollup-plugin-visualizer": "5.5.2",
"sass": "1.47.0",
"slugify": "1.6.5",
"typescript": "4.5.4",
"vite": "2.7.10",
"vite-plugin-pwa": "0.11.12",
"vite-svg-loader": "3.1.1",
"vitest": "0.0.139",
"vue-tsc": "0.30.2",
"sass": "1.43.4",
"slugify": "1.6.2",
"ts-jest": "27.0.7",
"typescript": "4.4.4",
"vite": "2.6.14",
"vite-plugin-pwa": "0.11.5",
"vite-svg-loader": "3.1.0",
"vue-tsc": "0.29.5",
"wait-on": "6.0.0",
"workbox-cli": "6.4.2"
"workbox-cli": "6.4.1"
},
"eslintConfig": {
"root": true,
"env": {
"browser": true,
"es2021": true,
"node": true,
"vue/setup-compiler-macros": true
"node": true
},
"extends": [
"eslint:recommended",
@ -123,7 +117,6 @@
"error",
"never"
],
"vue/script-setup-uses-vars": "error",
"vue/multi-word-component-names": 0
},
"parser": "vue-eslint-parser",
@ -134,16 +127,30 @@
"ignorePatterns": [
"*.test.*",
"cypress/*"
],
"globals": {
"defineProps": "readonly"
}
]
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"license": "AGPL-3.0-or-later",
"packageManager": "yarn@1.22.17"
"jest": {
"testPathIgnorePatterns": [
"cypress"
],
"testEnvironment": "jsdom",
"preset": "ts-jest",
"roots": [
"<rootDir>/src"
],
"transform": {
"^.+\\.(js|tsx?)$": "ts-jest"
},
"moduleFileExtensions": [
"ts",
"js",
"json"
]
},
"license": "AGPL-3.0-or-later"
}

2
run.sh
View File

@ -4,7 +4,7 @@
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://7e684483a06a4225b3e05cc47cae7a11@sentry.kolaente.de/2"}"
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"

View File

@ -1,92 +1,112 @@
<template>
<ready>
<template v-if="authUser">
<top-navigation/>
<content-auth/>
</template>
<content-link-share v-else-if="authLinkShare"/>
<no-auth-wrapper v-else>
<router-view/>
</no-auth-wrapper>
<Notification/>
<div :class="{'is-touch': isTouch}">
<div :class="{'is-hidden': !online}">
<template v-if="authUser">
<top-navigation/>
<content-auth/>
</template>
<content-link-share v-else-if="authLinkShare"/>
<content-no-auth v-else/>
<notification/>
</div>
<transition name="fade">
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
</transition>
<transition name="fade">
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
</transition>
</div>
</ready>
</template>
<script lang="ts" setup>
import {computed, watch, Ref} from 'vue'
import {useRouter} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
<script>
import {defineComponent} from 'vue'
import {mapState, mapGetters} from 'vuex'
import isTouchDevice from 'is-touch-device'
import {success} from '@/message'
import Notification from '@/components/misc/notification.vue'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.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'
import Ready from '@/components/misc/ready.vue'
import Notification from './components/misc/notification'
import {KEYBOARD_SHORTCUTS_ACTIVE, ONLINE} from './store/mutation-types'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts'
import TopNavigation from './components/home/topNavigation'
import ContentAuth from './components/home/contentAuth'
import ContentLinkShare from './components/home/contentLinkShare'
import ContentNoAuth from './components/home/contentNoAuth'
import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete'
import Ready from '@/components/misc/ready'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
export default defineComponent({
name: 'app',
components: {
ContentNoAuth,
ContentLinkShare,
ContentAuth,
TopNavigation,
KeyboardShortcuts,
Notification,
Ready,
},
beforeMount() {
this.setupOnlineStatus()
this.setupPasswortResetRedirect()
this.setupEmailVerificationRedirect()
this.setupAccountDeletionVerification()
},
beforeCreate() {
setLanguage()
},
created() {
// Make sure to always load the home route when running with electron
if (this.$route.fullPath.endsWith('frontend/index.html')) {
this.$router.push({name: 'home'})
}
},
computed: {
isTouch() {
return isTouchDevice()
},
...mapState({
online: ONLINE,
keyboardShortcutsActive: KEYBOARD_SHORTCUTS_ACTIVE,
}),
...mapGetters('auth', [
'authUser',
'authLinkShare',
]),
},
methods: {
setupOnlineStatus() {
this.$store.commit(ONLINE, navigator.onLine)
window.addEventListener('online', () => this.$store.commit(ONLINE, navigator.onLine))
window.addEventListener('offline', () => this.$store.commit(ONLINE, navigator.onLine))
},
setupPasswortResetRedirect() {
if (typeof this.$route.query.userPasswordReset === 'undefined') {
return
}
const store = useStore()
const router = useRouter()
localStorage.setItem('passwordResetToken', this.$route.query.userPasswordReset)
this.$router.push({name: 'user.password-reset.reset'})
},
setupEmailVerificationRedirect() {
if (typeof this.$route.query.userEmailConfirm === 'undefined') {
return
}
useBodyClass('is-touch', isTouchDevice)
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
localStorage.setItem('emailConfirmToken', this.$route.query.userEmailConfirm)
this.$router.push({name: 'user.login'})
},
async setupAccountDeletionVerification() {
if (typeof this.$route.query.accountDeletionConfirm === 'undefined') {
return
}
const authUser = computed(() => store.getters['auth/authUser'])
const authLinkShare = computed(() => store.getters['auth/authLinkShare'])
const {t} = useI18n()
// setup account deletion verification
const accountDeletionConfirm = useRouteQuery('accountDeletionConfirm') as Ref<null | string>
watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
if (accountDeletionConfirm === null) {
return
}
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
success({message: t('user.deletion.confirmSuccess')})
store.dispatch('auth/refreshUserInfo')
}, { immediate: true })
// setup passwort reset redirect
const userPasswordReset = useRouteQuery('userPasswordReset') as Ref<null | string>
watch(userPasswordReset, (userPasswordReset) => {
if (userPasswordReset === null) {
return
}
localStorage.setItem('passwordResetToken', userPasswordReset)
router.push({name: 'user.password-reset.reset'})
}, { immediate: true })
// setup email verification redirect
const userEmailConfirm = useRouteQuery('userEmailConfirm') as Ref<null | string>
watch(userEmailConfirm, (userEmailConfirm) => {
if (userEmailConfirm === null) {
return
}
localStorage.setItem('emailConfirmToken', userEmailConfirm)
router.push({name: 'user.login'})
}, { immediate: true })
setLanguage()
useColorScheme()
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(this.$route.query.accountDeletionConfirm)
this.$message.success({message: this.$t('user.deletion.confirmSuccess')})
this.$store.dispatch('auth/refreshUserInfo')
},
},
})
</script>
<style lang="scss">

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

View File

@ -1,118 +0,0 @@
<template>
<component
:is="componentNodeName"
class="base-button"
:class="{ 'base-button--type-button': isButton }"
v-bind="elementBindings"
:disabled="disabled || undefined"
>
<slot />
</component>
</template>
<script lang="ts">
// see https://v3.vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script
export default {
inheritAttrs: false,
}
</script>
<script lang="ts" setup>
// this component removes styling differences between links / vue-router links and button elements
// by doing so we make it easy abstract the functionality from style and enable easier and semantic
// correct button and link usage. Also see: https://css-tricks.com/a-complete-guide-to-links-and-buttons/#accessibility-considerations
// the component tries to heuristically determine what it should be checking the props (see the
// componentNodeName and elementBindings ref for this).
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead!
import { ref, watchEffect, computed, useAttrs, PropType } from 'vue'
const BASE_BUTTON_TYPES_MAP = Object.freeze({
button: 'button',
submit: 'submit',
})
type BaseButtonTypes = keyof typeof BASE_BUTTON_TYPES_MAP
const props = defineProps({
type: {
type: String as PropType<BaseButtonTypes>,
default: 'button',
},
disabled: {
type: Boolean,
default: false,
},
})
const componentNodeName = ref<Node['nodeName']>('button')
interface ElementBindings {
type?: string;
rel?: string,
}
const elementBindings = ref({})
const attrs = useAttrs()
watchEffect(() => {
// by default this component is a button element with the attribute of the type "button" (default prop value)
let nodeName = 'button'
let bindings: ElementBindings = {type: props.type}
// if we find a "to" prop we set it as router-link
if ('to' in attrs) {
nodeName = 'router-link'
bindings = {}
}
// if there is a href we assume the user wants an external link via a link element
// 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: 'noopener'}
}
componentNodeName.value = nodeName
elementBindings.value = {
...bindings,
...attrs,
}
})
const isButton = computed(() => componentNodeName.value === 'button')
</script>
<style lang="scss">
// NOTE: we do not use scoped styles to reduce specifity and make it easy to overwrite
// We reset the default styles of a button element to enable easier styling
:where(.base-button--type-button) {
border: 0;
margin: 0;
padding: 0;
text-decoration: none;
background-color: transparent;
text-align: center;
appearance: none;
}
:where(.base-button) {
cursor: pointer;
display: block;
color: inherit;
font: inherit;
user-select: none;
pointer-events: auto; // disable possible resets
&:focus {
outline: transparent;
}
&[disabled] {
cursor: default;
}
}
</style>

View File

@ -7,11 +7,5 @@ const Logo = computed(() => new Date().getMonth() === 5 ? LogoFullPride : LogoFu
</script>
<template>
<Logo alt="Vikunja" class="logo" />
</template>
<style lang="scss" scoped>
.logo {
color: var(--logo-text-color);
}
</style>
<Logo alt="Vikunja" />
</template>

View File

@ -43,7 +43,7 @@ $size: $lineWidth + 1rem;
width: $lineWidth;
left: 50%;
transform: $transformX;
background-color: var(--grey-400);
background-color: $grey-400;
border-radius: 2px;
transition: all $transition;
}
@ -62,7 +62,7 @@ $size: $lineWidth + 1rem;
&:focus {
&::before,
&::after {
background-color: var(--grey-600);
background-color: $grey-600;
}
&::before {

View File

@ -10,7 +10,7 @@ import {POWERED_BY as poweredByUrl} from '@/urls'
<style lang="scss">
.menu-bottom-link {
color: var(--grey-300);
color: $grey-300;
text-align: center;
display: block;
padding-top: 1rem;

View File

@ -40,92 +40,96 @@
</div>
</template>
<script lang="ts" setup>
import {watch, computed} from 'vue'
import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
import Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue'
const store = useStore()
export default {
name: 'contentAuth',
components: {QuickActions, Navigation},
watch: {
'$route': {
handler: 'doStuffAfterRoute',
deep: true,
},
},
created() {
this.renewTokenOnFocus()
this.loadLabels()
},
computed: mapState({
background: 'background',
menuActive: MENU_ACTIVE,
userInfo: state => state.auth.info,
authenticated: state => state.auth.authenticated,
}),
methods: {
doStuffAfterRoute() {
// this.setTitle('') // Reset the title if the page component does not set one itself
this.hideMenuOnMobile()
this.resetCurrentList()
},
resetCurrentList() {
// Reset the current list highlight in menu if the current list is not list related.
if (
this.$route.name === 'home' ||
this.$route.name === 'namespace.edit' ||
this.$route.name === 'teams.index' ||
this.$route.name === 'teams.edit' ||
this.$route.name === 'tasks.range' ||
this.$route.name === 'labels.index' ||
this.$route.name === 'migrate.start' ||
this.$route.name === 'migrate.wunderlist' ||
this.$route.name === 'user.settings' ||
this.$route.name === 'namespaces.index'
) {
return this.$store.dispatch(CURRENT_LIST, null)
}
},
renewTokenOnFocus() {
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
this.$store.dispatch('auth/renewToken')
const background = computed(() => store.state.background)
const menuActive = computed(() => store.state.menuActive)
// Check if the token is still valid if the window gets focus again to maybe renew it
window.addEventListener('focus', () => {
function showKeyboardShortcuts() {
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
if (!this.authenticated) {
return
}
const expiresIn = (this.userInfo !== null ? this.userInfo.exp : 0) - +new Date() / 1000
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn < 0) {
this.$store.dispatch('auth/checkAuth')
this.$router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < 60 * 3600) {
this.$store.dispatch('auth/renewToken')
console.debug('renewed token')
}
})
},
hideMenuOnMobile() {
if (window.innerWidth < 769) {
this.$store.commit(MENU_ACTIVE, false)
}
},
showKeyboardShortcuts() {
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
},
loadLabels() {
this.$store.dispatch('labels/loadAllLabels')
},
},
}
const route = useRoute()
// hide menu on mobile
watch(() => route.fullPath, () => window.innerWidth < 769 && store.commit(MENU_ACTIVE, false))
// FIXME: this is really error prone
// Reset the current list highlight in menu if the current route is not list related.
watch(() => route.name as string, (routeName) => {
if (
routeName &&
(
[
'home',
'namespace.edit',
'teams.index',
'teams.edit',
'tasks.range',
'labels.index',
'migrate.start',
'migrate.wunderlist',
'namespaces.index',
].includes(routeName) ||
routeName.startsWith('user.settings')
)
) {
store.dispatch(CURRENT_LIST, null)
}
})
// TODO: Reset the title if the page component does not set one itself
function useRenewTokenOnFocus() {
const router = useRouter()
const userInfo = computed(() => store.state.auth.info)
const authenticated = computed(() => store.state.auth.authenticated)
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
store.dispatch('auth/renewToken')
// Check if the token is still valid if the window gets focus again to maybe renew it
useEventListener('focus', () => {
if (!authenticated.value) {
return
}
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn < 0) {
store.dispatch('auth/checkAuth')
router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < 60 * 3600) {
store.dispatch('auth/renewToken')
console.debug('renewed token')
}
})
}
useRenewTokenOnFocus()
store.dispatch('labels/loadAllLabels')
</script>
<style lang="scss" scoped>
@ -140,7 +144,7 @@ store.dispatch('labels/loadAllLabels')
justify-content: center;
align-items: center;
font-size: 2rem;
color: var(--grey-400);
color: $grey-400;
line-height: 1;
transition: all $transition;
@ -151,7 +155,7 @@ store.dispatch('labels/loadAllLabels')
&:hover,
&:focus {
height: 1rem;
color: var(--grey-600);
color: $grey-600;
}
}
@ -187,7 +191,7 @@ store.dispatch('labels/loadAllLabels')
}
.card {
background: var(--white);
background: $white;
}
}
}
@ -216,7 +220,7 @@ store.dispatch('labels/loadAllLabels')
right: 1rem;
z-index: 4500; // The modal has a z-index of 4000
color: var(--grey-500);
color: $grey-500;
transition: color $transition;
@media screen and (max-width: $tablet) {

View File

@ -1,6 +1,6 @@
<template>
<div
:class="[background ? 'has-background' : '', $route.name as string +'-view']"
:class="[background ? 'has-background' : '', $route.name+'-view']"
:style="{'background-image': `url(${background})`}"
class="link-share-container"
>
@ -21,16 +21,23 @@
</div>
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import {useStore} from 'vuex'
<script>
import {mapState} from 'vuex'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
const store = useStore()
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
export default {
name: 'contentLinkShare',
components: {
Logo,
PoweredByLink,
},
computed: mapState([
'currentList',
'background',
]),
}
</script>
<style lang="scss" scoped>
@ -50,11 +57,11 @@ const background = computed(() => store.state.background)
}
.title {
text-shadow: 0 0 1rem var(--white);
text-shadow: 0 0 1rem $white;
}
// FIXME: this should be defined somewhere deep
.link-share-view .card {
background-color: var(--white);
background-color: $white;
}
</style>

View File

@ -0,0 +1,47 @@
<template>
<no-auth-wrapper>
<router-view/>
</no-auth-wrapper>
</template>
<script>
import {saveLastVisited} from '@/helpers/saveLastVisited'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
export default {
name: 'contentNoAuth',
components: {NoAuthWrapper},
computed: {
routeName() {
return this.$route.name
},
},
watch: {
routeName: {
handler(routeName) {
if (!routeName) return
this.redirectToHome()
},
immediate: true,
},
},
methods: {
redirectToHome() {
// Check if the user is already logged in and redirect them to the home page if not
if (
this.$route.name !== 'user.login' &&
this.$route.name !== 'user.password-reset.request' &&
this.$route.name !== 'user.password-reset.reset' &&
this.$route.name !== 'user.register' &&
this.$route.name !== 'link-share.auth' &&
this.$route.name !== 'openid.auth' &&
localStorage.getItem('passwordResetToken') === null &&
localStorage.getItem('emailConfirmToken') === null
) {
saveLastVisited(this.$route.name, this.$route.params)
this.$router.push({name: 'user.login'})
}
},
},
}
</script>

View File

@ -280,8 +280,8 @@ export default {
<style lang="scss" scoped>
$navbar-padding: 2rem;
$vikunja-nav-background: var(--site-background);
$vikunja-nav-color: var(--grey-700);
$vikunja-nav-background: $light-background;
$vikunja-nav-color: $grey-700;
$vikunja-nav-selected-width: 0.4rem;
.namespace-container {
@ -349,12 +349,12 @@ $vikunja-nav-selected-width: 0.4rem;
opacity: 0;
&:hover {
color: var(--warning);
color: $orange;
}
&.is-favorite {
opacity: 1;
color: var(--warning);
color: $orange;
}
}
@ -436,7 +436,7 @@ $vikunja-nav-selected-width: 0.4rem;
align-items: center;
&:hover {
background: var(--white);
background: $white;
}
:deep(.dropdown-trigger) {
@ -456,7 +456,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
.ghost {
background: var(--grey-200);
background: $grey-200;
* {
opacity: 0;
@ -496,16 +496,16 @@ $vikunja-nav-selected-width: 0.4rem;
}
&.router-link-exact-active {
color: var(--primary);
border-left: $vikunja-nav-selected-width solid var(--primary);
color: $primary;
border-left: $vikunja-nav-selected-width solid $primary;
.icon {
color: var(--primary);
color: $primary;
}
}
&:hover {
border-left: $vikunja-nav-selected-width solid var(--primary);
border-left: $vikunja-nav-selected-width solid $primary;
}
}
}
@ -526,7 +526,7 @@ $vikunja-nav-selected-width: 0.4rem;
}
.icon {
color: var(--grey-400) !important;
color: $grey-400 !important;
}
}
@ -555,8 +555,4 @@ $vikunja-nav-selected-width: 0.4rem;
width: 32px;
flex-shrink: 0;
}
.namespaces-list.loader-container.is-loading {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
}
</style>

View File

@ -5,7 +5,7 @@
class="navbar main-theme is-fixed-top"
role="navigation"
>
<router-link :to="{name: 'home'}" class="logo-link">
<router-link :to="{name: 'home'}" class="navbar-item logo">
<Logo width="164" height="48"/>
</router-link>
<MenuButton class="menu-button"/>
@ -37,7 +37,7 @@
<dropdown class="is-right" ref="usernameDropdown">
<template #trigger>
<x-button
variant="secondary"
type="secondary"
:shadow="false">
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
<span class="icon is-small">
@ -137,18 +137,13 @@ export default {
<style lang="scss" scoped>
$vikunja-nav-logo-full-width: 164px;
$user-dropdown-width-mobile: 5rem;
$hamburger-menu-icon-spacing: 1rem;
$hamburger-menu-icon-width: 28px;
.navbar {
z-index: 4 !important;
}
.logo-link {
.logo {
display: none;
padding: 0.5rem 0.75rem;
@media screen and (min-width: $tablet) {
align-self: stretch;
@ -169,7 +164,7 @@ $hamburger-menu-icon-width: 28px;
}
.navbar.main-theme {
background: var(--site-background);
background: $light-background;
z-index: 5 !important;
justify-content: space-between;
align-items: center;
@ -224,7 +219,7 @@ $hamburger-menu-icon-width: 28px;
:deep() {
.trigger-button {
cursor: pointer;
color: var(--grey-400);
color: $grey-400;
padding: .5rem;
font-size: 1.25rem;
position: relative;
@ -283,7 +278,7 @@ $hamburger-menu-icon-width: 28px;
}
:deep(.dropdown-trigger) {
color: var(--grey-400);
color: $grey-400;
margin-left: 1rem;
height: 1rem;
width: 1rem;

View File

@ -57,7 +57,7 @@ export default {
padding: 0 0 0 .5rem;
border-radius: $radius;
font-size: .9rem;
color: var(--grey-900);
color: $grey-900;
justify-content: space-between;
@media screen and (max-width: $desktop) {

View File

@ -1,64 +1,79 @@
<template>
<BaseButton
<a
class="button"
:class="[
variantClass,
{
'is-loading': loading,
'has-no-shadow': !shadow || variant === 'tertiary',
}
]"
:class="{
'is-loading': loading,
'has-no-shadow': !shadow,
'is-primary': type === 'primary',
'is-outlined': type === 'secondary',
'is-text is-inverted has-no-shadow underline-none':
type === 'tertary',
}"
:disabled="disabled || null"
@click="click"
:href="href !== '' ? href : null"
>
<icon :icon="icon" v-if="showIconOnly"/>
<span class="icon is-small" v-else-if="icon !== ''">
<icon :icon="icon"/>
</span>
<slot />
</BaseButton>
<slot></slot>
</a>
</template>
<script lang="ts">
<script>
export default {
name: 'x-button',
props: {
type: {
type: String,
default: 'primary',
},
href: {
type: String,
default: '',
},
to: {
default: false,
},
icon: {
default: '',
},
loading: {
type: Boolean,
default: false,
},
shadow: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['click'],
computed: {
showIconOnly() {
return this.icon !== '' && typeof this.$slots.default === 'undefined'
},
},
methods: {
click(e) {
if (this.disabled) {
return
}
if (this.to !== false) {
this.$router.push(this.to)
}
this.$emit('click', e)
},
},
}
</script>
<script setup lang="ts">
import {computed, useSlots, PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
const BUTTON_TYPES_MAP = Object.freeze({
primary: 'is-primary',
secondary: 'is-outlined',
tertiary: 'is-text is-inverted underline-none',
})
type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
const props = defineProps({
variant: {
type: String as PropType<ButtonTypes>,
default: 'primary',
},
icon: {
default: '',
},
loading: {
type: Boolean,
default: false,
},
shadow: {
type: Boolean,
default: true,
},
})
const variantClass = computed(() => BUTTON_TYPES_MAP[props.variant])
const slots = useSlots()
const showIconOnly = computed(() => props.icon !== '' && typeof slots.default === 'undefined')
</script>
<style lang="scss" scoped>
.button {
transition: all $transition;
@ -67,11 +82,11 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
font-size: 0.85rem;
font-weight: bold;
height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
box-shadow: $shadow-sm;
&.is-hovered,
&:hover {
box-shadow: var(--shadow-md);
box-shadow: $shadow-md;
}
&.fullheight {
@ -84,17 +99,16 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
box-shadow: $shadow-xs !important;
}
&.is-primary.is-outlined:hover {
color: var(--white);
color: $white;
}
}
.is-small {
border-radius: $radius;
&.is-small {
border-radius: $radius;
}
}
.underline-none {

View File

@ -27,7 +27,7 @@
@click="reset"
class="is-small ml-2"
:shadow="false"
variant="secondary"
type="secondary"
>
{{ $t('input.resetColor') }}
</x-button>
@ -134,7 +134,7 @@ export default {
height: $PICKER_SIZE;
overflow: hidden;
border-radius: 100%;
border: $BORDER_WIDTH solid var(--grey-300);
border: $BORDER_WIDTH solid $grey-300;
box-shadow: $shadow;
& > * {

View File

@ -101,7 +101,6 @@
class="is-fullwidth"
:shadow="false"
@click="close"
v-cy="'closeDatepicker'"
>
{{ $t('misc.confirm') }}
</x-button>
@ -259,7 +258,7 @@ export default {
position: absolute;
z-index: 99;
width: 320px;
background: var(--white);
background: $white;
border-radius: $radius;
box-shadow: $shadow;
@ -273,7 +272,7 @@ export default {
padding: 0 .5rem;
width: 100%;
height: 2.25rem;
color: var(--text);
color: $text;
transition: all $transition;
&:first-child {
@ -281,7 +280,7 @@ export default {
}
&:hover {
background: var(--light);
background: $light;
}
.text {
@ -292,7 +291,7 @@ export default {
padding-right: .25rem;
.weekday {
color: var(--text-light);
color: $text-light;
text-transform: capitalize;
}
}

View File

@ -35,7 +35,7 @@
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
</li>
</ul>
<x-button v-else-if="isEditActive" @click="toggleEdit" variant="secondary" :shadow="false" v-cy="'saveEditor'">
<x-button v-else-if="isEditActive" @click="toggleEdit" type="secondary" :shadow="false">
{{ $t('misc.save') }}
</x-button>
</template>
@ -338,20 +338,15 @@ $editor-border-color: #ddd;
.CodeMirror {
padding: .5rem;
border: 1px solid $editor-border-color;
background: var(--white);
&-lines pre {
margin: 0 !important;
}
&-placeholder {
color: var(--grey-400) !important;
color: $grey-400 !important;
font-style: italic;
}
&-cursor {
border-color: var(--grey-700);
}
}
.editor-preview {
@ -388,7 +383,7 @@ $editor-border-color: #ddd;
pre.CodeMirror-line {
margin-bottom: 0 !important;
color: var(--grey-700) !important;
color: $grey-700 !important;
}
.cm-header {
@ -414,10 +409,10 @@ ul.actions {
}
&, a {
color: var(--grey-500);
color: $grey-500;
&.done-edit {
color: var(--primary);
color: $primary;
}
}

View File

@ -106,7 +106,7 @@ svg {
}
.check:hover svg {
stroke: var(--primary);
stroke: $primary;
}
.is-disabled .check:hover svg {
@ -125,7 +125,7 @@ polyline {
input[type=checkbox]:checked + .check {
svg {
stroke: var(--primary);
stroke: $primary;
}
path {

View File

@ -380,23 +380,23 @@ export default {
&.has-search-results .input-wrapper {
border-radius: $radius $radius 0 0;
border-color: var(--primary) !important;
background: var(--white) !important;
border-color: $primary !important;
background: $white !important;
&, &:focus-within {
border-bottom-color: var(--grey-200) !important;
border-bottom-color: $grey-200 !important;
}
}
.input-wrapper {
padding: 0;
background: var(--white) !important;
border-color: var(--grey-200) !important;
background: $white !important;
border-color: $grey-200 !important;
flex-wrap: wrap;
height: auto;
&:hover {
border-color: var(--grey-300) !important;
border-color: $grey-300 !important;
}
.input {
@ -422,8 +422,8 @@ export default {
}
&:focus-within {
border-color: var(--primary) !important;
background: var(--white) !important;
border-color: $primary !important;
background: $white !important;
}
.loader {
@ -432,9 +432,9 @@ export default {
}
.search-results {
background: var(--white);
background: $white;
border-radius: 0 0 $radius $radius;
border: 1px solid var(--primary);
border: 1px solid $primary;
border-top: none;
max-height: 50vh;
@ -457,7 +457,7 @@ export default {
text-transform: none;
font-family: $family-sans-serif;
font-weight: normal;
padding: .5rem;
padding: .5rem 0;
border: none;
cursor: pointer;
@ -477,20 +477,20 @@ export default {
font-size: .75rem;
color: transparent;
transition: color $transition;
padding-left: .5rem;
padding: 0 .5rem;
}
&:focus, &:hover {
background: var(--grey-100);
background: $grey-100;
box-shadow: none !important;
.hint-text {
color: var(--text);
color: $text;
}
}
&:active {
background: var(--grey-200);
background: $grey-200;
}
}
}

View File

@ -1,7 +1,7 @@
<template>
<x-button
v-if="hasFilters"
variant="secondary"
type="secondary"
@click="clearFilters"
>
{{ $t('filters.clear') }}
@ -10,7 +10,7 @@
<template #trigger="{toggle}">
<x-button
@click.prevent.stop="toggle()"
variant="secondary"
type="secondary"
icon="filter"
>
{{ $t('filters.title') }}

View File

@ -1,24 +1,23 @@
<template>
<card class="filters has-overflow">
<fancycheckbox v-model="params.filter_include_nulls">
{{ $t('filters.attributes.includeNulls') }}
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@change="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<div class="field">
<fancycheckbox v-model="params.filter_include_nulls">
{{ $t('filters.attributes.includeNulls') }}
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@change="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
<label class="label">
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!$route.name.includes('list.kanban') || !$route.name.includes('list.table')"
v-model="sortAlphabetically"
>
{{ $t('filters.attributes.sortAlphabetically') }}
</fancycheckbox>
</label>
<div class="control">
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('misc.search') }}</label>
@ -191,7 +190,6 @@ import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {objectToSnakeCase} from '@/helpers/case'
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = {
@ -222,8 +220,6 @@ const DEFAULT_FILTERS = {
namespace: '',
}
export const ALPHABETICAL_SORT = 'title'
export default {
name: 'filters',
components: {
@ -276,18 +272,6 @@ export default {
},
},
computed: {
sortAlphabetically: {
get() {
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
this.params.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
this.change()
},
},
foundLabels() {
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
},

View File

@ -20,61 +20,64 @@
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
@click.stop="toggleFavoriteList(list)"
class="favorite">
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
<icon icon="star" v-if="list.isFavorite"/>
<icon :icon="['far', 'star']" v-else/>
</span>
</div>
<div class="title">{{ list.title }}</div>
</router-link>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useStore} from 'vuex'
<script>
import ListService from '@/services/list'
import {colorIsDark} from '@/helpers/color/colorIsDark'
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
const props = defineProps({
list: {
type: Object,
required: true,
export default {
name: 'list-card',
data() {
return {
background: null,
backgroundLoading: false,
}
},
showArchived: {
default: false,
type: Boolean,
props: {
list: {
required: true,
},
showArchived: {
default: false,
type: Boolean,
},
},
})
watch: {
list: {
handler: 'loadBackground',
immediate: true,
},
},
methods: {
async loadBackground() {
if (this.list === null || !this.list.backgroundInformation || this.backgroundLoading) {
return
}
watch(props.list, loadBackground, { immediate: true })
this.backgroundLoading = true
async function loadBackground() {
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
return
}
backgroundLoading.value = true
const listService = new ListService()
try {
background.value = await listService.background(props.list)
} finally {
backgroundLoading.value = false
}
}
const store = useStore()
function toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
store.dispatch('lists/toggleListFavorite', list)
const listService = new ListService()
try {
this.background = await listService.background(this.list)
} finally {
this.backgroundLoading = false
}
},
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
},
},
}
</script>
@ -83,11 +86,11 @@ function toggleFavoriteList(list) {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height;
background: var(--white);
background: $white;
margin: 0 $list-spacing $list-spacing 0;
padding: 1rem;
border-radius: $radius;
box-shadow: var(--shadow-sm);
box-shadow: $shadow-sm;
transition: box-shadow $transition;
display: flex;
@ -95,13 +98,13 @@ function toggleFavoriteList(list) {
flex-wrap: wrap;
&:hover {
box-shadow: var(--shadow-md);
box-shadow: $shadow-md;
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
box-shadow: $shadow-xs !important;
}
@media screen and (min-width: $widescreen) {
@ -155,7 +158,7 @@ function toggleFavoriteList(list) {
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
color: var(--text);
color: $text;
width: 100%;
margin-bottom: 0;
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
@ -168,7 +171,7 @@ function toggleFavoriteList(list) {
}
&.has-light-text .title {
color: var(--light);
color: $light;
}
&.has-background {
@ -177,8 +180,8 @@ function toggleFavoriteList(list) {
background-position: center;
.title {
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
color: var(--white);
text-shadow: 0 0 10px $black, 1px 1px 5px $grey-700, -1px -1px 5px $grey-700;
color: $white;
}
}
@ -187,7 +190,7 @@ function toggleFavoriteList(list) {
opacity: 0;
&:hover {
color: var(--warning);
color: $orange;
}
&.is-archived {
@ -197,7 +200,7 @@ function toggleFavoriteList(list) {
&.is-favorite {
display: inline-block;
opacity: 1;
color: var(--warning);
color: $orange;
}
}

View File

@ -1,34 +1,47 @@
<template>
<div
v-if="isDone"
class="is-done"
:class="{ 'is-done--small': variant === 'small' }"
>
{{ $t('task.attributes.done') }}
</div>
v-if="isDone"
class="is-done"
:class="{ 'is-done--small': variant === variants.SMALL }"
>
{{ $t('task.attributes.done') }}
</div>
</template>
<script lang="ts" setup>
import {PropType} from 'vue'
type Variants = 'default' | 'small'
<script>
const VARIANTS = {
DEFAULT: 'default',
SMALL: 'small',
}
defineProps({
isDone: {
type: Boolean,
default: false,
export default {
name: 'Done',
data() {
return {
variants: VARIANTS,
}
},
variant: {
type: String as PropType<Variants>,
default: 'default',
props: {
isDone: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: VARIANTS.DEFAULT,
validator: (variant) => Object.values(VARIANTS).includes(variant),
},
},
})
}
</script>
<style lang="scss" scoped>
.is-done {
background: var(--success);
color: var(--white);
background: $green;
color: $white;
padding: .5rem;
font-weight: bold;
line-height: 1;

View File

@ -23,32 +23,34 @@
</div>
</div>
<div class="api-url-info" v-else>
<i18n-t keypath="apiConfig.use">
<i18n-t keypath="apiConfig.signInOn">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
</i18n-t>
<br/>
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a>
</div>
<message variant="success" v-if="successMsg !== '' && errorMsg === ''" class="mt-2">
<div
class="notification is-success mt-2"
v-if="successMsg !== '' && errorMsg === ''"
>
{{ successMsg }}
</message>
<message variant="danger" v-if="errorMsg !== '' && successMsg === ''" class="mt-2">
</div>
<div
class="notification is-danger mt-2"
v-if="errorMsg !== '' && successMsg === ''"
>
{{ errorMsg }}
</message>
</div>
</div>
</template>
<script>
import Message from '@/components/misc/message'
import {parseURL} from 'ufo'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
export default {
name: 'apiConfig',
components: {
Message,
},
data() {
return {
configureApi: false,
@ -101,7 +103,7 @@ export default {
// 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.successMsg = this.$t('apiConfig.success', {domain: this.apiDomain})
this.configureApi = false
this.apiUrl = url
this.$emit('foundApi', this.apiUrl)
@ -126,6 +128,6 @@ export default {
}
.url {
border-bottom: 1px dashed var(--primary);
border-bottom: 1px dashed $primary;
}
</style>

View File

@ -24,59 +24,61 @@
</div>
</template>
<script setup lang="ts">
defineProps({
title: {
type: String,
default: '',
<script>
export default {
name: 'card',
props: {
title: {
type: String,
default: '',
},
padding: {
type: Boolean,
default: true,
},
hasClose: {
type: Boolean,
default: false,
},
closeIcon: {
type: String,
default: 'times',
},
shadow: {
type: Boolean,
default: true,
},
hasContent: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
},
padding: {
type: Boolean,
default: true,
},
hasClose: {
type: Boolean,
default: false,
},
closeIcon: {
type: String,
default: 'times',
},
shadow: {
type: Boolean,
default: true,
},
hasContent: {
type: Boolean,
default: true,
},
loading: {
type: Boolean,
default: false,
},
})
defineEmits(['close'])
emits: ['close'],
}
</script>
<style lang="scss" scoped>
.card {
background-color: var(--white);
background-color: $white;
border-radius: $radius;
margin-bottom: 1rem;
border: 1px solid var(--card-border-color);
box-shadow: var(--shadow-sm);
border: 1px solid $grey-200;
box-shadow: $shadow-sm;
}
.card-header {
box-shadow: none;
border-bottom: 1px solid var(--card-border-color);
border-bottom: 1px solid $grey-200;
border-radius: $radius $radius 0 0;
}
// FIXME: should maybe be merged somehow with modal
:deep(.modal-card-foot) {
background-color: var(--grey-50);
background-color: $grey-50;
border-top: 0;
}
</style>

View File

@ -14,25 +14,25 @@
</div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
<x-button
v-if="tertiary !== ''"
:shadow="false"
variant="tertiary"
@click.prevent.stop="$emit('tertiary')"
type="tertary"
@click.prevent.stop="$emit('tertary')"
v-if="tertary !== ''"
>
{{ tertiary }}
{{ tertary }}
</x-button>
<x-button
variant="secondary"
type="secondary"
@click.prevent.stop="$router.back()"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
v-if="primaryLabel !== ''"
variant="primary"
type="primary"
@click.prevent.stop="primary"
:icon="primaryIcon"
:disabled="primaryDisabled"
v-if="primaryLabel !== ''"
>
{{ primaryLabel }}
</x-button>
@ -65,7 +65,7 @@ export default {
type: Boolean,
default: false,
},
tertiary: {
tertary: {
type: String,
default: '',
},
@ -78,7 +78,7 @@ export default {
default: false,
},
},
emits: ['create', 'primary', 'tertiary'],
emits: ['create', 'primary', 'tertary'],
methods: {
primary() {
this.$emit('create')

View File

@ -11,15 +11,18 @@
</router-link>
</template>
<script lang="ts" setup>
defineProps({
to: {
required: true,
<script>
export default {
name: 'dropdown-item',
props: {
to: {
required: true,
},
icon: {
type: String,
required: false,
default: '',
},
},
icon: {
type: String,
required: false,
default: '',
},
})
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<div class="dropdown is-right is-active" ref="dropdown">
<div class="dropdown-trigger is-flex" @click="open = !open">
<div class="dropdown-trigger" @click="open = !open">
<slot name="trigger" :close="close">
<icon :icon="triggerIcon" class="icon"/>
</slot>

View File

@ -1,16 +1,19 @@
<template>
<message variant="danger">
<div class="notification is-danger">
<i18n-t keypath="loadingError.failed">
<a @click="reload">{{ $t('loadingError.tryAgain') }}</a>
<a href="https://vikunja.io/contact/" rel="noreferrer noopener nofollow" target="_blank">{{ $t('loadingError.contact') }}</a>
</i18n-t>
</message>
</div>
</template>
<script lang="ts" setup>
import Message from '@/components/misc/message.vue'
function reload() {
window.location.reload()
<script>
export default {
name: 'error',
methods: {
reload() {
window.location.reload()
},
},
}
</script>

View File

@ -4,11 +4,13 @@
<template v-for="(s, i) in shortcuts" :key="i">
<h3>{{ $t(s.title) }}</h3>
<message>
{{
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages')
}}
</message>
<div class="message is-primary">
<div class="message-body">
{{
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages')
}}
</div>
</div>
<dl class="shortcut-list">
<template v-for="(sc, si) in s.shortcuts" :key="si">
@ -28,15 +30,11 @@
<script>
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
import Shortcut from '@/components/misc/shortcut.vue'
import Message from '@/components/misc/message'
import {KEYBOARD_SHORTCUTS} from './shortcuts'
export default {
name: 'keyboard-shortcuts',
components: {
Message,
Shortcut,
},
components: {Shortcut},
data() {
return {
shortcuts: KEYBOARD_SHORTCUTS,

View File

@ -6,21 +6,23 @@
</div>
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import {useStore} from 'vuex'
<script>
import {mapState} from 'vuex'
const store = useStore()
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
export default {
name: 'legal',
computed: mapState({
imprintUrl: state => state.config.legal.imprintUrl,
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
}),
}
</script>
<style lang="scss" scoped>
.legal-links {
margin-top: 1rem;
text-align: right;
color: var(--grey-300);
color: $grey-300;
font-size: 1rem;
}
</style>

View File

@ -2,6 +2,12 @@
<div class="loader-container is-loading"></div>
</template>
<script>
export default {
name: 'loading',
}
</script>
<style scoped>
.loader-container {
height: 100%;

View File

@ -1,48 +0,0 @@
<template>
<div class="message-wrapper">
<div class="message" :class="variant">
<slot/>
</div>
</div>
</template>
<script lang="ts" setup>
defineProps({
variant: {
type: String,
default: 'info',
},
})
</script>
<style lang="scss" scoped>
.message-wrapper {
border-radius: $radius;
background: var(--white);
}
.message {
padding: .75rem 1rem;
border-radius: $radius;
}
.info {
border: 1px solid var(--primary);
background: hsla(var(--primary-hsl), .05);
}
.danger {
border: 1px solid var(--danger);
background: hsla(var(--danger-h), var(--danger-s), var(--danger-l), .05);
}
.warning {
border: 1px solid var(--warning);
background: hsla(var(--warning-h), var(--warning-s), var(--warning-l), .05);
}
.success {
border: 1px solid var(--success);
background: hsla(var(--success-h), var(--success-s), var(--success-l), .05);
}
</style>

View File

@ -1,134 +1,39 @@
<template>
<div class="no-auth-wrapper">
<Logo class="logo" width="200" height="58"/>
<div class="noauth-container">
<section class="image" :class="{'has-message': motd !== ''}">
<Message v-if="motd !== ''">
{{ motd }}
</Message>
<h2 class="image-title">
{{ $t('misc.welcomeBack') }}
</h2>
</section>
<section class="content">
<div>
<h2 class="title" v-if="title">{{ title }}</h2>
<api-config/>
<slot/>
<Logo width="400" height="117" />
<div class="message is-info" v-if="motd !== ''">
<div class="message-header">
<p>{{ $t('misc.info') }}</p>
</div>
<legal/>
</section>
<div class="message-body">
{{ motd }}
</div>
</div>
<slot/>
</div>
</div>
</template>
<script lang="ts" setup>
<script setup>
import Logo from '@/components/home/Logo.vue'
import Message from '@/components/misc/message.vue'
import Legal from '@/components/misc/legal.vue'
import ApiConfig from '@/components/misc/api-config.vue'
import {useStore} from 'vuex'
import {computed} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
const route = useRoute()
const store = useStore()
const {t} = useI18n()
const motd = computed(() => store.state.config.motd)
// @ts-ignore
const title = computed(() => t(route.meta.title ?? ''))
useTitle(() => title.value)
</script>
<style lang="scss" scoped>
.no-auth-wrapper {
background: var(--site-background) url('@/assets/llama.svg?url') no-repeat fixed bottom left;
background: url('@/assets/llama.svg') no-repeat bottom left fixed $light-background;
min-height: 100vh;
display: flex;
flex-direction: column;
place-items: center;
@media screen and (max-width: $fullhd) {
padding-bottom: 15rem;
}
}
.noauth-container {
max-width: $desktop;
max-width: 450px;
width: 100%;
min-height: 60vh;
display: flex;
background-color: var(--white);
box-shadow: var(--shadow-md);
overflow: hidden;
@media screen and (min-width: $desktop) {
border-radius: $radius;
}
}
.image {
width: 50%;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
@media screen and (max-width: $tablet) {
display: none;
}
@media screen and (min-width: $tablet) {
background: url('@/assets/no-auth-image.jpg') no-repeat bottom/cover;
position: relative;
&.has-message {
justify-content: space-between;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, .2);
}
> * {
position: relative;
}
}
}
.content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 2rem 2rem 1.5rem;
@media screen and (max-width: $desktop) {
width: 100%;
max-width: 450px;
margin-inline: auto;
}
@media screen and (min-width: $desktop) {
width: 50%;
}
}
.logo {
max-width: 100%;
margin: 1rem 0;
}
.image-title {
color: var(--white);
font-size: 2.5rem;
}
</style>

View File

@ -26,7 +26,7 @@
@click="action.callback"
:shadow="false"
class="is-small"
variant="secondary"
type="secondary"
v-for="(action, i) in item.data.actions"
>
{{ action.title }}

View File

@ -21,11 +21,11 @@
<li :key="`page-${i}`" v-for="(p, i) in pages">
<span class="pagination-ellipsis" v-if="p.isEllipsis">&hellip;</span>
<router-link
v-else
class="pagination-link"
:aria-label="'Goto page ' + p.number"
:class="{ 'is-current': p.number === currentPage }"
:to="getRouteForPagination(p.number)"
class="pagination-link"
v-else
>
{{ p.number }}
</router-link>
@ -34,10 +34,8 @@
</nav>
</template>
<script lang="ts" setup>
import {computed} from 'vue'
function createPagination(totalPages: number, currentPage: number) {
<script>
function createPagination(totalPages, currentPage) {
const pages = []
for (let i = 0; i < totalPages; i++) {
@ -81,30 +79,42 @@ function getRouteForPagination(page = 1, type = 'list') {
}
}
const props = defineProps({
totalPages: {
type: Number,
required: true,
},
currentPage: {
type: Number,
default: 0,
},
})
export default {
name: 'Pagination',
const pages = computed(() => createPagination(props.totalPages, props.currentPage))
props: {
totalPages: {
type: Number,
required: true,
},
currentPage: {
type: Number,
default: 0,
},
},
computed: {
pages() {
return createPagination(this.totalPages, this.currentPage)
},
},
methods: {
getRouteForPagination,
},
}
</script>
<style lang="scss" scoped>
.pagination {
padding-bottom: 1rem;
}
.pagination-previous,
.pagination-next {
&:not(:disabled):hover {
background: $scheme-main;
cursor: pointer;
.pagination-previous,
.pagination-next {
&:not(:disabled):hover {
background: $scheme-main;
cursor: pointer;
}
}
}
</style>

View File

@ -13,10 +13,10 @@
<section v-else-if="error !== ''">
<no-auth-wrapper>
<card>
<p v-if="error === ERROR_NO_API_URL">
<p v-if="error === errorNoApiUrl">
{{ $t('ready.noApiUrlConfigured') }}
</p>
<message variant="danger" v-else>
<div class="notification is-danger" v-else>
<p>
{{ $t('ready.errorOccured') }}<br/>
{{ error }}
@ -24,7 +24,7 @@
<p>
{{ $t('ready.checkApiUrl') }}
</p>
</message>
</div>
<api-config :configure-open="true" @found-api="load"/>
</card>
</no-auth-wrapper>
@ -40,35 +40,49 @@
</transition>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
<script>
import Logo from '@/assets/logo.svg?component'
import ApiConfig from '@/components/misc/api-config.vue'
import Message from '@/components/misc/message.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import ApiConfig from '@/components/misc/api-config'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
import {mapState} from 'vuex'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
import {useOnline} from '@/composables/useOnline'
const store = useStore()
const ready = computed(() => store.state.vikunjaReady)
const online = useOnline()
const error = ref('')
const showLoading = computed(() => !ready.value && error.value === '')
async function load() {
try {
await store.dispatch('loadApp')
} catch(e: any) {
error.value = e
}
export default {
name: 'ready',
components: {
Logo,
NoAuthWrapper,
ApiConfig,
},
data() {
return {
error: '',
errorNoApiUrl: ERROR_NO_API_URL,
}
},
created() {
this.load()
},
computed: {
ready() {
return this.$store.state.vikunjaReady
},
showLoading() {
return !this.ready && this.error === ''
},
...mapState([
'online',
]),
},
methods: {
load() {
this.$store.dispatch('loadApp')
.catch(e => {
this.error = e
})
},
},
}
load()
</script>
<style lang="scss" scoped>
@ -84,7 +98,7 @@ load()
left: 0;
bottom: 0;
right: 0;
background: var(--grey-100);
background: $grey-100;
z-index: 99;
}
@ -98,8 +112,8 @@ load()
margin-right: 1rem;
&.is-loading::after {
border-left-color: var(--grey-400);
border-bottom-color: var(--grey-400);
border-left-color: $grey-400;
border-bottom-color: $grey-400;
}
}

View File

@ -7,21 +7,24 @@
</component>
</template>
<script lang="ts" setup>
defineProps({
keys: {
type: Array,
required: true,
<script>
export default {
name: 'shortcut',
props: {
keys: {
type: Array,
required: true,
},
combination: {
type: String,
default: '+',
},
is: {
type: String,
default: 'div',
},
},
combination: {
type: String,
default: '+',
},
is: {
type: String,
default: 'div',
},
})
}
</script>
<style lang="scss" scoped>
@ -32,8 +35,8 @@ defineProps({
kbd {
padding: .1rem .35rem;
border: 1px solid var(--grey-300);
background: var(--grey-100);
border: 1px solid $grey-300;
background: $grey-100;
border-radius: 3px;
font-size: .75rem;
}

View File

@ -1,6 +1,6 @@
<template>
<x-button
variant="secondary"
type="secondary"
:icon="icon"
v-tooltip="tooltipText"
@click="changeSubscription"
@ -22,89 +22,91 @@
</a>
</template>
<script lang="ts" setup>
import {computed, shallowRef} from 'vue'
import {useI18n} from 'vue-i18n'
<script>
import SubscriptionService from '@/services/subscription'
import SubscriptionModel from '@/models/subscription'
import {success} from '@/message'
const props = defineProps({
entity: {
required: true,
type: String,
export default {
name: 'task-subscription',
data() {
return {
subscriptionService: new SubscriptionService(),
}
},
subscription: {
required: true,
props: {
entity: {
required: true,
type: String,
},
subscription: {
required: true,
},
entityId: {
required: true,
},
isButton: {
type: Boolean,
default: true,
},
},
entityId: {
required: true,
emits: ['change'],
computed: {
tooltipText() {
if (this.disabled) {
return this.$t('task.subscription.subscribedThroughParent', {
entity: this.entity,
parent: this.subscription.entity,
})
}
return this.subscription !== null ?
this.$t('task.subscription.subscribed', {entity: this.entity}) :
this.$t('task.subscription.notSubscribed', {entity: this.entity})
},
buttonText() {
return this.subscription !== null ? this.$t('task.subscription.unsubscribe') : this.$t('task.subscription.subscribe')
},
icon() {
return this.subscription !== null ? ['far', 'bell-slash'] : 'bell'
},
disabled() {
if (this.subscription === null) {
return false
}
return this.subscription.entity !== this.entity
},
},
isButton: {
type: Boolean,
default: true,
methods: {
changeSubscription() {
if (this.disabled) {
return
}
if (this.subscription === null) {
this.subscribe()
} else {
this.unsubscribe()
}
},
async subscribe() {
const subscription = new SubscriptionModel({
entity: this.entity,
entityId: this.entityId,
})
await this.subscriptionService.create(subscription)
this.$emit('change', subscription)
this.$message.success({message: this.$t('task.subscription.subscribeSuccess', {entity: this.entity})})
},
async unsubscribe() {
const subscription = new SubscriptionModel({
entity: this.entity,
entityId: this.entityId,
})
await this.subscriptionService.delete(subscription)
this.$emit('change', null)
this.$message.success({message: this.$t('task.subscription.unsubscribeSuccess', {entity: this.entity})})
},
},
})
const emit = defineEmits(['change'])
const subscriptionService = shallowRef(new SubscriptionService())
const {t} = useI18n()
const tooltipText = computed(() => {
if (disabled.value) {
return t('task.subscription.subscribedThroughParent', {
entity: props.entity,
parent: props.subscription.entity,
})
}
return props.subscription !== null ?
t('task.subscription.subscribed', {entity: props.entity}) :
t('task.subscription.notSubscribed', {entity: props.entity})
})
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
const icon = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
const disabled = computed(() => {
if (props.subscription === null) {
return false
}
return props.subscription.entity !== props.entity
})
function changeSubscription() {
if (disabled.value) {
return
}
if (props.subscription === null) {
subscribe()
} else {
unsubscribe()
}
}
async function subscribe() {
const subscription = new SubscriptionModel({
entity: props.entity,
entityId: props.entityId,
})
await subscriptionService.value.create(subscription)
emit('change', subscription)
success({message: t('task.subscription.subscribeSuccess', {entity: props.entity})})
}
async function unsubscribe() {
const subscription = new SubscriptionModel({
entity: props.entity,
entityId: props.entityId,
})
await subscriptionService.value.delete(subscription)
emit('change', null)
success({message: t('task.subscription.unsubscribeSuccess', {entity: props.entity})})
}
</script>

View File

@ -11,28 +11,31 @@
</div>
</template>
<script lang="ts" setup>
defineProps({
user: {
required: true,
type: Object,
<script>
export default {
name: 'user',
props: {
user: {
required: true,
type: Object,
},
showUsername: {
required: false,
type: Boolean,
default: true,
},
avatarSize: {
required: false,
type: Number,
default: 50,
},
isInline: {
required: false,
type: Boolean,
default: false,
},
},
showUsername: {
required: false,
type: Boolean,
default: true,
},
avatarSize: {
required: false,
type: Number,
default: 50,
},
isInline: {
required: false,
type: Boolean,
default: false,
},
})
}
</script>
<style lang="scss" scoped>

View File

@ -31,15 +31,14 @@
<div class="actions">
<x-button
@click="$emit('close')"
variant="tertiary"
type="tertary"
class="has-text-danger"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
@click="$emit('submit')"
variant="primary"
v-cy="'modalPrimary'"
type="primary"
:shadow="false"
>
{{ $t('misc.doit') }}
@ -194,6 +193,10 @@ export default {
align-items: center;
}
.message-body {
padding: .5rem .75rem;
}
}
}

View File

@ -9,23 +9,32 @@
/>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
<script>
import Multiselect from '@/components/input/multiselect.vue'
const emit = defineEmits(['selected'])
const query = ref('')
const store = useStore()
const namespaces = computed(() => store.getters['namespaces/searchNamespace'](query.value))
function findNamespaces(newQuery: string) {
query.value = newQuery
}
function select(namespace) {
emit('selected', namespace)
export default {
name: 'namespace-search',
emits: ['selected'],
data() {
return {
query: '',
}
},
components: {
Multiselect,
},
computed: {
namespaces() {
return this.$store.getters['namespaces/searchNamespace'](this.query)
},
},
methods: {
findNamespaces(query) {
this.query = query
},
select(namespace) {
this.$emit('selected', namespace)
},
},
}
</script>

View File

@ -52,22 +52,30 @@
</dropdown>
</template>
<script setup lang="ts">
import {ref, onMounted} from 'vue'
<script>
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import TaskSubscription from '@/components/misc/subscription.vue'
const props = defineProps({
namespace: {
type: Object, // NamespaceModel
required: true,
export default {
name: 'namespace-settings-dropdown',
data() {
return {
subscription: null,
}
},
})
const subscription = ref(null)
onMounted(() => {
subscription.value = props.namespace.subscription
})
components: {
DropdownItem,
Dropdown,
TaskSubscription,
},
props: {
namespace: {
required: true,
},
},
mounted() {
this.subscription = this.namespace.subscription
},
}
</script>

View File

@ -145,9 +145,9 @@ export default {
width: .75rem;
height: .75rem;
background: var(--primary);
background: $primary;
border-radius: 100%;
border: 2px solid var(--white);
border: 2px solid $white;
}
.notifications-list {
@ -157,12 +157,12 @@ export default {
max-height: 400px;
overflow-y: auto;
background: var(--white);
background: $white;
width: 350px;
max-width: calc(100vw - 2rem);
padding: .75rem .25rem;
border-radius: $radius;
box-shadow: var(--shadow-sm);
box-shadow: $shadow-sm;
font-size: .85rem;
@media screen and (max-width: $tablet) {
@ -183,14 +183,14 @@ export default {
transition: background-color $transition;
&:hover {
background: var(--grey-100);
background: $grey-100;
border-radius: $radius;
}
.read-indicator {
width: .35rem;
height: .35rem;
background: var(--primary);
background: $primary;
border-radius: 100%;
margin-left: .5rem;
@ -219,7 +219,7 @@ export default {
}
.created {
color: var(--grey-400);
color: $grey-400;
}
&:last-child {
@ -227,14 +227,14 @@ export default {
}
a {
color: var(--grey-800);
color: $grey-800;
}
}
.nothing {
text-align: center;
padding: 1rem 0;
color: var(--grey-500);
color: $grey-500;
.explainer {
font-size: .75rem;

View File

@ -507,19 +507,17 @@ export default {
.active-cmd {
font-size: 1.25rem;
margin-left: .5rem;
background-color: var(--grey-100);
color: var(--grey-800);
}
}
.results {
text-align: left;
width: 100%;
color: var(--grey-800);
color: $grey-800;
.result {
&-title {
background: var(--grey-50);
background: $grey-50;
padding: .5rem;
display: block;
font-size: .75rem;
@ -530,7 +528,6 @@ export default {
font-size: .9rem;
width: 100%;
background: transparent;
color: var(--grey-800);
text-align: left;
box-shadow: none;
border-radius: 0;
@ -542,12 +539,12 @@ export default {
cursor: pointer;
&:focus, &:hover {
background: var(--grey-50);
background: $grey-50;
box-shadow: none !important;
}
&:active {
background: var(--grey-100);
background: $grey-100;
}
}
}

View File

@ -3,13 +3,17 @@
<div class="field is-grouped">
<p class="control has-icons-left is-expanded">
<textarea
:disabled="taskService.loading || undefined"
class="add-task-textarea input"
:disabled="taskService.loading || null"
class="input"
:placeholder="$t('list.list.addPlaceholder')"
rows="1"
cols="1"
v-focus
v-model="newTaskTitle"
ref="newTaskInput"
:style="{
'minHeight': `${initialTextAreaHeight}px`,
'height': `calc(${textAreaHeight}px - 2px + 1rem)`
}"
@keyup="errorMessage = ''"
@keydown.enter="handleEnter"
/>
@ -19,8 +23,7 @@
</p>
<p class="control">
<x-button
class="add-task-button"
:disabled="newTaskTitle === '' || taskService.loading || undefined"
:disabled="newTaskTitle === '' || taskService.loading || null"
@click="addTask()"
icon="plus"
:loading="taskService.loading"
@ -32,171 +35,121 @@
<p class="help is-danger" v-if="errorMessage !== ''">
{{ errorMessage }}
</p>
<quick-add-magic v-else />
<quick-add-magic v-if="errorMessage === ''"/>
</div>
</template>
<script setup lang="ts">
import {ref, watch, unref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from 'vuex'
import { tryOnMounted, debouncedWatch, useWindowSize, MaybeRef } from '@vueuse/core'
import TaskService from '@/services/task'
<script>
import TaskService from '../../services/task'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
function cleanupTitle(title: string) {
const INPUT_BORDER_PX = 2
const LINE_HEIGHT = 1.5 // using getComputedStyles().lineHeight returns an (wrong) absolute pixel value, we need the factor to do calculations with it.
const cleanupTitle = title => {
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
}
function useAutoHeightTextarea(value: MaybeRef<string>) {
const textarea = ref<HTMLInputElement>()
const minHeight = ref(0)
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
function resize(textareaEl: HTMLInputElement|undefined) {
if (!textareaEl) return
let empty
// the value here is the the attribute value
if (!textareaEl.value && textareaEl.placeholder) {
empty = true
textareaEl.value = textareaEl.placeholder
export default {
name: 'add-task',
emits: ['taskAdded'],
data() {
return {
newTaskTitle: '',
taskService: new TaskService(),
errorMessage: '',
textAreaHeight: null,
initialTextAreaHeight: null,
}
const cs = getComputedStyle(textareaEl)
textareaEl.style.minHeight = ''
textareaEl.style.height = '0'
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
const height = textareaEl.scrollHeight + offset + 'px'
textareaEl.style.height = height
// calculate min-height for the first time
if (!minHeight.value) {
minHeight.value = parseFloat(height)
}
textareaEl.style.minHeight = minHeight.value.toString()
if (empty) {
textareaEl.value = ''
}
}
tryOnMounted(() => {
if (textarea.value) {
// we don't want scrollbars
textarea.value.style.overflowY = 'hidden'
}
})
const { width: windowWidth } = useWindowSize()
debouncedWatch(
windowWidth,
() => resize(textarea.value),
{ debounce: 200 },
)
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
// So instead we watch the value that we bound to it.
watch(
() => [textarea.value, unref(value)],
() => resize(textarea.value),
{
immediate: true, // calculate initial size
flush: 'post', // resize after value change is rendered to DOM
},
)
return textarea
}
const props = defineProps({
defaultPosition: {
type: Number,
required: false,
},
})
components: {
QuickAddMagic,
},
props: {
defaultPosition: {
type: Number,
required: false,
},
},
watch: {
newTaskTitle(newVal) {
// Calculating the textarea height based on lines of input in it.
// That is more reliable when removing a line from the input.
const numberOfLines = newVal.split(/\r\n|\r|\n/).length
const fontSize = parseFloat(window.getComputedStyle(this.$refs.newTaskInput, null).getPropertyValue('font-size'))
const emit = defineEmits(['taskAdded'])
this.textAreaHeight = numberOfLines * fontSize * LINE_HEIGHT + INPUT_BORDER_PX
},
},
mounted() {
this.initialTextAreaHeight = this.$refs.newTaskInput.scrollHeight + INPUT_BORDER_PX
},
methods: {
async addTask() {
if (this.newTaskTitle === '') {
this.errorMessage = this.$t('list.create.addTitleRequired')
return
}
this.errorMessage = ''
const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
if (this.taskService.loading) {
return
}
const { t } = useI18n()
const store = useStore()
const newTasks = this.newTaskTitle.split(/[\r\n]+/).map(async t => {
const title = cleanupTitle(t)
if (title === '') {
return
}
const taskService = shallowReactive(new TaskService())
const errorMessage = ref('')
const task = await this.$store.dispatch('tasks/createNewTask', {
title,
listId: this.$store.state.auth.settings.defaultListId,
position: this.defaultPosition,
})
this.$emit('taskAdded', task)
return task
})
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('list.create.addTitleRequired')
return
}
errorMessage.value = ''
try {
await Promise.all(newTasks)
this.newTaskTitle = ''
} catch (e) {
if (e.message === 'NO_LIST') {
this.errorMessage = this.$t('list.create.addListRequired')
return
}
throw e
}
},
handleEnter(e) {
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
// the new task(s). The vue event modifier don't allow this, hence this method.
if (e.shiftKey) {
return
}
if (taskService.loading) {
return
}
const taskTitleBackup = newTaskTitle.value
const newTasks = newTaskTitle.value.split(/[\r\n]+/).map(async uncleanedTitle => {
const title = cleanupTitle(uncleanedTitle)
if (title === '') {
return
}
const task = await store.dispatch('tasks/createNewTask', {
title,
listId: store.state.auth.settings.defaultListId,
position: props.defaultPosition,
})
emit('taskAdded', task)
return task
})
try {
newTaskTitle.value = ''
await Promise.all(newTasks)
} catch (e: any) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_LIST') {
errorMessage.value = t('list.create.addListRequired')
return
}
throw e
}
}
function handleEnter(e: KeyboardEvent) {
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
// the new task(s). The vue event modifier don't allow this, hence this method.
if (e.shiftKey) {
return
}
e.preventDefault()
addTask()
e.preventDefault()
this.addTask()
},
},
}
</script>
<style lang="scss" scoped>
.task-add {
margin-bottom: 0;
.button {
height: 2.5rem;
}
}
.add-task-button {
height: 2.5rem;
}
.add-task-textarea {
.input, .textarea {
transition: border-color $transition;
resize: none;
}
.input {
resize: vertical;
}
</style>

View File

@ -78,6 +78,7 @@
<script>
import AsyncEditor from '@/components/input/AsyncEditor'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import priorities from '../../models/constants/priorities'
@ -89,10 +90,14 @@ export default {
name: 'edit-task',
data() {
return {
listId: this.$route.params.id,
listService: new ListService(),
taskService: new TaskService(),
priorities: priorities,
list: {},
editorActive: false,
newTask: new TaskModel(),
isTaskEdit: false,
taskEditTask: TaskModel,
}
@ -162,7 +167,7 @@ ul.assingees {
a {
float: right;
color: var(--danger);
color: $red;
transition: all $transition;
}
}

View File

@ -2,7 +2,15 @@
<div class="gantt-chart">
<div class="filter-container">
<div class="items">
<x-button
@click.prevent.stop="showTaskFilter = !showTaskFilter"
type="secondary"
icon="filter"
>
{{ $t('filters.title') }}
</x-button>
<filter-popup
:visible="showTaskFilter"
v-model="params"
@update:modelValue="loadTasks()"
/>
@ -183,8 +191,6 @@ import {mapState} from 'vuex'
import Rights from '../../models/constants/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import {colorIsDark} from '@/helpers/color/colorIsDark'
export default {
name: 'GanttChart',
components: {
@ -203,10 +209,10 @@ export default {
default: false,
},
dateFrom: {
default: () => new Date(new Date().setDate(new Date().getDate() - 15)),
default: new Date(new Date().setDate(new Date().getDate() - 15)),
},
dateTo: {
default: () => new Date(new Date().setDate(new Date().getDate() + 30)),
default: new Date(new Date().setDate(new Date().getDate() + 30)),
},
// The width of a day in pixels, used to calculate all sorts of things.
dayWidth: {
@ -231,6 +237,7 @@ export default {
newTaskFieldActive: false,
priorities: priorities,
taskCollectionService: new TaskCollectionService(),
showTaskFilter: false,
params: {
sort_by: ['done', 'id'],
@ -254,7 +261,6 @@ export default {
canWrite: (state) => state.currentList.maxRight > Rights.READ,
}),
methods: {
colorIsDark,
buildTheGanttChart() {
this.setDates()
this.prepareGanttDays()
@ -439,12 +445,12 @@ export default {
</script>
<style lang="scss" scoped>
$gantt-border: 1px solid var(--grey-200);
$gantt-vertical-border-color: var(--grey-100);
$gantt-border: 1px solid $grey-200;
$gantt-vertical-border-color: $grey-100;
.gantt-chart {
overflow-x: auto;
border-top: 1px solid var(--grey-200);
border-top: 1px solid $grey-200;
.dates {
display: flex;
@ -471,8 +477,8 @@ $gantt-vertical-border-color: var(--grey-100);
font-weight: normal;
&.today {
background: var(--primary);
color: var(--white);
background: $primary;
color: $white;
border-radius: 5px 5px 0 0;
font-weight: bold;
}
@ -494,6 +500,7 @@ $gantt-vertical-border-color: var(--grey-100);
.tasks {
max-width: unset !important;
margin: 0;
border-top: $gantt-border;
.row {
@ -501,7 +508,7 @@ $gantt-vertical-border-color: var(--grey-100);
.task {
display: inline-block;
border: 2px solid var(--primary);
border: 2px solid $primary;
font-size: 0.85rem;
margin: 0.5rem;
border-radius: 6px;
@ -514,30 +521,30 @@ $gantt-vertical-border-color: var(--grey-100);
user-select: none; // Non-prefixed version
&.is-current-edit {
border-color: var(--warning) !important;
border-color: $orange !important;
}
&.has-light-text {
color: var(--light);
color: $light;
&.done span:after {
border-top: 1px solid var(--light);
border-top: 1px solid $light;
}
.edit-toggle {
color: var(--light);
color: $light;
}
}
&.has-dark-text {
color: var(--text);
color: $text;
&.done span:after {
border-top: 1px solid var(--dark);
border-top: 1px solid $dark;
}
.edit-toggle {
color: var(--text);
color: $text;
}
}
@ -590,8 +597,8 @@ $gantt-vertical-border-color: var(--grey-100);
}
&.nodate {
border: 2px dashed var(--grey-300);
background: var(--grey-100);
border: 2px dashed $grey-300;
background: $grey-100;
}
&:active {

View File

@ -44,6 +44,7 @@ export default {
params = null,
forceLoading = false,
) {
// Because this function is triggered every time on topNavigation, we're putting a condition here to only load it when we actually want to show tasks
// FIXME: This is a bit hacky -> Cleanup.
if (

View File

@ -83,7 +83,7 @@
@click="$refs.files.click()"
class="mb-4"
icon="cloud-upload-alt"
variant="secondary"
type="secondary"
:shadow="false"
>
{{ $t('task.attachment.upload') }}
@ -267,17 +267,17 @@ export default {
padding: .5rem;
&:hover {
background-color: var(--grey-200);
background-color: $grey-200;
}
.filename {
font-weight: bold;
margin-bottom: .25rem;
color: var(--text);
color: $text;
}
.info {
color: var(--grey-500);
color: $grey-500;
font-size: .9rem;
p {
@ -339,17 +339,17 @@ export default {
width: 100%;
font-size: 5rem;
height: auto;
text-shadow: var(--shadow-md);
text-shadow: $shadow-md;
animation: bounce 2s infinite;
}
.hint {
margin: .5rem auto 2rem;
border-radius: 2px;
box-shadow: var(--shadow-md);
background: var(--primary);
box-shadow: $shadow-md;
background: $primary;
padding: 1rem;
color: var(--white);
color: $white;
width: 100%;
max-width: 300px;
}

View File

@ -39,7 +39,7 @@ export default {
<style scoped lang="scss">
.checklist-summary {
color: var(--grey-500);
color: $grey-500;
display: inline-flex;
align-items: center;
@ -49,10 +49,10 @@ export default {
margin-right: .25rem;
circle {
stroke: var(--grey-400);
stroke: $grey-400;
&:last-child {
stroke: var(--primary);
stroke: $primary;
}
}
}

View File

@ -276,7 +276,7 @@ export default {
this.commentEdit.taskId = this.taskId
try {
const comment = await this.taskCommentService.update(this.commentEdit)
const comment = this.taskCommentService.update(this.commentEdit)
for (const c in this.comments) {
if (this.comments[c].id === this.commentEdit.id) {
this.comments[c] = comment

View File

@ -8,21 +8,21 @@
<x-button
@click.prevent.stop="() => deferDays(1)"
:shadow="false"
variant="secondary"
type="secondary"
>
{{ $t('task.deferDueDate.1day') }}
</x-button>
<x-button
@click.prevent.stop="() => deferDays(3)"
:shadow="false"
variant="secondary"
type="secondary"
>
{{ $t('task.deferDueDate.3days') }}
</x-button>
<x-button
@click.prevent.stop="() => deferDays(7)"
:shadow="false"
variant="secondary"
type="secondary"
>
{{ $t('task.deferDueDate.1week') }}
</x-button>
@ -141,14 +141,14 @@ $defer-task-max-width: 350px + 100px;
width: 100%;
max-width: $defer-task-max-width;
border-radius: $radius;
border: 1px solid var(--grey-200);
border: 1px solid $grey-200;
padding: 1rem;
margin: 1rem;
background: var(--white);
color: var(--text);
background: $white;
color: $text;
cursor: default;
z-index: 10;
box-shadow: var(--shadow-lg);
box-shadow: $shadow-lg;
@media screen and (max-width: ($defer-task-max-width)) {
left: .5rem;

View File

@ -127,7 +127,7 @@ export default {
}
:deep(.user img) {
border: 2px solid var(--white);
border: 2px solid $white;
margin-right: 0;
}
@ -135,8 +135,8 @@ export default {
position: absolute;
top: 4px;
left: 2px;
color: var(--danger);
background: var(--white);
color: $red;
background: $white;
padding: 0 4px;
display: block;
border-radius: 100%;

View File

@ -7,7 +7,7 @@
:class="{'disabled': !canWrite}"
@blur="save($event.target.textContent)"
@keydown.enter.prevent.stop="$event.target.blur()"
:contenteditable="canWrite ? true : undefined"
:contenteditable="canWrite ? 'true' : 'false'"
:spellcheck="false"
>
{{ task.title.trim() }}

View File

@ -1,6 +1,5 @@
<template>
<div
class="task loader-container draggable"
:class="{
'is-loading': loadingInternal || loading,
'draggable': !(loadingInternal || loading),
@ -10,6 +9,7 @@
@click.ctrl="() => toggleTaskDone(task)"
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
@click.meta="() => toggleTaskDone(task)"
class="task loader-container draggable"
>
<span class="task-id">
<Done class="kanban-card__done" :is-done="task.done" variant="small" />
@ -73,8 +73,6 @@ import Done from '@/components/misc/Done.vue'
import Labels from '../../../components/tasks/partials/labels'
import ChecklistSummary from './checklist-summary'
import {colorIsDark} from '@/helpers/color/colorIsDark'
export default {
name: 'kanban-card',
components: {
@ -100,7 +98,6 @@ export default {
},
},
methods: {
colorIsDark,
async toggleTaskDone(task) {
this.loadingInternal = true
try {
@ -120,13 +117,13 @@ export default {
</script>
<style lang="scss" scoped>
$task-background: var(--white);
$task-background: $white;
.task {
-webkit-touch-callout: none; // iOS Safari
user-select: none;
cursor: pointer;
box-shadow: var(--shadow-xs);
box-shadow: $shadow-xs;
display: block;
border: 3px solid transparent;
@ -166,7 +163,7 @@ $task-background: var(--white);
}
&.overdue {
color: var(--danger);
color: $red;
}
}
@ -222,7 +219,7 @@ $task-background: var(--white);
.footer .icon,
.due-date,
.priority-label {
background: var(--grey-100);
background: $grey-100;
border-radius: $radius;
padding: 0 .5rem;
}
@ -232,7 +229,7 @@ $task-background: var(--white);
}
.task-id {
color: var(--grey-500);
color: $grey-500;
font-size: .8rem;
margin-bottom: .25rem;
display: flex;
@ -247,21 +244,21 @@ $task-background: var(--white);
}
&.has-light-text {
color: var(--white);
color: $white;
.task-id {
color: var(--grey-200);
color: $grey-200;
}
.footer .icon,
.due-date,
.priority-label {
background: var(--grey-800);
background: $grey-800;
}
.footer {
.icon svg {
fill: var(--white);
fill: $white;
}
}
}

View File

@ -16,54 +16,60 @@
</multiselect>
</template>
<script lang="ts" setup>
import {reactive, ref, watchEffect} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import ListModel from '@/models/list'
<script>
import ListModel from '../../../models/list'
import Multiselect from '@/components/input/multiselect.vue'
const store = useStore()
const {t} = useI18n()
const list = reactive(new ListModel())
const props = defineProps({
modelValue: {
validator(value) {
return value instanceof ListModel
},
required: false,
export default {
name: 'listSearch',
data() {
return {
list: new ListModel(),
foundLists: [],
}
},
})
const emit = defineEmits(['update:modelValue'])
props: {
modelValue: {
required: false,
},
},
emits: ['update:modelValue', 'selected'],
components: {
Multiselect,
},
watch: {
modelValue: {
handler(value) {
this.list = value
},
immeditate: true,
deep: true,
},
},
methods: {
findLists(query) {
this.foundLists = this.$store.getters['lists/searchList'](query)
},
watchEffect(() => {
Object.assign(list, props.modelValue)
})
select(list) {
this.list = list
this.$emit('selected', list)
this.$emit('update:modelValue', list)
},
const foundLists = ref([])
function findLists(query: string) {
if (query === '') {
select(null)
}
foundLists.value = store.getters['lists/searchList'](query)
}
function select(l: ListModel | null) {
Object.assign(list, l)
emit('update:modelValue', list)
}
function namespace(namespaceId: number) {
const namespace = store.getters['namespaces/getNamespaceById'](namespaceId)
return namespace !== null
? namespace.title
: t('list.shared')
namespace(namespaceId) {
const namespace = this.$store.getters['namespaces/getNamespaceById'](namespaceId)
if (namespace !== null) {
return namespace.title
}
return this.$t('list.shared')
},
},
}
</script>
<style lang="scss" scoped>
.list-namespace-title {
color: var(--grey-500);
color: $grey-500;
}
</style>

View File

@ -54,7 +54,7 @@ export default {
}
span.high-priority {
color: var(--danger);
color: $red;
width: auto !important; // To override the width set in tasks
.icon {
@ -64,7 +64,7 @@ span.high-priority {
}
&.not-so-high {
color: var(--warning);
color: $orange;
}
}
</style>

View File

@ -65,21 +65,6 @@
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Every day</li>
<li>Every 3 days</li>
<li>Every week</li>
<li>Every 2 weeks</li>
<li>Every month</li>
<li>Every 6 months</li>
<li>Every year</li>
<li>Every 2 years</li>
</ul>
</card>
</modal>
</div>

View File

@ -6,7 +6,7 @@
class="is-pulled-right add-task-relation-button"
:class="{'is-active': showNewRelationForm}"
v-tooltip="$t('task.relation.add')"
variant="secondary"
type="secondary"
icon="plus"
:shadow="false"
/>
@ -310,7 +310,7 @@ export default {
}
.different-list {
color: var(--grey-500);
color: $grey-500;
width: auto;
}
@ -319,10 +319,6 @@ export default {
margin: 0;
}
.tasks {
padding: .5rem;
}
.task {
display: flex;
flex-wrap: wrap;
@ -332,21 +328,21 @@ export default {
border-radius: $radius;
&:hover {
background-color: var(--grey-200);
background-color: $grey-200;
}
a {
color: var(--text);
color: $text;
transition: color ease $transition-duration;
&:hover {
color: var(--grey-900);
color: $grey-900;
}
}
.remove {
text-align: center;
color: var(--danger);
color: $red;
opacity: 0;
transition: opacity $transition;
}

View File

@ -112,7 +112,7 @@ export default {
align-items: center;
&.overdue :deep(.datepicker a.show) {
color: var(--danger);
color: $red;
}
&:last-child {
@ -120,7 +120,7 @@ export default {
}
a.remove {
color: var(--danger);
color: $red;
padding-left: .5rem;
}
}

View File

@ -1,9 +1,9 @@
<template>
<div class="control repeat-after-input">
<div class="buttons has-addons is-centered mt-2">
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(1, 'days')">{{ $t('task.repeat.everyDay') }}</x-button>
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(1, 'weeks')">{{ $t('task.repeat.everyWeek') }}</x-button>
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(1, 'months')">{{ $t('task.repeat.everyMonth') }}</x-button>
<x-button type="secondary" class="is-small" @click="() => setRepeatAfter(1, 'days')">{{ $t('task.repeat.everyDay') }}</x-button>
<x-button type="secondary" class="is-small" @click="() => setRepeatAfter(1, 'weeks')">{{ $t('task.repeat.everyWeek') }}</x-button>
<x-button type="secondary" class="is-small" @click="() => setRepeatAfter(1, 'months')">{{ $t('task.repeat.everyMonth') }}</x-button>
</div>
<div class="is-flex is-align-items-center mb-2">
<label for="repeatMode" class="is-fullwidth">

View File

@ -227,7 +227,7 @@ export default {
border: 2px solid transparent;
&:hover {
background-color: var(--grey-100);
background-color: $grey-100;
}
.tasktext,
@ -239,13 +239,13 @@ export default {
flex: 1 0 50%;
.overdue {
color: var(--danger);
color: $red;
}
}
.task-list {
width: auto;
color: var(--grey-400);
color: $grey-400;
font-size: .9rem;
white-space: nowrap;
}
@ -273,11 +273,11 @@ export default {
}
a {
color: var(--text);
color: $text;
transition: color ease $transition-duration;
&:hover {
color: var(--grey-900);
color: $grey-900;
}
}
@ -288,12 +288,12 @@ export default {
transition: opacity $transition, color $transition;
&:hover {
color: var(--warning);
color: $orange;
}
&.is-favorite {
opacity: 1;
color: var(--warning);
color: $orange;
}
}
@ -324,16 +324,16 @@ export default {
.tasktext.done {
text-decoration: line-through;
color: var(--grey-500);
color: $grey-500;
}
span.parent-tasks {
color: var(--grey-500);
color: $grey-500;
width: auto;
}
.remove {
color: var(--danger);
color: $red;
}
input[type="checkbox"] {
@ -351,8 +351,8 @@ export default {
left: calc(50% - 1rem);
width: 2rem;
height: 2rem;
border-left-color: var(--grey-300);
border-bottom-color: var(--grey-300);
border-left-color: $grey-300;
border-bottom-color: $grey-300;
}
}
</style>

View File

@ -1,16 +0,0 @@
import {ref, watchEffect} from 'vue'
import {tryOnBeforeUnmount} from '@vueuse/core'
export function useBodyClass(className: string, defaultValue = false) {
const isActive = ref(defaultValue)
watchEffect(() => {
isActive.value
? document.body.classList.add(className)
: document.body.classList.remove(className)
})
tryOnBeforeUnmount(() => isActive.value && document.body.classList.remove(className))
return isActive
}

View File

@ -1,48 +0,0 @@
import {computed, watch, readonly} from 'vue'
import {useStorage, createSharedComposable, ColorSchema, usePreferredColorScheme, tryOnMounted} from '@vueuse/core'
const STORAGE_KEY = 'color-scheme'
const DEFAULT_COLOR_SCHEME_SETTING: ColorSchema = 'light'
const CLASS_DARK = 'dark'
const CLASS_LIGHT = 'light'
// This is built upon the vueuse useDark
// Main differences:
// - usePreferredColorScheme
// - doesn't allow setting via the `isDark` ref.
// - instead the store is exposed
// - value is synced via `createSharedComposable`
// https://github.com/vueuse/vueuse/blob/main/packages/core/useDark/index.ts
export const useColorScheme = createSharedComposable(() => {
const store = useStorage<ColorSchema>(STORAGE_KEY, DEFAULT_COLOR_SCHEME_SETTING)
const preferredColorScheme = usePreferredColorScheme()
const isDark = computed<boolean>(() => {
if (store.value !== 'auto') {
return store.value === 'dark'
}
const autoColorScheme = preferredColorScheme.value === 'no-preference'
? DEFAULT_COLOR_SCHEME_SETTING
: preferredColorScheme.value
return autoColorScheme === 'dark'
})
function onChanged(v: boolean) {
const el = window?.document.querySelector('html')
el?.classList.toggle(CLASS_DARK, v)
el?.classList.toggle(CLASS_LIGHT, !v)
}
watch(isDark, onChanged, { flush: 'post' })
tryOnMounted(() => onChanged(isDark.value))
return {
store,
isDark: readonly(isDark),
}
})

View File

@ -1,31 +0,0 @@
import {describe, it, expect} from 'vitest'
import {hourToSalutation} from './useDateTimeSalutation'
const dateWithHour = (hours: number): Date => {
const date = new Date()
date.setHours(hours)
return date
}
describe('Salutation', () => {
it('shows the right salutation in the night', () => {
const salutation = hourToSalutation(dateWithHour(4))
expect(salutation).toBe('home.welcomeNight')
})
it('shows the right salutation in the morning', () => {
const salutation = hourToSalutation(dateWithHour(8))
expect(salutation).toBe('home.welcomeMorning')
})
it('shows the right salutation in the day', () => {
const salutation = hourToSalutation(dateWithHour(13))
expect(salutation).toBe('home.welcomeDay')
})
it('shows the right salutation in the night', () => {
const salutation = hourToSalutation(dateWithHour(20))
expect(salutation).toBe('home.welcomeEvening')
})
it('shows the right salutation in the night again', () => {
const salutation = hourToSalutation(dateWithHour(23))
expect(salutation).toBe('home.welcomeNight')
})
})

View File

@ -1,31 +0,0 @@
import {computed} from 'vue'
import {useNow} from '@vueuse/core'
const TRANSLATION_KEY_PREFIX = 'home.welcome'
export function hourToSalutation(now: Date) {
const hours = now.getHours()
if (hours < 5) {
return `${TRANSLATION_KEY_PREFIX}Night`
}
if (hours < 11) {
return `${TRANSLATION_KEY_PREFIX}Morning`
}
if (hours < 18) {
return `${TRANSLATION_KEY_PREFIX}Day`
}
if (hours < 23) {
return `${TRANSLATION_KEY_PREFIX}Evening`
}
return `${TRANSLATION_KEY_PREFIX}Night`
}
export function useDateTimeSalutation() {
const now = useNow()
return computed(() => hourToSalutation(now.value))
}

View File

@ -1,14 +0,0 @@
import {ref} from 'vue'
import {useOnline as useNetworkOnline, ConfigurableWindow} from '@vueuse/core'
export function useOnline(options?: ConfigurableWindow) {
const fakeOnlineState = !!import.meta.env.VITE_IS_ONLINE
if (fakeOnlineState) {
console.log('Setting fake online state', fakeOnlineState)
}
return fakeOnlineState
? ref(true)
: useNetworkOnline(options)
}

View File

@ -1,23 +0,0 @@
import {Directive} from 'vue'
declare global {
interface Window {
Cypress: object;
}
}
const cypressDirective: Directive = {
mounted(el, {value}) {
if (
(window.Cypress || import.meta.env.DEV) &&
value
) {
el.setAttribute('data-cy', value)
}
},
beforeUnmount(el) {
el.removeAttribute('data-cy')
},
}
export default cypressDirective

View File

@ -5,7 +5,7 @@ export default {
// auto focusing elements on mobile can be annoying since in these cases the
// keyboard always pops up and takes half of the available space on the screen.
// The threshhold is the same as the breakpoints in css.
if (window.innerWidth > 769 || modifiers?.always) {
if (window.innerWidth > 769 || (typeof modifiers.always !== 'undefined' && modifiers.always)) {
el.focus()
}
},

83
src/directives/tooltip.js Normal file
View File

@ -0,0 +1,83 @@
const calculateTop = (coords, tooltip) => {
// Bottom tooltip use the exact inverse calculation compared to the default.
if (tooltip.classList.contains('bottom')) {
return coords.top + tooltip.offsetHeight + 5
}
// The top position of the tooltip is the coordinates of the bound element - the height of the tooltip -
// 5px spacing for the arrow (which is exactly 5px high)
return coords.top - tooltip.offsetHeight - 5
}
const calculateArrowTop = (top, tooltip) => {
if (tooltip.classList.contains('bottom')) {
return `${top - 5}px` // 5px arrow height
}
return `${top + tooltip.offsetHeight}px`
}
// This global object holds all created tooltip elements (and their arrows) using the element they were created for as
// key. This allows us to find the tooltip elements if the element the tooltip was created for is unbound so that
// we can remove the tooltip element.
const createdTooltips = {}
export default {
mounted(el, {value, modifiers}) {
// First, we create the tooltip and arrow elements
const tooltip = document.createElement('div')
tooltip.style.position = 'fixed'
tooltip.innerText = value
tooltip.classList.add('tooltip')
const arrow = document.createElement('div')
arrow.classList.add('tooltip-arrow')
arrow.style.position = 'fixed'
if (typeof modifiers.bottom !== 'undefined') {
tooltip.classList.add('bottom')
arrow.classList.add('bottom')
}
// We don't append the element until hovering over it because that's the most reliable way to determine
// where the parent elemtent is located at the time the user hovers over it.
el.addEventListener('mouseover', () => {
// Appending the element right away because we can only calculate the height of the element if it is
// already in the DOM.
document.body.appendChild(tooltip)
document.body.appendChild(arrow)
const coords = el.getBoundingClientRect()
const top = calculateTop(coords, tooltip)
// The left position of the tooltip is calculated so that the middle point of the tooltip
// (where the arrow will be) is the middle of the bound element
const left = coords.left - (tooltip.offsetWidth / 2) + (el.offsetWidth / 2)
// Now setting all the values
tooltip.style.top = `${top}px`
tooltip.style.left = `${coords.left}px`
tooltip.style.left = `${left}px`
arrow.style.left = `${left + (tooltip.offsetWidth / 2) - (arrow.offsetWidth / 2)}px`
arrow.style.top = calculateArrowTop(top, tooltip)
// And finally make it visible to the user. This will also trigger a nice fade-in animation through
// css transitions
tooltip.classList.add('visible')
arrow.classList.add('visible')
})
el.addEventListener('mouseout', () => {
tooltip.classList.remove('visible')
arrow.classList.remove('visible')
})
createdTooltips[el] = {
tooltip: tooltip,
arrow: arrow,
}
},
unmounted(el) {
if (typeof createdTooltips[el] !== 'undefined') {
createdTooltips[el].tooltip.remove()
createdTooltips[el].arrow.remove()
}
},
}

View File

@ -1,5 +1,3 @@
import {it, expect} from 'vitest'
import {calculateItemPosition} from './calculateItemPosition'
it('should calculate the task position', () => {

View File

@ -0,0 +1,8 @@
describe('Find the correct api url', () => {
// TODO: To check:
// /api/v1
// / (should append api suffix)
// :3456/api/v1
// :3456/ (should append api suffix)
// + Everything with http and https -> start with the current protocol, then try the other one
})

View File

@ -1,5 +1,3 @@
import {describe, it, expect} from 'vitest'
import {findCheckboxesInText, getChecklistStatistics} from './checklistFromText'
describe('Find checklists in text', () => {
@ -23,7 +21,6 @@ Here's some text in between
expect(checkboxes[0]).toBe(0)
expect(checkboxes[1]).toBe(18)
expect(checkboxes[2]).toBe(69)
expect(checkboxes[3]).toBe(90)
})
it('should find one checkbox with *', () => {
const text: string = '* [ ] Lorem Ipsum'

View File

@ -40,7 +40,7 @@ export const findCheckboxesInText = (text: string): number[] => {
return [
...checkboxes.checked,
...checkboxes.unchecked,
].sort((a, b) => a < b ? -1 : 1)
].sort()
}
export const getChecklistStatistics = (text: string): CheckboxStatistics => {

View File

@ -1,5 +1,3 @@
import {test, expect} from 'vitest'
import {colorFromHex} from './colorFromHex'
test('hex', () => {

View File

@ -1,5 +1,3 @@
import {test, expect} from 'vitest'
import {colorIsDark} from './colorIsDark'
test('dark color', () => {

View File

@ -1,5 +1,3 @@
import {describe, it, expect} from 'vitest'
import {filterLabelsByQuery} from './labels'
import {createNewIndexer} from '../indexes'

11
src/helpers/playPop.js Normal file
View File

@ -0,0 +1,11 @@
export const playSoundWhenDoneKey = 'playSoundWhenTaskDone'
export const playPop = () => {
const enabled = localStorage.getItem(playSoundWhenDoneKey) === 'true' || localStorage.getItem(playSoundWhenDoneKey) === null
if(!enabled) {
return
}
const popSound = new Audio('/audio/pop.mp3')
popSound.play()
}

View File

@ -1,13 +0,0 @@
import popSoundFile from '@/assets/audio/pop.mp3'
export const playSoundWhenDoneKey = 'playSoundWhenTaskDone'
export function playPop() {
const enabled = Boolean(localStorage.getItem(playSoundWhenDoneKey))
if(!enabled) {
return
}
const popSound = new Audio(popSoundFile)
popSound.play()
}

View File

@ -1,5 +1,4 @@
import {createRandomID} from '@/helpers/randomId'
import {parseURL} from 'ufo'
interface Provider {
name: string
@ -8,15 +7,7 @@ interface Provider {
clientId: string
}
export const redirectToProvider = (provider: Provider, redirectUrl: string = '') => {
// We're not using the redirect url provided by the server to allow redirects when using the electron app.
// The implications are not quite clear yet hence the logic to pass in another redirect url still exists.
if (redirectUrl === '') {
const {host, protocol} = parseURL(window.location.href)
redirectUrl = `${protocol}//${host}/auth/openid/`
}
export const redirectToProvider = (provider: Provider, redirectUrl: string) => {
const state = createRandomID(24)
localStorage.setItem('state', state)

View File

@ -1,5 +1,3 @@
import {test, expect} from 'vitest'
import {calculateDayInterval} from './calculateDayInterval'
const days = {

View File

@ -1,5 +1,3 @@
import {test, expect} from 'vitest'
import {calculateNearestHours} from './calculateNearestHours'
test('5:00', () => {

View File

@ -1,5 +1,3 @@
import {test, expect} from 'vitest'
import {createDateFromString} from './createDateFromString'
test('YYYY-MM-DD HH:MM', () => {

Some files were not shown because too many files have changed in this diff Show More