Merge remote-tracking branch 'upstream/main' into bulma-css-variables
continuous-integration/drone/pr Build is failing Details

This commit is contained in:
Adrian Simmons 2021-11-15 13:31:17 +00:00
commit 41ef1eea4a
37 changed files with 1255 additions and 599 deletions

View File

@ -148,6 +148,7 @@ steps:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js
depends_on:
- build-prod
@ -655,6 +656,6 @@ steps:
from_secret: crowdin_key
---
kind: signature
hmac: 15df446c7e93a881249d46273485183386157229ee6a37b1ed0fcb2a0b32bbe2
hmac: 188ee90100c5fc5922a445e531e7a47453121edddb2a64a182eb23ed2bf602de
...

View File

@ -24,8 +24,8 @@ context('Registration', () => {
cy.visit('/register')
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
cy.get('#password1').type(fixture.password)
cy.get('#password2').type(fixture.password)
cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
@ -42,8 +42,8 @@ context('Registration', () => {
cy.visit('/register')
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
cy.get('#password1').type(fixture.password)
cy.get('#password2').type(fixture.password)
cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click()
cy.get('div.notification.is-danger').contains('A user with this username already exists.')
})

View File

@ -31,6 +31,7 @@
"dompurify": "2.3.3",
"easymde": "2.15.0",
"flatpickr": "4.6.9",
"flexsearch": "0.7.21",
"highlight.js": "11.3.1",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
@ -39,8 +40,8 @@
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"ufo": "0.7.9",
"vue": "3.2.21",
"vue-advanced-cropper": "2.6.3",
"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.18",
@ -55,6 +56,7 @@
"@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",
"@types/jest": "27.0.2",
"@typescript-eslint/eslint-plugin": "5.3.1",
"@typescript-eslint/parser": "5.3.1",
@ -63,7 +65,7 @@
"@vue/eslint-config-typescript": "9.0.1",
"autoprefixer": "10.4.0",
"axios": "0.24.0",
"browserslist": "4.17.6",
"browserslist": "4.18.0",
"cypress": "9.0.0",
"cypress-file-upload": "5.0.8",
"esbuild": "0.13.13",
@ -81,9 +83,9 @@
"ts-jest": "27.0.7",
"typescript": "4.4.4",
"vite": "2.6.14",
"vite-plugin-pwa": "0.11.3",
"vue-tsc": "0.29.4",
"vite-plugin-pwa": "0.11.5",
"vite-svg-loader": "3.1.0",
"vue-tsc": "0.29.4",
"wait-on": "6.0.0",
"workbox-cli": "6.3.0"
},

View File

@ -0,0 +1 @@
55ce0faaa2c1919341617ccfaeccbb6029ac12107964ff488985cff13dd952f1a991df3ab0d4b0705deb761e508e6434 ./scripts/deploy-preview-netlify.js

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="256" height="256">
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 256 256" width="256" height="256">
<path d="M2268.2 2512.3a953.7 953.7 0 0 1-50 57c-180.5 189.5-426.2 294-691.6 294A953.7 953.7 0 0 1 847.8 2582a952.7 952.7 0 0 1-281.2-678.8 953.8 953.8 0 0 1 281.2-678.9 953.7 953.7 0 0 1 678.8-281.1 953.7 953.7 0 0 1 678.8 281.1 953.7 953.7 0 0 1 281.2 678.9c0 219.2-78.9 437.2-218.4 609" style="fill:#196aff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
<path d="M1823.7 1650.9c35.7 104.2 94.7 136.1 102 297 2.6 56.5-14.7 236-14.7 236s28 72-25.8 152.3c-83.5 124.3-255.4 132.8-345.7 132.8-90.3 0-260.2-8.5-343.7-132.8C1142 2256 1170 2184 1170 2184s-9.5-92.4-16.7-173.8c-1.7-19.1.1-94.7 2.4-113a453 453 0 0 1 25.8-96.2c14.4-39.6 36.8-79.9 54-120.5 51.8-122.8 8.4-274.9 11.1-407.3 2.2-94-20-189.3-28.7-281.2a960.4 960.4 0 0 1 308.7-50.6 958.6 958.6 0 0 1 344.9 63.6c-20.4 115-44.1 224.2-47.8 265.9-10.6 125.9-41.3 259.4 0 380" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36655635" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
<path d="M1162.9 2383.9c1.1-18.8 3-38 8.3-56.2 1.6-5.7 4-19.7 11.4-21.8 9-2.6 25.9 8.3 32.3 13 12.3 9 23.9 18.5 36.2 27.6 8 6 16.5 10.5 24.3 16.5 8.4 6.6 14.7 14.5 21.7 22.2 8.4 9.4 14.8 19 21.3 29.5 5.1 8.2 37.1 13.5 42.2 21 5.6 8.3 1 18.6 1 28.7 0 74.2 4.4 147.6 6.1 220.3 1.8 50 21.4 109.2-53.4 85.8-160.3-50-158.5-271.3-151.4-386.6M1869.1 2279.7c-1.6 1.8-4.2 3.2-6.3 4.8a208 208 0 0 0-25.1 21.5c-9.4 9.6-19.2 19-28.2 28.9-7.9 8.7-17.3 16.6-25 25.6-5.1 6-10 12.3-14.6 18.5-2.3 3.2-3.5 7-5.3 10.4-2.7 5-40 10.1-36.2 15 6.3 8.3 20.3 15.4 23.7 25 17.2 48.6 24.8 244.5 26.8 294.5 5.4 127.8 117.6-6.3 137.2-57.7 57-149.7 23.2-258.8-46.3-386.6" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -189,6 +189,8 @@ import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {objectToSnakeCase} from '@/helpers/case'
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = {
sort_by: [],
@ -261,7 +263,9 @@ export default {
watch: {
modelValue: {
handler(value) {
this.params = value
// FIXME: filters should only be converted to snake case in
// the last moment
this.params = objectToSnakeCase(value)
this.prepareFilters()
},
immediate: true,

View File

@ -67,7 +67,7 @@ export default {
},
computed: {
apiDomain() {
return parseURL(this.apiUrl).host
return parseURL(this.apiUrl).host || parseURL(window.location.href).host
},
},
props: {

View File

@ -1,6 +1,6 @@
<template>
<modal @close="close()">
<card class="has-background-white has-no-shadow" :title="$t('keyboardShortcuts.title')">
<card class="has-background-white has-no-shadow keyboard-shortcuts" :title="$t('keyboardShortcuts.title')">
<template v-for="(s, i) in shortcuts" :key="i">
<h3>{{ $t(s.title) }}</h3>
@ -12,10 +12,11 @@
</div>
</div>
<dl>
<dl class="shortcut-list">
<template v-for="(sc, si) in s.shortcuts" :key="si">
<dt>{{ $t(sc.title) }}</dt>
<dt class="shortcut-title">{{ $t(sc.title) }}</dt>
<shortcut
class="shortcut-keys"
is="dd"
:keys="sc.keys"
:combination="typeof sc.combination !== 'undefined' ? $t(`keyboardShortcuts.${sc.combination}`) : null"/>
@ -47,8 +48,30 @@ export default {
}
</script>
<style>
dt {
font-weight: bold;
<style scoped>
.keyboard-shortcuts {
text-align: left;
}
.message:not(:last-child) {
margin-bottom: 1rem;
}
.message-body {
padding: .75rem;
}
.shortcut-list {
display: grid;
grid-template-columns: 2fr 1fr;
}
.shortcut-title {
margin-bottom: .5rem;
}
.shortcut-keys {
justify-content: end;
margin-bottom: .5rem;
}
</style>

View File

@ -89,7 +89,7 @@ export default {
},
currentPage: {
type: Number,
required: true,
default: 0,
},
},

View File

@ -31,7 +31,7 @@
</section>
<transition name="fade">
<section class="vikunja-loading" v-if="showLoading">
<img alt="Vikunja" :src="logoUrl" width="100" height="100"/>
<Logo class="logo"/>
<p>
<span class="loader-container is-loading-small is-loading"></span>
{{ $t('ready.loading') }}
@ -41,7 +41,7 @@
</template>
<script>
import logoUrl from '@/assets/logo.svg'
import Logo from '@/assets/logo.svg?component'
import ApiConfig from '@/components/misc/api-config'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
import {mapState} from 'vuex'
@ -50,12 +50,12 @@ import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
export default {
name: 'ready',
components: {
Logo,
NoAuthWrapper,
ApiConfig,
},
data() {
return {
logoUrl,
error: '',
errorNoApiUrl: ERROR_NO_API_URL,
}
@ -100,10 +100,12 @@ export default {
right: 0;
background: var(--grey-100);
z-index: 99;
}
img {
margin-bottom: 1rem;
}
.logo {
margin-bottom: 1rem;
width: 100px;
height: 100px;
}
.loader-container {

View File

@ -25,15 +25,7 @@ export default {
},
computed: {
namespaces() {
if (this.query === '') {
return []
}
return this.$store.state.namespaces.namespaces.filter(n => {
return !n.isArchived &&
n.id > 0 &&
n.title.toLowerCase().includes(this.query.toLowerCase())
})
return this.$store.getters['namespaces/searchNamespace'](this.query)
},
},
methods: {

View File

@ -110,40 +110,32 @@ export default {
results() {
let lists = []
if (this.searchMode === SEARCH_MODE_ALL || this.searchMode === SEARCH_MODE_LISTS) {
const ncache = {}
const history = getHistory()
// Puts recently visited lists at the top
const allLists = [...new Set([
...history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}),
...Object.values(this.$store.state.lists)])]
const {list} = this.parsedQuery
if (list === null) {
lists = []
} else {
const ncache = {}
const history = getHistory()
// Puts recently visited lists at the top
const allLists = [...new Set([
...history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}),
...this.$store.getters['lists/searchList'](list),
])]
lists = allLists.filter(l => {
if (typeof l === 'undefined' || l === null) {
return false
}
if (l.isArchived) {
return false
}
if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
}
if (ncache[l.namespaceId].isArchived) {
return false
}
return l.title.toLowerCase().includes(list.toLowerCase())
}) ?? []
return !ncache[l.namespaceId].isArchived
})
}
}

View File

@ -1,7 +1,6 @@
<template>
<multiselect
class="control is-expanded"
:loading="listSerivce.loading"
:placeholder="$t('list.search')"
@search="findLists"
:search-results="foundLists"
@ -18,7 +17,6 @@
</template>
<script>
import ListService from '../../../services/list'
import ListModel from '../../../models/list'
import Multiselect from '@/components/input/multiselect.vue'
@ -26,7 +24,6 @@ export default {
name: 'listSearch',
data() {
return {
listSerivce: new ListService(),
list: new ListModel(),
foundLists: [],
}
@ -50,17 +47,8 @@ export default {
},
},
methods: {
async findLists(query) {
if (query === '') {
this.clearAll()
return
}
this.foundLists = await this.listSerivce.getAll({}, {s: query})
},
clearAll() {
this.foundLists = []
findLists(query) {
this.foundLists = this.$store.getters['lists/searchList'](query)
},
select(list) {
@ -82,6 +70,10 @@ export default {
<style lang="scss" scoped>
.list-namespace-title {
<<<<<<< HEAD
color: var(--grey-500);
=======
color: $grey-500;
>>>>>>> upstream/main
}
</style>

View File

@ -0,0 +1,12 @@
import { computed, watchEffect } from 'vue'
import { setTitle } from '@/helpers/setTitle'
import { ComputedGetter, ComputedRef } from '@vue/reactivity'
export function useTitle<T>(titleGetter: ComputedGetter<T>) : ComputedRef<T> {
const titleRef = computed(titleGetter)
watchEffect(() => setTitle(titleRef.value))
return titleRef
}

View File

@ -1,20 +1,25 @@
import {filterLabelsByQuery} from './labels'
import {createNewIndexer} from '../indexes'
const {add} = createNewIndexer('labels', ['title', 'description'])
describe('filter labels', () => {
const state = {
labels: [
{id: 1, title: 'label1'},
{id: 2, title: 'label2'},
{id: 3, title: 'label3'},
{id: 4, title: 'label4'},
{id: 5, title: 'label5'},
{id: 6, title: 'label6'},
{id: 7, title: 'label7'},
{id: 8, title: 'label8'},
{id: 9, title: 'label9'},
],
labels: {
1: {id: 1, title: 'label1'},
2: {id: 2, title: 'label2'},
3: {id: 3, title: 'label3'},
4: {id: 4, title: 'label4'},
5: {id: 5, title: 'label5'},
6: {id: 6, title: 'label6'},
7: {id: 7, title: 'label7'},
8: {id: 8, title: 'label8'},
9: {id: 9, title: 'label9'},
},
}
Object.values(state.labels).forEach(add)
it('should return an empty array for an empty query', () => {
const labels = filterLabelsByQuery(state, [], '')
@ -31,7 +36,7 @@ describe('filter labels', () => {
id: number,
title: string,
}
const labelsToHide: label[] = [{id: 1, title: 'label1'}]
const labels = filterLabelsByQuery(state, labelsToHide, 'label1')

View File

@ -1,10 +1,16 @@
interface label {
import {createNewIndexer} from '../indexes'
const {search} = createNewIndexer('labels', ['title', 'description'])
export interface label {
id: number,
title: string,
}
interface labelState {
labels: label[],
labels: {
[k: number]: label,
},
}
/**
@ -15,17 +21,12 @@ interface labelState {
* @returns {Array}
*/
export function filterLabelsByQuery(state: labelState, labelsToHide: label[], query: string) {
if (query === '') {
return []
}
const labelIdsToHide: number[] = labelsToHide.map(({id}) => id)
const labelQuery = query.toLowerCase()
const labelIds = labelsToHide.map(({id}) => id)
return Object
.values(state.labels)
.filter(({id, title}) => {
return !labelIds.includes(id) && title.toLowerCase().includes(labelQuery)
})
return search(query)
?.filter(value => !labelIdsToHide.includes(value))
.map(id => state.labels[id])
|| []
}

View File

@ -1,8 +1,5 @@
export const setTitle = title => {
if (typeof title === 'undefined' || title === '') {
document.title = 'Vikunja'
return
}
document.title = `${title} | Vikunja`
export function setTitle(title) {
document.title = (typeof title === 'undefined' || title === '')
? 'Vikunja'
: `${title} | Vikunja`
}

View File

@ -17,14 +17,14 @@
"text": "Požadovaná stránka neexistuje."
},
"ready": {
"loading": "Vikunja is loading…",
"errorOccured": "An error occured:",
"checkApiUrl": "Please check if the api url is correct.",
"noApiUrlConfigured": "No API url was configured. Please set one below:"
"loading": "Vikunja se načítá…",
"errorOccured": "Došlo k chybě:",
"checkApiUrl": "Zkontrolujte, zda je adresa URL API správná.",
"noApiUrlConfigured": "Nebyla nakonfigurována žádná adresa API. Prosím nastavte jednu níže:"
},
"offline": {
"title": "You are offline.",
"text": "Please check your network connection and try again."
"title": "Jste offline.",
"text": "Zkontrolujte své internetové připojení a zkuste to znovu."
},
"user": {
"auth": {
@ -354,7 +354,7 @@
},
"filters": {
"title": "Filtry",
"clear": "Clear Filters",
"clear": "Vymazat filtry",
"attributes": {
"title": "Název",
"titlePlaceholder": "Název uloženého filtru přijde sem…",
@ -461,8 +461,8 @@
"default": "Výchozí",
"close": "Zavřít",
"download": "Stáhnout",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"showMenu": "Zobrazit nabídku",
"hideMenu": "Skrýt nabídku"
},
"input": {
"resetColor": "Obnovit barvu",
@ -656,7 +656,7 @@
"searchPlaceholder": "Hledejte nový úkol, který chcete přidat jako související…",
"createPlaceholder": "Přidat toto jako nový související úkol",
"differentList": "Tento úkol patří do jiného seznamu.",
"differentNamespace": "This task belongs to a different namespace.",
"differentNamespace": "Tento úkol patří do jiného prostoru.",
"noneYet": "Zatím žádné vztahy mezi úkoly.",
"delete": "Odstranit vztah k úloze",
"deleteText1": "Jste si jisti, že chcete odstranit tento vztah úkolu?",
@ -757,12 +757,12 @@
},
"keyboardShortcuts": {
"title": "Klávesové zkratky",
"general": "General",
"general": "Obecné",
"allPages": "Tyto zkratky fungují na všech stránkách.",
"currentPageOnly": "Tyto zkratky fungují pouze na aktuální stránce.",
"toggleMenu": "Přepnout nabídku",
"quickSearch": "Otevřít vyhledávání / panel rychlých akcí",
"then": "then",
"then": "potom",
"task": {
"title": "Stránka úkolů",
"done": "Označit úkol jako hotový",
@ -773,11 +773,11 @@
"related": "Upravit související úkoly tohoto úkolu"
},
"list": {
"title": "List Views",
"switchToListView": "Switch to list view",
"switchToGanttView": "Switch to gantt view",
"switchToKanbanView": "Switch to kanban view",
"switchToTableView": "Switch to table view"
"title": "Zobrazení seznamů",
"switchToListView": "Přepnout na zobrazení seznamu",
"switchToGanttView": "Přepnout na zobrazení gantt",
"switchToKanbanView": "Přepnout na zobrazení kanbanu",
"switchToTableView": "Přepnout na zobrazení tabulky"
}
},
"update": {
@ -799,9 +799,9 @@
"urlPlaceholder": "např. https://localhost:3456",
"change": "změnit",
"signInOn": "Přihlaste se ke svému účtu Vikunja na {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Nelze najít nebo použít instalaci Vikunja na \"{domain}\". Zkuste prosím jinou url.",
"success": "Pomocí instalace Vikunja na \"{domain}\".",
"urlRequired": "A url is required."
"urlRequired": "Je vyžadována adresa URL."
},
"loadingError": {
"failed": "Načítání selhalo, prosím {0}. Pokud chyba přetrvává, {1}.",
@ -816,7 +816,7 @@
"quickActions": {
"commands": "Příkazy",
"placeholder": "Napište příkaz nebo vyhledávání…",
"hint": "You can use {list} to limit the search to a list. Combine {list} or {label} (labels) with a search query to search for a task with these labels or on that list. Use {assignee} to only search for teams.",
"hint": "Můžete použít {list} k omezení hledání na seznam. Kombinujte {list} nebo {label} (štítky) s vyhledávacím dotazem pro hledání úkolu s těmito štítky nebo na tomto seznamu. Použijte {assignee} pouze pro hledání týmů.",
"tasks": "Úkoly",
"lists": "Seznamy",
"teams": "Týmy",

View File

@ -17,14 +17,14 @@
"text": "Trang bạn yêu cầu không tồn tại."
},
"ready": {
"loading": "Vikunja is loading…",
"errorOccured": "An error occured:",
"checkApiUrl": "Please check if the api url is correct.",
"noApiUrlConfigured": "No API url was configured. Please set one below:"
"loading": "Vikunja đang tải…",
"errorOccured": "Đã xảy ra lỗi:",
"checkApiUrl": "Vui lòng kiểm tra lại url api.",
"noApiUrlConfigured": "Không có url API nào được cấu hình. Hãy đặt một cái:"
},
"offline": {
"title": "You are offline.",
"text": "Please check your network connection and try again."
"title": "Bạn đang offline.",
"text": "Vui lòng kiểm tra kết nối mạng của bạn và thử lại."
},
"user": {
"auth": {
@ -354,7 +354,7 @@
},
"filters": {
"title": "Bộ lọc",
"clear": "Clear Filters",
"clear": "Xoá các bộ lọc",
"attributes": {
"title": "Tiêu đề",
"titlePlaceholder": "Tiêu đề bộ lọc đã lưu ở đây…",
@ -461,8 +461,8 @@
"default": "Mặc định",
"close": "Đóng",
"download": "Tải về",
"showMenu": "Show the menu",
"hideMenu": "Hide the menu"
"showMenu": "Hiển thị menu",
"hideMenu": "Ẩn menu"
},
"input": {
"resetColor": "Đặt lại màu",
@ -656,7 +656,7 @@
"searchPlaceholder": "Gõ tìm kiếm một công việc để thêm dưới dạng liên quan…",
"createPlaceholder": "Thêm điều này làm công việc liên quan mới",
"differentList": "Công việc này thuộc về một danh sách khác.",
"differentNamespace": "This task belongs to a different namespace.",
"differentNamespace": "Công việc này thuộc về một Góc làm việc khác.",
"noneYet": "Không có công việc liên quan nào.",
"delete": "Xóa công việc liên quan",
"deleteText1": "Bạn có chắc muốn xóa công việc liên quan này không?",
@ -757,12 +757,12 @@
},
"keyboardShortcuts": {
"title": "Phím tắt",
"general": "General",
"general": "Tổng quan",
"allPages": "Các phím tắt này hoạt động ở mọi nơi.",
"currentPageOnly": "Các phím tắt này chỉ hoạt động ở trang hiện tại.",
"toggleMenu": "Bật/tắt Menu",
"quickSearch": "Mở thanh tìm kiếm/thao tác nhanh",
"then": "then",
"then": "sau đó",
"task": {
"title": "Trang công việc",
"done": "Đánh dấu hoàn thành",
@ -773,11 +773,11 @@
"related": "Sửa đổi các công việc liên kết"
},
"list": {
"title": "List Views",
"switchToListView": "Switch to list view",
"switchToGanttView": "Switch to gantt view",
"switchToKanbanView": "Switch to kanban view",
"switchToTableView": "Switch to table view"
"title": "Xem danh sách",
"switchToListView": "Chuyển sang chế độ danh sách",
"switchToGanttView": "Chuyển sang biểu đồ Gantt",
"switchToKanbanView": "Chuyển sang Kanban",
"switchToTableView": "Chuyển qua xem Bảng"
}
},
"update": {
@ -799,9 +799,9 @@
"urlPlaceholder": "ví dụ: https://localhost:3456",
"change": "thay đổi",
"signInOn": "Đăng nhập vào tài khoản Vikunia tại {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Không thể tìm thấy hoặc sử dụng cài đặt Vikunja tại \"{domain}\". Vui lòng thử một url khác.",
"success": "Sử dụng cài đặt Vikunja tại \"{domain}\".",
"urlRequired": "A url is required."
"urlRequired": "Cần có một url."
},
"loadingError": {
"failed": "Tải thất bại, vui lòng {0}. Nếu lỗi vẫn xảy ra, vui lòng {1}.",
@ -816,7 +816,7 @@
"quickActions": {
"commands": "Các lệnh",
"placeholder": "Gõ một lệnh hoặc tìm kiếm…",
"hint": "You can use {list} to limit the search to a list. Combine {list} or {label} (labels) with a search query to search for a task with these labels or on that list. Use {assignee} to only search for teams.",
"hint": "Bạn có thể dùng {list} để giới hạn tìm kiếm trong một danh sách. Kết hợp {list} hoặc {label} (các nhãn) với một truy vấn để tìm kiếm một công việc được gắn các nhãn này hoặc trên danh sách đó. Sử dụng {assignee} để chỉ tìm kiếm các Team.",
"tasks": "Tác vụ",
"lists": "Danh sách",
"teams": "Team",

52
src/indexes/index.ts Normal file
View File

@ -0,0 +1,52 @@
import {Document, SimpleDocumentSearchResultSetUnit} from 'flexsearch'
export interface withId {
id: number,
}
const indexes: { [k: string]: Document<withId> } = {}
export const createNewIndexer = (name: string, fieldsToIndex: string[]) => {
if (typeof indexes[name] === 'undefined') {
indexes[name] = new Document<withId>({
tokenize: 'full',
document: {
id: 'id',
index: fieldsToIndex,
},
})
}
const index = indexes[name]
function add(item: withId) {
return index.add(item.id, item)
}
function remove(item: withId) {
return index.remove(item.id)
}
function update(item: withId) {
return index.update(item.id, item)
}
function search(query: string | null): number[] | null {
if (query === '' || query === null) {
return null
}
// @ts-ignore
return index.search(query)
?.flatMap(r => r.result)
.filter((value, index, self) => self.indexOf(value) === index)
|| null
}
return {
add,
remove,
update,
search,
}
}

View File

@ -35,15 +35,17 @@ import ListSettingDuplicate from '../views/list/settings/duplicate'
import ListSettingShare from '../views/list/settings/share'
import ListSettingDelete from '../views/list/settings/delete'
import ListSettingArchive from '../views/list/settings/archive'
import FilterSettingEdit from '../views/filters/settings/edit'
import FilterSettingDelete from '../views/filters/settings/delete'
// Namespace Settings
import NamespaceSettingEdit from '../views/namespaces/settings/edit'
import NamespaceSettingShare from '../views/namespaces/settings/share'
import NamespaceSettingArchive from '../views/namespaces/settings/archive'
import NamespaceSettingDelete from '../views/namespaces/settings/delete'
// Saved Filters
import CreateSavedFilter from '../views/filters/CreateSavedFilter'
import FilterNew from '@/views/filters/FilterNew'
import FilterEdit from '@/views/filters/FilterEdit'
import FilterDelete from '@/views/filters/FilterDelete'
const PasswordResetComponent = () => import('../views/user/PasswordReset')
const GetPasswordResetComponent = () => import('../views/user/RequestPasswordReset')
@ -123,6 +125,7 @@ const router = createRouter({
path: '/user/settings',
name: 'user.settings',
component: UserSettingsComponent,
redirect: {name: 'user.settings.general'},
children: [
{
path: '/user/settings/avatar',
@ -279,14 +282,14 @@ const router = createRouter({
path: '/lists/:listId/settings/edit',
name: 'filter.settings.edit',
components: {
popup: FilterSettingEdit,
popup: FilterEdit,
},
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.settings.delete',
components: {
popup: FilterSettingDelete,
popup: FilterDelete,
},
},
{
@ -337,12 +340,12 @@ const router = createRouter({
{
path: '/lists/:listId/settings/edit',
name: 'filter.list.settings.edit',
component: FilterSettingEdit,
component: FilterEdit,
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.list.settings.delete',
component: FilterSettingDelete,
component: FilterDelete,
},
],
},
@ -389,12 +392,12 @@ const router = createRouter({
{
path: '/lists/:listId/settings/edit',
name: 'filter.gantt.settings.edit',
component: FilterSettingEdit,
component: FilterEdit,
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.gantt.settings.delete',
component: FilterSettingDelete,
component: FilterDelete,
},
],
},
@ -436,12 +439,12 @@ const router = createRouter({
{
path: '/lists/:listId/settings/edit',
name: 'filter.table.settings.edit',
component: FilterSettingEdit,
component: FilterEdit,
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.table.settings.delete',
component: FilterSettingDelete,
component: FilterDelete,
},
],
},
@ -488,12 +491,12 @@ const router = createRouter({
{
path: '/lists/:listId/settings/edit',
name: 'filter.kanban.settings.edit',
component: FilterSettingEdit,
component: FilterEdit,
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.kanban.settings.delete',
component: FilterSettingDelete,
component: FilterDelete,
},
],
},
@ -542,7 +545,7 @@ const router = createRouter({
path: '/filters/new',
name: 'filters.create',
components: {
popup: CreateSavedFilter,
popup: FilterNew,
},
},
{

View File

@ -3,6 +3,9 @@ import {setLoading} from '@/store/helper'
import {success} from '@/message'
import {i18n} from '@/i18n'
import {getLabelsByIds, filterLabelsByQuery} from '@/helpers/labels'
import {createNewIndexer} from '@/indexes'
const {add, remove, update} = createNewIndexer('labels', ['title', 'description'])
async function getAllLabels(page = 1) {
const labelService = new LabelService()
@ -26,13 +29,16 @@ export default {
setLabels(state, labels) {
labels.forEach(l => {
state.labels[l.id] = l
add(l)
})
},
setLabel(state, label) {
state.labels[label.id] = label
update(label)
},
removeLabelById(state, label) {
delete state.labels[label.id]
remove(label)
},
setLoaded(state, loaded) {
state.loaded = loaded

View File

@ -1,6 +1,9 @@
import ListService from '@/services/list'
import {setLoading} from '@/store/helper'
import {removeListFromHistory} from '@/modules/listHistory.ts'
import {createNewIndexer} from '@/indexes'
const {add, remove, search, update} = createNewIndexer('lists', ['title', 'description'])
const FavoriteListsNamespace = -2
@ -11,14 +14,17 @@ export default {
mutations: {
setList(state, list) {
state[list.id] = list
update(list)
},
setLists(state, lists) {
lists.forEach(l => {
state[l.id] = l
add(l)
})
},
removeListById(state, list) {
delete state[list.id]
remove(list)
},
},
getters: {
@ -34,6 +40,13 @@ export default {
})
return typeof list === 'undefined' ? null : list
},
searchList: state => (query, includeArchived = false) => {
return search(query)
?.filter(value => value > 0)
.map(id => state[id])
.filter(list => list.isArchived === includeArchived)
|| []
},
},
actions: {
toggleListFavorite(ctx, list) {
@ -66,7 +79,7 @@ export default {
await listService.update(list)
ctx.commit('setList', list)
ctx.commit('namespaces/setListInNamespaceById', list, {root: true})
// the returned list from listService.update is the same!
// in order to not validate vuex mutations we have to create a new copy
const newList = {
@ -81,7 +94,7 @@ export default {
ctx.dispatch('namespaces/loadNamespacesIfFavoritesDontExist', null, {root: true})
ctx.dispatch('namespaces/removeFavoritesNamespaceIfEmpty', null, {root: true})
return newList
} catch(e) {
} catch (e) {
// Reset the list state to the initial one to avoid confusion for the user
ctx.commit('setList', {
...list,
@ -97,13 +110,13 @@ export default {
const cancel = setLoading(ctx, 'lists')
const listService = new ListService()
try {
try {
const response = await listService.delete(list)
ctx.commit('removeListById', list)
ctx.commit('namespaces/removeListFromNamespaceById', list, {root: true})
removeListFromHistory({id: list.id})
return response
} finally{
} finally {
cancel()
}
},

View File

@ -1,5 +1,8 @@
import NamespaceService from '../../services/namespace'
import {setLoading} from '@/store/helper'
import {createNewIndexer} from '@/indexes'
const {add, remove, search, update} = createNewIndexer('namespaces', ['title', 'description'])
export default {
namespaced: true,
@ -9,6 +12,9 @@ export default {
mutations: {
namespaces(state, namespaces) {
state.namespaces = namespaces
namespaces.forEach(n => {
add(n)
})
},
setNamespaceById(state, namespace) {
const namespaceIndex = state.namespaces.findIndex(n => n.id === namespace.id)
@ -22,8 +28,9 @@ export default {
if (!namespace.lists || namespace.lists.length === 0) {
namespace.lists = state.namespaces[namespaceIndex].lists
}
state.namespaces[namespaceIndex] = namespace
update(namespace)
},
setListInNamespaceById(state, list) {
for (const n in state.namespaces) {
@ -43,11 +50,13 @@ export default {
},
addNamespace(state, namespace) {
state.namespaces.push(namespace)
add(namespace)
},
removeNamespaceById(state, namespaceId) {
for (const n in state.namespaces) {
if (state.namespaces[n].id === namespaceId) {
state.namespaces.splice(n, 1)
remove(state.namespaces[n])
return
}
}
@ -78,11 +87,11 @@ export default {
getters: {
getListAndNamespaceById: state => (listId, ignorePseudoNamespaces = false) => {
for (const n in state.namespaces) {
if(ignorePseudoNamespaces && state.namespaces[n].id < 0) {
if (ignorePseudoNamespaces && state.namespaces[n].id < 0) {
continue
}
for (const l in state.namespaces[n].lists) {
if (state.namespaces[n].lists[l].id === listId) {
return {
@ -97,6 +106,13 @@ export default {
getNamespaceById: state => namespaceId => {
return state.namespaces.find(({id}) => id == namespaceId) || null
},
searchNamespace: (state, getters) => query => {
return search(query)
?.filter(value => value > 0)
.map(getters.getNamespaceById)
.filter(n => n !== null)
|| []
},
},
actions: {
async loadNamespaces(ctx) {
@ -107,12 +123,12 @@ export default {
// We always load all namespaces and filter them on the frontend
const namespaces = await namespaceService.getAll({}, {is_archived: true})
ctx.commit('namespaces', namespaces)
// Put all lists in the list state
const lists = namespaces.flatMap(({lists}) => lists)
ctx.commit('lists/setLists', lists, {root: true})
return namespaces
} finally {
cancel()

View File

@ -13,10 +13,10 @@
>
<div class="p-4">
<p>
{{ $t('about.frontendVersion', {version: this.frontendVersion}) }}
{{ $t('about.frontendVersion', {version: frontendVersion}) }}
</p>
<p>
{{ $t('about.apiVersion', {version: this.apiVersion}) }}
{{ $t('about.apiVersion', {version: apiVersion}) }}
</p>
</div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
@ -32,18 +32,11 @@
</template>
<script>
import {VERSION} from '../version.json'
<script setup>
import {computed} from 'vue'
export default {
name: 'About',
computed: {
frontendVersion() {
return VERSION
},
apiVersion() {
return this.$store.state.config.version
},
},
}
import { store } from '@/store'
import {VERSION as frontendVersion} from '@/version.json'
const apiVersion = computed(() => store.state.config.version)
</script>

View File

@ -0,0 +1,40 @@
<template>
<modal
@close="$router.back()"
@submit="deleteSavedFilter()"
>
<template #header><span>{{ $t('filters.delete.header') }}</span></template>
<template #text>
<p>{{ $t('filters.delete.text') }}</p>
</template>
</modal>
</template>
<script setup>
import { store } from '@/store'
import { useI18n } from 'vue-i18n'
import { useRouter, useRoute } from 'vue-router'
import {success} from '@/message'
import SavedFilterModel from '@/models/savedFilter'
import SavedFilterService from '@/services/savedFilter'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
async function deleteSavedFilter() {
// We assume the listId in the route is the pseudolist
const savedFilterId = getSavedFilterIdFromListId(route.params.listId)
const filterService = new SavedFilterService()
const filter = new SavedFilterModel({id: savedFilterId})
await filterService.delete(filter)
await store.dispatch('namespaces/loadNamespaces')
success({message: t('filters.delete.success')})
router.push({name: 'namespaces.index'})
}
</script>

View File

@ -0,0 +1,127 @@
<template>
<create-edit
:title="$t('filters.edit.title')"
primary-icon=""
:primary-label="$t('misc.save')"
@primary="saveSavedFilter"
:tertary="$t('misc.delete')"
@tertary="$router.push({ name: 'filter.settings.delete', params: { id: $route.params.listId } })"
>
<form @submit.prevent="saveSavedFilter()">
<div class="field">
<label class="label" for="title">{{ $t('filters.attributes.title') }}</label>
<div class="control">
<input
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading || null"
@keyup.enter="saveSavedFilter"
class="input"
id="title"
:placeholder="$t('filters.attributes.titlePlaceholder')"
type="text"
v-focus
v-model="filter.title"/>
</div>
</div>
<div class="field">
<label class="label" for="description">{{ $t('filters.attributes.description') }}</label>
<div class="control">
<editor
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading"
:preview-is-default="false"
id="description"
:placeholder="$t('filters.attributes.descriptionPlaceholder')"
v-model="filter.description"
/>
</div>
</div>
<div class="field">
<label class="label" for="filters">{{ $t('filters.title') }}</label>
<div class="control">
<Filters
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading"
class="has-no-shadow has-no-border"
v-model="filters"
/>
</div>
</div>
</form>
</create-edit>
</template>
<script setup>
import { ref, shallowRef, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { store } from '@/store'
import { success } from '@/message'
import { useI18n } from 'vue-i18n'
import {default as Editor} from '@/components/input/AsyncEditor'
import CreateEdit from '@/components/misc/create-edit.vue'
import Filters from '@/components/list/partials/filters.vue'
import SavedFilterModel from '@/models/savedFilter'
import SavedFilterService from '@/services/savedFilter'
import {objectToSnakeCase} from '@/helpers/case'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
const { t } = useI18n()
function useSavedFilter(listId) {
const filterService = shallowRef(new SavedFilterService())
const filter = ref(new SavedFilterModel())
const filters = computed({
get: () => filter.value.filters,
set(value) {
filter.value.filters = value
},
})
// loadSavedFilter
watch(listId, async () => {
// We assume the listId in the route is the pseudolist
const savedFilterId = getSavedFilterIdFromListId(route.params.listId)
filter.value = new SavedFilterModel({id: savedFilterId })
const response = await filterService.value.get(filter.value)
response.filters = objectToSnakeCase(filter.value.filters)
filter.value = response
}, { immediate: true })
async function save() {
filter.value.filters = filters.value
const response = await filterService.value.update(filter.value)
await store.dispatch('namespaces/loadNamespaces')
success({message: t('filters.edit.success')})
response.filters = objectToSnakeCase(filter.value.filters)
filter.value = response
}
return {
save,
filter,
filters,
filterService,
}
}
const route = useRoute()
const listId = computed(() => route.params.listId)
const {
save,
filter,
filters,
filterService,
} = useSavedFilter(listId)
const router = useRouter()
async function saveSavedFilter() {
await save()
router.back()
}
</script>

View File

@ -26,20 +26,20 @@
<label class="label" for="description">{{ $t('filters.attributes.description') }}</label>
<div class="control">
<editor
:key="savedFilter.id"
v-model="savedFilter.description"
:class="{ 'disabled': savedFilterService.loading}"
:disabled="savedFilterService.loading"
:preview-is-default="false"
id="description"
:placeholder="$t('filters.attributes.descriptionPlaceholder')"
v-if="editorActive"
/>
</div>
</div>
<div class="field">
<label class="label" for="filters">{{ $t('filters.title') }}</label>
<div class="control">
<filters
<Filters
:class="{ 'disabled': savedFilterService.loading}"
:disabled="savedFilterService.loading"
class="has-no-shadow has-no-border"
@ -59,46 +59,30 @@
</modal>
</template>
<script>
import AsyncEditor from '@/components/input/AsyncEditor'
<script setup>
import { ref, shallowRef, computed } from 'vue'
import { store } from '@/store'
import { useRouter } from 'vue-router'
import {default as Editor} from '@/components/input/AsyncEditor'
import Filters from '@/components/list/partials/filters.vue'
import SavedFilterService from '@/services/savedFilter'
import SavedFilterModel from '@/models/savedFilter'
export default {
name: 'CreateSavedFilter',
data() {
return {
editorActive: false,
filters: {
sort_by: ['done', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
filter_include_nulls: true,
},
savedFilterService: new SavedFilterService(),
savedFilter: new SavedFilterModel(),
}
},
components: {
Filters,
editor: AsyncEditor,
},
created() {
this.editorActive = false
this.$nextTick(() => this.editorActive = true)
},
methods: {
async create() {
this.savedFilter.filters = this.filters
const savedFilter = await this.savedFilterService.create(this.savedFilter)
await this.$store.dispatch('namespaces/loadNamespaces')
this.$router.push({name: 'list.index', params: {listId: savedFilter.getListId()}})
},
},
const savedFilterService = shallowRef(new SavedFilterService())
const savedFilter = ref(new SavedFilterModel())
const filters = computed({
get: () => savedFilter.value.filters,
set: (value) => (savedFilter.value.filters = value),
})
const router = useRouter()
async function create() {
savedFilter.value = await savedFilterService.value.create(savedFilter.value)
await store.dispatch('namespaces/loadNamespaces')
router.push({name: 'list.index', params: {listId: savedFilter.value.getListId()}})
}
</script>

View File

@ -1,39 +0,0 @@
<template>
<modal
@close="$router.back()"
@submit="deleteSavedFilter()"
>
<template #header><span>{{ $t('filters.delete.header') }}</span></template>
<template #text>
<p>{{ $t('filters.delete.text') }}</p>
</template>
</modal>
</template>
<script>
import SavedFilterModel from '@/models/savedFilter'
import SavedFilterService from '@/services/savedFilter'
import ListModel from '@/models/list'
export default {
name: 'filter-settings-delete',
data() {
return {
filterService: new SavedFilterService(),
}
},
methods: {
async deleteSavedFilter() {
// We assume the listId in the route is the pseudolist
const list = new ListModel({id: this.$route.params.listId})
const filter = new SavedFilterModel({id: list.getSavedFilterId()})
await this.filterService.delete(filter)
await this.$store.dispatch('namespaces/loadNamespaces')
this.$message.success({message: this.$t('filters.delete.success')})
this.$router.push({name: 'namespaces.index'})
},
},
}
</script>

View File

@ -1,117 +0,0 @@
<template>
<create-edit
:title="$t('filters.edit.title')"
primary-icon=""
:primary-label="$t('misc.save')"
@primary="save"
:tertary="$t('misc.delete')"
@tertary="$router.push({ name: 'filter.list.settings.delete', params: { id: $route.params.listId } })"
>
<form @submit.prevent="save()">
<div class="field">
<label class="label" for="title">{{ $t('filters.attributes.title') }}</label>
<div class="control">
<input
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading || null"
@keyup.enter="save"
class="input"
id="title"
:placeholder="$t('filters.attributes.titlePlaceholder')"
type="text"
v-focus
v-model="filter.title"/>
</div>
</div>
<div class="field">
<label class="label" for="description">{{ $t('filters.attributes.description') }}</label>
<div class="control">
<editor
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading"
:preview-is-default="false"
id="description"
:placeholder="$t('filters.attributes.descriptionPlaceholder')"
v-model="filter.description"
/>
</div>
</div>
<div class="field">
<label class="label" for="filters">{{ $t('filters.title') }}</label>
<div class="control">
<filters
:class="{ 'disabled': filterService.loading}"
:disabled="filterService.loading"
class="has-no-shadow has-no-border"
v-model="filters"
/>
</div>
</div>
</form>
</create-edit>
</template>
<script>
import AsyncEditor from '@/components/input/AsyncEditor'
import CreateEdit from '@/components/misc/create-edit.vue'
import SavedFilterModel from '@/models/savedFilter'
import SavedFilterService from '@/services/savedFilter'
import ListModel from '@/models/list'
import Filters from '@/components/list/partials/filters.vue'
import {objectToSnakeCase} from '@/helpers/case'
export default {
name: 'filter-settings-edit',
data() {
return {
filter: SavedFilterModel,
filterService: new SavedFilterService(),
filters: {
sort_by: ['done', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
filter_include_nulls: true,
},
showDeleteModal: false,
}
},
components: {
CreateEdit,
Filters,
editor: AsyncEditor,
},
watch: {
// call again the method if the route changes
'$route': {
handler: 'loadSavedFilter',
deep: true,
immediate: true,
},
},
methods: {
async loadSavedFilter() {
// We assume the listId in the route is the pseudolist
const list = new ListModel({id: this.$route.params.listId})
this.filter = new SavedFilterModel({id: list.getSavedFilterId()})
this.filter = await this.filterService.get(this.filter)
this.filters = objectToSnakeCase(this.filter.filters)
},
async save() {
this.filter.filters = this.filters
const filter = await this.filterService.update(this.filter)
await this.$store.dispatch('namespaces/loadNamespaces')
this.$message.success({message: this.$t('filters.edit.success')})
this.filter = filter
this.filters = objectToSnakeCase(this.filter.filters)
this.$router.back()
},
},
}
</script>

View File

@ -41,7 +41,7 @@
<div class="progress-dots">
<span v-for="i in progressDotsCount" :key="i" />
</div>
<Logo alt="Vikunja" />
<Logo/>
</div>
<p>{{ $t('migrate.inProgress') }}</p>
</div>
@ -186,9 +186,10 @@ export default {
align-items: center;
margin-bottom: 2rem;
img {
img, svg {
display: block;
max-height: 100px;
max-width: 100px;
}
}

View File

@ -35,36 +35,25 @@
</div>
</template>
<script>
import DataExportService from '../../services/dataExport'
<script setup>
import {ref, computed, reactive} from 'vue'
import DataExportService from '@/services/dataExport'
import {store} from '@/store'
export default {
name: 'data-export-download',
data() {
return {
dataExportService: DataExportService,
password: '',
errPasswordRequired: false,
}
},
created() {
this.dataExportService = new DataExportService()
},
computed: {
isLocalUser() {
return this.$store.state.auth.info?.isLocalUser
},
},
methods: {
download() {
if (this.password === '' && this.isLocalUser) {
this.errPasswordRequired = true
this.$refs.passwordInput.focus()
return
}
const dataExportService = reactive(new DataExportService())
const password = ref('')
const errPasswordRequired = ref(false)
const passwordInput = ref(null)
this.dataExportService.download(this.password)
},
},
const isLocalUser = computed(() => store.state.auth.info?.isLocalUser)
function download() {
if (password.value === '' && isLocalUser.value) {
errPasswordRequired.value = true
passwordInput.value.focus()
return
}
dataExportService.download(password.value)
}
</script>

View File

@ -60,55 +60,49 @@
{{ $t('user.auth.login') }}
</x-button>
</div>
<legal/>
<Legal />
</div>
</div>
</template>
<script>
import PasswordResetModel from '../../models/passwordReset'
import PasswordResetService from '../../services/passwordReset'
import Legal from '../../components/misc/legal'
<script setup>
import {ref, reactive} from 'vue'
import { useI18n } from 'vue-i18n'
export default {
components: {
Legal,
},
data() {
return {
passwordResetService: new PasswordResetService(),
credentials: {
password: '',
password2: '',
},
errorMsg: '',
successMessage: '',
}
},
import Legal from '@/components/misc/legal'
mounted() {
this.setTitle(this.$t('user.auth.resetPassword'))
},
import PasswordResetModel from '@/models/passwordReset'
import PasswordResetService from '@/services/passwordReset'
import { useTitle } from '@/composables/useTitle'
methods: {
async submit() {
this.errorMsg = ''
const { t } = useI18n()
useTitle(() => t('user.auth.resetPassword'))
if (this.credentials.password2 !== this.credentials.password) {
this.errorMsg = this.$t('user.auth.passwordsDontMatch')
return
}
const credentials = reactive({
password: '',
password2: '',
})
let passwordReset = new PasswordResetModel({newPassword: this.credentials.password})
try {
const { message } = this.passwordResetService.resetPassword(passwordReset)
this.successMessage = message
localStorage.removeItem('passwordResetToken')
} catch(e) {
this.errorMsg = e.response.data.message
}
},
},
const passwordResetService = reactive(new PasswordResetService())
const errorMsg = ref('')
const successMessage = ref('')
async function submit() {
errorMsg.value = ''
if (credentials.password2 !== credentials.password) {
errorMsg.value = t('user.auth.passwordsDontMatch')
return
}
const passwordReset = new PasswordResetModel({newPassword: credentials.password})
try {
const { message } = passwordResetService.resetPassword(passwordReset)
successMessage.value = message
localStorage.removeItem('passwordResetToken')
} catch(e) {
errorMsg.value = e.response.data.message
}
}
</script>

View File

@ -36,12 +36,12 @@
</div>
</div>
<div class="field">
<label class="label" for="password1">{{ $t('user.auth.password') }}</label>
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<div class="control">
<input
class="input"
id="password1"
name="password1"
id="password"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
type="password"
@ -52,17 +52,17 @@
</div>
</div>
<div class="field">
<label class="label" for="password2">{{ $t('user.auth.passwordRepeat') }}</label>
<label class="label" for="passwordValidation">{{ $t('user.auth.passwordRepeat') }}</label>
<div class="control">
<input
class="input"
id="password2"
name="password2"
id="passwordValidation"
name="passwordValidation"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
type="password"
autocomplete="new-password"
v-model="credentials.password2"
v-model="passwordValidation"
@keyup.enter="submit"
/>
</div>
@ -95,61 +95,50 @@
</div>
</template>
<script>
import router from '../../router'
import {mapState} from 'vuex'
import {LOADING} from '@/store/mutation-types'
import Legal from '../../components/misc/legal'
<script setup>
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
import { useI18n } from 'vue-i18n'
export default {
components: {
Legal,
},
data() {
return {
credentials: {
username: '',
email: '',
password: '',
password2: '',
},
errorMessage: '',
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect them to the homepage
if (this.authenticated) {
router.push({name: 'home'})
}
},
mounted() {
this.setTitle(this.$t('user.auth.register'))
},
computed: mapState({
authenticated: state => state.auth.authenticated,
loading: LOADING,
}),
methods: {
async submit() {
this.errorMessage = ''
import router from '@/router'
import { store } from '@/store'
import { useTitle } from '@/composables/useTitle'
if (this.credentials.password2 !== this.credentials.password) {
this.errorMessage = this.$t('user.auth.passwordsDontMatch')
return
}
import Legal from '@/components/misc/legal'
const credentials = {
username: this.credentials.username,
email: this.credentials.email,
password: this.credentials.password,
}
// FIXME: use the `beforeEnter` hook of vue-router
// Check if the user is already logged in, if so, redirect them to the homepage
onBeforeMount(() => {
if (store.state.auth.authenticated) {
router.push({name: 'home'})
}
})
try {
await this.$store.dispatch('auth/register', credentials)
} catch(e) {
this.errorMessage = e.message
}
},
},
const { t } = useI18n()
useTitle(() => t('user.auth.register'))
const credentials = reactive({
username: '',
email: '',
password: '',
})
const passwordValidation = ref('')
const loading = computed(() => store.state.loading)
const errorMessage = ref('')
async function submit() {
errorMessage.value = ''
if (credentials.password !== passwordValidation.value) {
errorMessage.value = t('user.auth.passwordsDontMatch')
return
}
try {
await store.dispatch('auth/register', toRaw(credentials))
} catch(e) {
errorMessage.value = e.message
}
}
</script>

View File

@ -43,42 +43,38 @@
{{ $t('user.auth.login') }}
</x-button>
</div>
<legal/>
<Legal />
</div>
</div>
</template>
<script>
import PasswordResetModel from '../../models/passwordReset'
import PasswordResetService from '../../services/passwordReset'
import Legal from '../../components/misc/legal'
<script setup>
import {ref, reactive} from 'vue'
import { useI18n } from 'vue-i18n'
export default {
components: {
Legal,
},
data() {
return {
passwordResetService: new PasswordResetService(),
passwordReset: new PasswordResetModel(),
errorMsg: '',
isSuccess: false,
}
},
mounted() {
this.setTitle(this.$t('user.auth.resetPassword'))
},
methods: {
async submit() {
this.errorMsg = ''
try {
await this.passwordResetService.requestResetPassword(this.passwordReset)
this.isSuccess = true
} catch(e) {
this.errorMsg = e.response.data.message
}
},
},
import Legal from '@/components/misc/legal'
import PasswordResetModel from '@/models/passwordReset'
import PasswordResetService from '@/services/passwordReset'
import { useTitle } from '@/composables/useTitle'
const { t } = useI18n()
useTitle(() => t('user.auth.resetPassword'))
// Not sure if this instance needs a shalloRef at all
const passwordResetService = reactive(new PasswordResetService())
const passwordReset = ref(new PasswordResetModel())
const errorMsg = ref('')
const isSuccess = ref(false)
async function submit() {
errorMsg.value = ''
try {
await passwordResetService.requestResetPassword(passwordReset.value)
isSuccess.value = true
} catch(e) {
errorMsg.value = e.response.data.message
}
}
</script>

View File

@ -57,24 +57,19 @@
</div>
</template>
<script>
import {mapState} from 'vuex'
<script setup>
import {computed} from 'vue'
import { store } from '@/store'
import { useI18n } from 'vue-i18n'
import { useTitle } from '@/composables/useTitle'
export default {
name: 'Settings',
mounted() {
this.setTitle(this.$t('user.settings.title'))
},
computed: {
...mapState('config', ['totpEnabled', 'caldavEnabled']),
migratorsEnabled() {
return this.$store.getters['config/migratorsEnabled']
},
isLocalUser() {
return this.$store.state.auth.info?.isLocalUser
},
},
}
const { t } = useI18n()
useTitle(() => t('user.settings.title'))
const totpEnabled = computed(() => store.state.config.totpEnabled)
const caldavEnabled = computed(() => store.state.config.caldavEnabled)
const migratorsEnabled = computed(() => store.getters['config/migratorsEnabled'])
const isLocalUser = computed(() => store.state.auth.info?.isLocalUser)
</script>
<style lang="scss" scoped>

684
yarn.lock

File diff suppressed because it is too large Load Diff