Merge pull request 'main' (#1) from vikunja/frontend:main into main

Reviewed-on: #1
This commit is contained in:
Elix 2022-06-11 15:29:06 +00:00
commit 0cc55420ea
357 changed files with 16265 additions and 13644 deletions

View File

@ -39,7 +39,7 @@ steps:
# - '.cache'
- name: dependencies
image: node:16
image: node:18
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
@ -70,7 +70,7 @@ steps:
# - dependencies
- name: lint
image: node:16
image: node:18
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
@ -81,7 +81,7 @@ steps:
- dependencies
- name: build-prod
image: node:16
image: node:18
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
@ -91,13 +91,22 @@ steps:
- dependencies
- name: test-unit
image: node:16
image: node:18
pull: true
commands:
- yarn test:unit
depends_on:
- dependencies
- name: typecheck
failure: ignore
image: node:18
pull: true
commands:
- yarn typecheck
depends_on:
- dependencies
- name: test-frontend
image: cypress/browsers:node16.5.0-chrome94-ff93
pull: true
@ -107,38 +116,17 @@ steps:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
CYPRESS_RECORD_KEY:
from_secret: cypress_project_key
commands:
- sed -i 's/localhost/api/g' dist/index.html
- yarn serve:dist & npx wait-on http://localhost:5000
- yarn test:frontend --browser chrome
- yarn serve:dist & npx wait-on http://localhost:4173
- yarn test:frontend --browser chrome --record
depends_on:
- dependencies
- build-prod
- name: upload-test-results
image: plugins/s3
pull: true
settings:
bucket: drone-test-results
access_key:
from_secret: test_results_aws_access_key_id
secret_key:
from_secret: test_results_aws_secret_access_key
endpoint: https://s3.fr-par.scw.cloud
region: fr-par
path_style: true
source: cypress/screenshots/**/**/*
strip_prefix: cypress/screenshots/
target: /${DRONE_REPO}/${DRONE_PULL_REQUEST}_${DRONE_BRANCH}/${DRONE_BUILD_NUMBER}/
depends_on:
- test-frontend
when:
status:
- failure
- success
- name: deploy-preview
image: node:16
image: node:18
pull: true
environment:
NETLIFY_AUTH_TOKEN:
@ -148,6 +136,9 @@ steps:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- cp -r dist dist-preview
# Override the default api url used for preview
- sed -i 's|localhost:3456|try.vikunja.io|g' dist-preview/index.html
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js
depends_on:
@ -195,7 +186,7 @@ steps:
# - '.cache'
- name: build
image: node:16
image: node:18
pull: true
group: build-static
environment:
@ -270,7 +261,7 @@ steps:
# - '.cache'
- name: build
image: node:16
image: node:18
pull: true
group: build-static
environment:
@ -656,6 +647,6 @@ steps:
from_secret: crowdin_key
---
kind: signature
hmac: 188ee90100c5fc5922a445e531e7a47453121edddb2a64a182eb23ed2bf602de
hmac: 997e1badebe484ac29557c4af356e63db4d3d57f3d32e92d482f117f8cec64da
...

2
.nvmrc
View File

@ -1 +1 @@
v16
v18

View File

@ -1,5 +1,5 @@
# Stage 1: Build application
FROM node:16 AS compile-image
FROM node:18 AS compile-image
WORKDIR /build

22
cypress.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { defineConfig } from 'cypress'
export default defineConfig({
env: {
API_URL: 'http://localhost:3456/api/v1',
TEST_SECRET: 'averyLongSecretToSe33dtheDB',
},
video: false,
retries: {
runMode: 2,
},
projectId: '181c7x',
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:4173',
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
},
})

View File

@ -1,11 +0,0 @@
{
"baseUrl": "http://localhost:5000",
"env": {
"API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "averyLongSecretToSe33dtheDB"
},
"video": false,
"retries": {
"runMode": 2
}
}

View File

@ -0,0 +1,56 @@
import {ListFactory} from '../../factories/list'
import '../../support/authenticateUser'
import {prepareLists} from './prepareLists'
describe('List History', () => {
prepareLists()
it('should show a list history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
const lists = ListFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/lists/${lists[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
// cy.visit('/')
// cy.wait('@loadNamespaces')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
cy.get('.list-cards-wrapper-2-rows')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)
.should('contain', lists[3].title)
.should('contain', lists[4].title)
.should('contain', lists[5].title)
})
})

View File

@ -0,0 +1,76 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View Gantt', () => {
prepareLists()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks')
.should('not.contain', tasks[0].title)
})
it('Shows tasks from the current and next month', () => {
const now = new Date()
const nextMonth = now
nextMonth.setDate(1)
nextMonth.setMonth(now.getMonth() + 1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .months')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
it('Shows tasks with dates', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart .tasks')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart .tasks .task.nodate')
.should('exist')
})
it('Drags a task around', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks .task')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
})
})

View File

@ -0,0 +1,196 @@
import {BucketFactory} from '../../factories/bucket'
import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View Kanban', () => {
let buckets
prepareLists()
beforeEach(() => {
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
cy.get('.kanban .bucket')
.first()
.should('contain', data[0].title)
})
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .button')
.contains('Add another task')
.click()
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .field .control input.input')
.type('New Task{enter}')
cy.get('.kanban .bucket')
.first()
.should('contain', 'New Task')
})
it('Can create a new bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
cy.get('.kanban .bucket.new-bucket input.input')
.type('New Bucket{enter}')
cy.wait(1000) // Wait for the request to finish
cy.get('.kanban .bucket .title')
.contains('New Bucket')
.should('exist')
})
it('Can set a bucket limit', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('[data-cy="setBucketLimit"]')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .title')
.first()
.type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.should('contain', 'New Bucket Title')
})
it('Can delete a bucket', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete the bucket')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('not.exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
})
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
})

View File

@ -0,0 +1,107 @@
import {UserListFactory} from '../../factories/users_list'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View List', () => {
prepareLists()
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/list')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.list-title .dropdown')
.should('exist')
cy.get('p')
.contains('This list is currently empty.')
.should('exist')
})
it('Should create a new task', () => {
const newTaskTitle = 'New task'
cy.visit('/lists/1')
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.get('.tasks')
.should('contain.text', newTaskTitle)
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
.first()
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.list-title a.icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a list in the navigation and not in the list view', () => {
const lists = ListFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
list_id: lists[0].id,
})
cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
})
})

View File

@ -0,0 +1,52 @@
import {TaskFactory} from '../../factories/task'
import '../../support/authenticateUser'
describe('List View Table', () => {
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.list-table table.table')
.should('exist')
cy.get('.list-table table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.list-table .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority')
.click()
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.list-table table.table th')
.contains('Priority')
.should('exist')
cy.get('.list-table table.table th')
.contains('Done')
.should('not.exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/table')
cy.get('.list-table table.table')
.contains(tasks[0].title)
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
})

View File

@ -0,0 +1,101 @@
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('Lists', () => {
let lists
prepareLists((newLists) => (lists = newLists))
it('Should create a new list', () => {
cy.visit('/')
cy.get('.namespace-title .dropdown-trigger')
.click()
cy.get('.namespace-title .dropdown .dropdown-item')
.contains('New list')
.click()
cy.url()
.should('contain', '/lists/new/1')
cy.get('.card-header-title')
.contains('New list')
cy.get('input.input')
.type('New List')
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
.should('contain', 'Success')
cy.url()
.should('contain', '/lists/')
cy.get('.list-title h1')
.should('contain', 'New List')
})
it('Should redirect to a specific list view after visited', () => {
cy.visit('/lists/1/kanban')
cy.url()
.should('contain', '/lists/1/kanban')
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/kanban')
})
it('Should rename the list in all places', () => {
TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
const newListName = 'New list name'
cy.visit('/lists/1')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.list-title h1')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content')
.should('contain', newListName)
.should('not.contain', lists[0].title)
})
it('Should remove a list', () => {
cy.visit(`/lists/${lists[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('[data-cy="modalPrimary"]')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list')
.should('not.contain', lists[0].title)
cy.location('pathname')
.should('equal', '/')
})
})

View File

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

View File

@ -0,0 +1,16 @@
import {ListFactory} from '../../factories/list'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function prepareLists(setLists = () => {}) {
beforeEach(() => {
UserFactory.create(1)
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
title: 'First List'
})
setLists(lists)
TaskFactory.truncate()
})
}

View File

@ -0,0 +1,130 @@
import {ListFactory} from '../../factories/list'
import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {formatISO} from 'date-fns'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {BucketFactory} from '../../factories/bucket'
import {updateUserSettings} from '../../support/updateUserSettings'
import '../../support/authenticateUser'
function seedTasks(numberOfTasks = 100, startDueDate = new Date()) {
UserFactory.create(1)
NamespaceFactory.create(1)
const list = ListFactory.create()[0]
BucketFactory.create(1, {
list_id: list.id,
})
const tasks = []
let dueDate = startDueDate
for (let i = 0; i < numberOfTasks; i++) {
const now = new Date()
dueDate = (new Date(dueDate.valueOf())).setDate((new Date(dueDate.valueOf())).getDate() + 2)
tasks.push({
id: i + 1,
list_id: list.id,
done: false,
created_by_id: 1,
title: 'Test Task ' + i,
index: i + 1,
due_date: formatISO(dueDate),
created: formatISO(now),
updated: formatISO(now),
})
}
seed(TaskFactory.table, tasks)
return {tasks, list}
}
describe('Home Page Task Overview', () => {
it('Should show tasks with a near due date first on the home page overview', () => {
const {tasks} = seedTasks()
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.each(([task], index) => {
expect(task.innerText).to.contain(tasks[index].title)
})
})
it('Should show overdue tasks first, then show other tasks', () => {
const oldDate = (new Date()).setDate((new Date()).getDate() - 14)
const {tasks} = seedTasks(100, oldDate)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.each(([task], index) => {
expect(task.innerText).to.contain(tasks[index].title)
})
})
it('Should show a new task with a very soon due date at the top', () => {
const {tasks} = seedTasks()
const newTaskTitle = 'New Task'
cy.visit('/')
TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
due_date: formatISO(new Date()),
}, false)
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.first()
.should('contain.text', newTaskTitle)
})
it('Should not show a new task without a date at the bottom when there are > 50 tasks', () => {
// We're not using the api here to create the task in order to verify the flow
const {tasks} = seedTasks()
const newTaskTitle = 'New Task'
cy.visit('/')
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('not.contain.text', newTaskTitle)
})
it('Should show a new task without a date at the bottom when there are < 50 tasks', () => {
seedTasks(40)
const newTaskTitle = 'New Task'
TaskFactory.create(1, {
id: 999,
title: newTaskTitle,
}, false)
cy.visit('/')
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('contain.text', newTaskTitle)
})
it('Should show a task without a due date added via default list at the bottom', () => {
const {list} = seedTasks(40)
updateUserSettings({
default_list_id: list.id,
})
const newTaskTitle = 'New Task'
cy.visit('/')
cy.get('.add-task-textarea')
.type(`${newTaskTitle}{enter}`)
cy.get('[data-cy="showTasks"] .card .task')
.last()
.should('contain.text', newTaskTitle)
})
})

View File

@ -6,13 +6,13 @@ import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {UserListFactory} from '../../factories/users_list'
import '../../support/authenticateUser'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
describe('Task', () => {
let namespaces
let lists
@ -116,6 +116,7 @@ describe('Task', () => {
.should('be.visible')
.should('contain', 'Done')
cy.get('.task-view .action-buttons p.created')
.scrollIntoView()
.should('be.visible')
.should('contain', 'Done')
})
@ -128,7 +129,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Done!')
.contains('Mark task done!')
.click()
cy.get('.task-view .heading .is-done')
@ -168,7 +169,7 @@ describe('Task', () => {
.click()
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.type('{selectall}New Description')
cy.get('.task-view .details.content.description .editor a')
cy.get('[data-cy="saveEditor"]')
.contains('Save')
.click()
@ -209,7 +210,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Move task')
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
@ -236,7 +237,7 @@ describe('Task', () => {
cy.get('.task-view .action-buttons .button')
.should('be.visible')
.contains('Delete task')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete this task')
@ -316,7 +317,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Add labels')
.contains('Add Labels')
.should('be.visible')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
@ -343,7 +344,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Add labels')
.contains('Add Labels')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(labels[0].title)
@ -372,13 +373,13 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
.should('be.visible')
.should('contain', labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
.children()
.first()
.get('a.delete')
.get('[data-cy="taskDetail.removeLabel"]')
.click()
cy.get('.global-notification')
@ -404,7 +405,7 @@ describe('Task', () => {
cy.get('.datepicker .datepicker-popup a')
.contains('Tomorrow')
.click()
cy.get('.datepicker .datepicker-popup a.button')
cy.get('[data-cy="closeDatepicker"]')
.contains('Confirm')
.click()

View File

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

View File

@ -25,7 +25,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
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
@ -43,7 +42,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email)
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.')
})

View File

@ -8,21 +8,23 @@ describe('User Settings', () => {
})
it('Changes the user avatar', () => {
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
cy.visit('/user/settings/avatar')
cy.get('input[name=avatarProvider][value=upload]')
.click()
cy.get('input[type=file]', { timeout: 1000 })
.attachFile('image.jpg')
cy.get('input[type=file]', {timeout: 1000})
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientY: 100})
.trigger('mouseup')
cy.get('a.button.is-primary')
cy.get('[data-cy="uploadAvatar"]')
.contains('Upload Avatar')
.click()
cy.wait(3000) // Wait for the request to finish
cy.wait('@uploadAvatar')
cy.get('.global-notification')
.should('contain', 'Success')
})
@ -33,7 +35,7 @@ describe('User Settings', () => {
cy.get('.general-settings .control input.input')
.first()
.type('Lorem Ipsum')
cy.get('.card.general-settings .button.is-primary')
cy.get('[data-cy="saveGeneralSettings"]')
.contains('Save')
.click()

View File

@ -1,4 +1,4 @@
import faker from 'faker'
import faker from '@faker-js/faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View File

@ -1,4 +1,4 @@
import faker from 'faker'
import faker from '@faker-js/faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View File

@ -1,6 +1,6 @@
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
import faker from 'faker'
import faker from '@faker-js/faker'
export class LinkShareFactory extends Factory {
static table = 'link_shares'

View File

@ -1,6 +1,6 @@
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
import faker from 'faker'
import faker from '@faker-js/faker'
export class ListFactory extends Factory {
static table = 'lists'

View File

@ -1,4 +1,4 @@
import faker from 'faker'
import faker from '@faker-js/faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View File

@ -1,4 +1,4 @@
import faker from 'faker'
import faker from '@faker-js/faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View File

@ -1,4 +1,4 @@
import faker from 'faker'
import faker from '@faker-js/faker'
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"

View File

@ -1,4 +1,4 @@
import faker from 'faker'
import faker from '@faker-js/faker'
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'

View File

@ -1,4 +1,4 @@
import faker from 'faker'
import faker from '@faker-js/faker'
import {Factory} from '../support/factory'
import {formatISO} from "date-fns"

View File

@ -1,540 +0,0 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {ListFactory} from '../../factories/list'
import {UserListFactory} from '../../factories/users_list'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
describe('Lists', () => {
let lists
beforeEach(() => {
UserFactory.create(1)
NamespaceFactory.create(1)
lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
})
it('Should create a new list', () => {
cy.visit('/')
cy.get('.namespace-title .dropdown-trigger')
.click()
cy.get('.namespace-title .dropdown .dropdown-item')
.contains('New list')
.click()
cy.url()
.should('contain', '/namespaces/1/list')
cy.get('.card-header-title')
.contains('Create a new list')
cy.get('input.input')
.type('New List')
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
.should('contain', 'Success')
cy.url()
.should('contain', '/lists/')
cy.get('.list-title h1')
.should('contain', 'New List')
})
it('Should redirect to a specific list view after visited', () => {
cy.visit('/lists/1/kanban')
cy.url()
.should('contain', '/lists/1/kanban')
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/kanban')
})
it('Should rename the list in all places', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
const newListName = 'New list name'
cy.visit('/lists/1')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.list-title h1')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content .tasks')
.should('contain', newListName)
.should('not.contain', lists[0].title)
})
it('Should remove a list', () => {
cy.visit(`/lists/${lists[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list')
.should('not.contain', lists[0].title)
cy.location('pathname')
.should('equal', '/')
})
describe('List View', () => {
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/list')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.list-title .dropdown')
.should('exist')
cy.get('p')
.contains('This list is currently empty.')
.should('exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
.first()
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.list-title a.icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a list in the navigation and not in the list view', () => {
const lists = ListFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
list_id: lists[0].id,
})
cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
})
})
describe('Table View', () => {
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.table-view table.table')
.should('exist')
cy.get('.table-view table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.table-view .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority')
.click()
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.table-view table.table th')
.contains('Priority')
.should('exist')
cy.get('.table-view table.table th')
.contains('Done')
.should('not.exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/table')
cy.get('.table-view table.table')
.contains(tasks[0].title)
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
})
describe('Gantt View', () => {
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.contain', tasks[0].title)
})
it('Shows tasks from the current and next month', () => {
const now = new Date()
const nextMonth = now
nextMonth.setDate(1)
nextMonth.setMonth(now.getMonth() + 1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .months')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
it('Shows tasks with dates', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart-container .gantt-chart .tasks .task.nodate')
.should('exist')
})
it('Drags a task around', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks .task')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
})
})
describe('Kanban', () => {
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
cy.get('.kanban .bucket')
.first()
.should('contain', data[0].title)
})
it('Can add a new task to a bucket', () => {
const data = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .button')
.contains('Add another task')
.click()
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .field .control input.input')
.type('New Task{enter}')
cy.get('.kanban .bucket')
.first()
.should('contain', 'New Task')
})
it('Can create a new bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
cy.get('.kanban .bucket.new-bucket input.input')
.type('New Bucket{enter}')
cy.wait(1000) // Wait for the request to finish
cy.get('.kanban .bucket .title')
.contains('New Bucket')
.should('exist')
})
it('Can set a bucket limit', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field a.button.is-primary')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.should('contain', 'New Bucket Title')
})
it('Can delete a bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete the bucket')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('not.exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button')
.contains('Move task')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
})
describe('List history', () => {
it('should show a list history on the home page', () => {
const lists = ListFactory.create(6)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('not.exist')
cy.visit(`/lists/${lists[0].id}`)
cy.visit(`/lists/${lists[1].id}`)
cy.visit(`/lists/${lists[2].id}`)
cy.visit(`/lists/${lists[3].id}`)
cy.visit(`/lists/${lists[4].id}`)
cy.visit(`/lists/${lists[5].id}`)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('exist')
cy.get('.list-cards-wrapper-2-rows')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)
.should('contain', lists[3].title)
.should('contain', lists[4].title)
.should('contain', lists[5].title)
})
})
})

View File

@ -1,35 +0,0 @@
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

@ -1,6 +1,5 @@
import './commands'
import 'cypress-file-upload'
import '@4tw/cypress-drag-drop'
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275

View File

@ -0,0 +1,26 @@
export function updateUserSettings(settings) {
const token = `Bearer ${window.localStorage.getItem('token')}`
return cy.request({
method: 'GET',
url: `${Cypress.env('API_URL')}/user`,
headers: {
'Authorization': token,
},
})
.its('body')
.then(oldSettings => {
return cy.request({
method: 'POST',
url: `${Cypress.env('API_URL')}/user/settings/general`,
headers: {
'Authorization': token,
},
body: {
...oldSettings,
...settings,
},
})
})
}

View File

@ -1,6 +1,6 @@
[build]
command = "yarn build"
publish = "dist"
publish = "dist-preview"
[[redirects]]
from = "/*"

View File

@ -6,79 +6,110 @@ pid /var/run/nginx.pid;
events {
worker_connections 1024;
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
types {
application/manifest+json webmanifest;
}
access_log /var/log/nginx/access.log main;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
sendfile on;
#tcp_nopush on;
access_log /var/log/nginx/access.log main;
keepalive_timeout 65;
sendfile on;
#tcp_nopush on;
gzip on;
gzip_disable "msie6";
keepalive_timeout 65;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon audio/wav;
gzip on;
map_hash_max_size 128;
map_hash_bucket_size 128;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
text/plain
text/css
application/json
application/x-javascript
application/javascript
text/xml
application/xml
application/xml+rss
text/javascript
application/vnd.ms-fontobject
application/x-font-ttf
font/opentype
image/svg+xml
image/x-icon
audio/wav;
# Expires map
map $sent_http_content_type $expires {
default off;
text/html max;
text/css max;
application/javascript max;
text/javascript max;
application/vnd.ms-fontobject max;
application/x-font-ttf max;
font/opentype max;
font/woff2 max;
image/svg+xml max;
image/x-icon max;
audio/wav max;
~image/ max;
~font/ max;
}
map_hash_max_size 128;
map_hash_bucket_size 128;
server {
listen 80;
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
# Expires map
map $sent_http_content_type $expires {
default off;
text/css max;
application/javascript max;
text/javascript max;
application/vnd.ms-fontobject max;
application/x-font-ttf max;
font/opentype max;
font/woff2 max;
image/svg+xml max;
image/x-icon max;
audio/wav max;
~images/ max;
~font/ max;
}
server_name _;
server {
listen 80;
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
expires $expires;
server_name _;
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
root /usr/share/nginx/html;
try_files $uri $uri/ =404;
}
expires $expires;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
index index.html;
}
root /usr/share/nginx/html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
# all assets contain hash in filename, cache forever
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# all workbox scripts are compiled with hash in filename, cache forever3
location ^~ /workbox- {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# assume that everything else is handled by the application router, by injecting the index.html.
location / {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
try_files $uri /index.html =404;
}
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
try_files $uri $uri/ =404;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
}

View File

@ -5,93 +5,92 @@
"scripts": {
"serve": "vite",
"serve:dist-dev": "node scripts/serve-dist.js",
"serve:dist": "vite preview",
"serve:dist": "vite preview --port 4173",
"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": "jest",
"test:unit": "vitest run",
"test:unit-watch": "vitest watch",
"test:frontend": "cypress run",
"browserslist:update": "npx browserslist@latest --update-db"
},
"dependencies": {
"@github/hotkey": "1.6.1",
"@github/hotkey": "2.0.0",
"@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.16.1",
"@sentry/vue": "6.16.1",
"@vue/compat": "3.2.26",
"@vueuse/core": "7.5.1",
"@vueuse/router": "7.5.1",
"@sentry/tracing": "7.1.1",
"@sentry/vue": "7.1.1",
"@types/is-touch-device": "1.0.0",
"@types/sortablejs": "1.13.0",
"@vueuse/core": "8.6.0",
"@vueuse/router": "8.6.0",
"blurhash": "1.1.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.0",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.28.0",
"dompurify": "2.3.4",
"easymde": "2.15.0",
"flatpickr": "4.6.9",
"dompurify": "2.3.8",
"easymde": "2.16.1",
"flatpickr": "4.6.13",
"flexsearch": "0.7.21",
"highlight.js": "11.3.1",
"highlight.js": "11.5.1",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"marked": "4.0.8",
"marked": "4.0.16",
"minimist": "1.2.6",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"ufo": "0.7.9",
"v-tooltip": "4.0.0-beta.2",
"vue": "3.2.26",
"vue-advanced-cropper": "2.7.0",
"ufo": "0.8.4",
"v-tooltip": "4.0.0-beta.17",
"vue": "3.2.37",
"vue-advanced-cropper": "2.8.1",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.5",
"vue-i18n": "9.2.0-beta.26",
"vue-router": "4.0.12",
"vuedraggable": "4.1.0",
"vue-flatpickr-component": "9.0.6",
"vue-i18n": "9.2.0-beta.36",
"vue-router": "4.0.16",
"vuex": "4.0.2",
"workbox-precaching": "6.4.2"
"workbox-precaching": "6.5.3",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.1.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",
"@types/jest": "27.4.0",
"@typescript-eslint/eslint-plugin": "5.8.1",
"@typescript-eslint/parser": "5.8.1",
"@vitejs/plugin-legacy": "1.6.4",
"@vitejs/plugin-vue": "2.0.1",
"@faker-js/faker": "6.3.1",
"@fortawesome/fontawesome-svg-core": "6.1.1",
"@fortawesome/free-regular-svg-icons": "6.1.1",
"@fortawesome/free-solid-svg-icons": "6.1.1",
"@fortawesome/vue-fontawesome": "3.0.0",
"@types/flexsearch": "0.7.3",
"@typescript-eslint/eslint-plugin": "5.27.1",
"@typescript-eslint/parser": "5.27.1",
"@vitejs/plugin-legacy": "1.8.2",
"@vitejs/plugin-vue": "2.3.3",
"@vue/eslint-config-typescript": "10.0.0",
"autoprefixer": "10.4.1",
"axios": "0.24.0",
"browserslist": "4.19.1",
"caniuse-lite": "1.0.30001295",
"cypress": "9.2.0",
"cypress-file-upload": "5.0.8",
"esbuild": "0.14.10",
"eslint": "8.5.0",
"eslint-plugin-vue": "8.2.0",
"express": "4.17.2",
"faker": "5.5.3",
"jest": "27.4.5",
"netlify-cli": "8.6.0",
"postcss": "8.4.5",
"postcss-preset-env": "7.1.0",
"rollup": "2.62.0",
"rollup-plugin-visualizer": "5.5.2",
"sass": "1.45.2",
"slugify": "1.6.4",
"ts-jest": "27.1.2",
"typescript": "4.5.4",
"vite": "2.7.10",
"vite-plugin-pwa": "0.11.12",
"vite-svg-loader": "3.1.1",
"vue-tsc": "0.30.1",
"wait-on": "6.0.0",
"workbox-cli": "6.4.2"
"autoprefixer": "10.4.7",
"axios": "0.27.2",
"browserslist": "4.20.4",
"caniuse-lite": "1.0.30001341",
"cypress": "10.1.0",
"esbuild": "0.14.43",
"eslint": "8.17.0",
"eslint-plugin-vue": "9.1.1",
"express": "4.18.1",
"happy-dom": "5.2.0",
"netlify-cli": "10.3.1",
"postcss": "8.4.14",
"postcss-preset-env": "7.7.1",
"rollup": "2.75.6",
"rollup-plugin-visualizer": "5.6.0",
"sass": "1.52.3",
"typescript": "4.7.3",
"vite": "2.9.12",
"vite-plugin-pwa": "0.12.0",
"vite-svg-loader": "3.3.0",
"vitest": "0.14.2",
"vue-tsc": "0.37.3",
"wait-on": "6.0.1",
"workbox-cli": "6.5.3"
},
"eslintConfig": {
"root": true,
@ -129,7 +128,7 @@
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2021
"ecmaVersion": 2022
},
"ignorePatterns": [
"*.test.*",
@ -144,24 +143,6 @@
"autoprefixer": {}
}
},
"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",
"packageManager": "yarn@1.22.17"
"packageManager": "yarn@1.22.19"
}

View File

@ -3,5 +3,22 @@
"labels": ["dependencies"],
"extends": [
"config:base"
],
"packageRules": [
{
"matchPackageNames": ["netlify-cli"],
"extends": ["schedule:weekly"]
},
{
"groupName": "caniuse-and-related",
"matchPackageNames": ["caniuse-lite", "browserslist"],
"extends": ["schedule:weekly"]
},
{
"groupName": "vueuse",
"matchPackagePrefixes": [
"@vueuse/"
]
}
]
}

2
run.sh
View File

@ -16,7 +16,7 @@ VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
sed -i "s/http\:\/\/localhost\:3456//g" /usr/share/nginx/html/index.html # replacing in two steps to make sure api urls from releases are properly replaced as well
sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html
sed -i "s/\.SENTRY_DSN = '.*'/\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'/g" /usr/share/nginx/html/index.html
sed -i "s|\.SENTRY_DSN = '.*'|\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'|g" /usr/share/nginx/html/index.html
sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf

View File

@ -1,20 +1,24 @@
const slugify = require('slugify')
const {exec} = require('child_process')
const axios = require('axios')
const BOT_USER_ID = 513
const giteaToken = process.env.GITEA_TOKEN
const siteId = process.env.NETLIFY_SITE_ID
const branchSlug = slugify(process.env.DRONE_SOURCE_BRANCH)
const branchSlug = String(process.env.DRONE_SOURCE_BRANCH)
.trim()
.normalize('NFKD')
.toLowerCase()
.replace(/[.\s/]/g, '-')
.replace(/[^A-Za-z\d-]/g, '')
const prNumber = process.env.DRONE_PULL_REQUEST
const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments`
const alias = `${prNumber}-${branchSlug}`
const alias = `${prNumber}-${branchSlug}`.substring(0,37)
const fullPreviewUrl = `https://${alias}--vikunja-frontend-preview.netlify.app`
const promiseExec = cmd => {
return new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
exec(cmd, (error, stdout) => {
if (error) {
reject(error)
return

View File

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

View File

@ -3,7 +3,7 @@ const express = require('express')
const app = express()
const p = path.join(__dirname, '..', 'dist-dev')
const port = 5000
const port = 4173
app.use(express.static(p))
// Handle urls set by the frontend

View File

@ -1,16 +1,14 @@
<template>
<ready :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"/>
<no-auth-wrapper v-else>
<router-view/>
</no-auth-wrapper>
<Notification/>
</div>
<ready>
<template v-if="authUser">
<TheNavigation/>
<content-auth/>
</template>
<content-link-share v-else-if="authLinkShare"/>
<no-auth-wrapper v-else>
<router-view/>
</no-auth-wrapper>
<Notification/>
<transition name="fade">
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
@ -19,18 +17,17 @@
</template>
<script lang="ts" setup>
import {computed, watch, watchEffect, Ref} from 'vue'
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'
import {useOnline} from '@vueuse/core'
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 TheNavigation from '@/components/home/TheNavigation.vue'
import ContentAuth from './components/home/contentAuth.vue'
import ContentLinkShare from './components/home/contentLinkShare.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
@ -38,23 +35,20 @@ import Ready from '@/components/misc/ready.vue'
import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete'
import {ONLINE} from '@/store/mutation-types'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
const store = useStore()
const online = useOnline()
watchEffect(() => store.commit(ONLINE, online.value))
const router = useRouter()
const isTouch = computed(isTouchDevice)
useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
const authUser = computed(() => store.getters['auth/authUser'])
const authLinkShare = computed(() => store.getters['auth/authLinkShare'])
const {t} = useI18n()
const {t} = useI18n({useScope: 'global'})
// setup account deletion verification
const accountDeletionConfirm = useRouteQuery('accountDeletionConfirm') as Ref<null | string>
@ -69,7 +63,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
store.dispatch('auth/refreshUserInfo')
}, { immediate: true })
// setup passwort reset redirect
// setup password reset redirect
const userPasswordReset = useRouteQuery('userPasswordReset') as Ref<null | string>
watch(userPasswordReset, (userPasswordReset) => {
if (userPasswordReset === null) {

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

View File

@ -0,0 +1,120 @@
<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">
import {defineComponent} from 'vue'
// see https://v3.vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script
export default defineComponent({
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 a predefined value for the attribute rel, but make it possible to overwrite this by the user.
if ('href' in attrs) {
nodeName = 'a'
bindings = {rel: 'noreferrer noopener nofollow'}
}
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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,11 @@
<template>
<a class="menu-bottom-link" :href="poweredByUrl" target="_blank" rel="noreferrer noopener nofollow">
<BaseButton class="menu-bottom-link" :href="poweredByUrl" target="_blank">
{{ $t('misc.poweredBy') }}
</a>
</BaseButton>
</template>
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
import {POWERED_BY as poweredByUrl} from '@/urls'
</script>

View File

@ -1,9 +1,8 @@
<template>
<nav
:class="{'has-background': background}"
<header
:class="{'has-background': background, 'menu-active': menuActive}"
aria-label="main navigation"
class="navbar main-theme is-fixed-top"
role="navigation"
class="navbar main-theme is-fixed-top d-print-none"
>
<router-link :to="{name: 'home'}" class="logo-link">
<Logo width="164" height="48"/>
@ -33,12 +32,15 @@
</a>
<notifications/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<dropdown class="is-right" ref="usernameDropdown">
<template #trigger>
<template #trigger="{toggleOpen}">
<x-button
type="secondary"
:shadow="false">
class="username-dropdown-trigger"
@click="toggleOpen()"
variant="secondary"
:shadow="false"
>
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
@ -46,92 +48,98 @@
</x-button>
</template>
<router-link :to="{name: 'user.settings'}" class="dropdown-item">
<BaseButton
:to="{name: 'user.settings'}"
class="dropdown-item"
>
{{ $t('user.settings.title') }}
</router-link>
<a
</BaseButton>
<BaseButton
v-if="imprintUrl"
:href="imprintUrl"
class="dropdown-item"
target="_blank"
rel="noreferrer noopener nofollow"
v-if="imprintUrl">
>
{{ $t('navigation.imprint') }}
</a>
<a
</BaseButton>
<BaseButton
v-if="privacyPolicyUrl"
:href="privacyPolicyUrl"
class="dropdown-item"
target="_blank"
rel="noreferrer noopener nofollow"
v-if="privacyPolicyUrl">
>
{{ $t('navigation.privacy') }}
</a>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">
</BaseButton>
<BaseButton
@click="$store.commit('keyboardShortcutsActive', true)"
class="dropdown-item"
>
{{ $t('keyboardShortcuts.title') }}
</a>
<router-link :to="{name: 'about'}" class="dropdown-item">
</BaseButton>
<BaseButton
:to="{name: 'about'}"
class="dropdown-item"
>
{{ $t('about.title') }}
</router-link>
<a @click="logout()" class="dropdown-item">
</BaseButton>
<BaseButton
@click="logout()"
class="dropdown-item"
>
{{ $t('user.auth.logout') }}
</a>
</BaseButton>
</dropdown>
</div>
</div>
</nav>
</header>
</template>
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
<script setup lang="ts">
import {ref, computed, onMounted, nextTick} from 'vue'
import {useStore} from 'vuex'
import {useRouter} from 'vue-router'
import {QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import Rights from '@/models/constants/rights.json'
import Update from '@/components/home/update.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import Notifications from '@/components/notifications/notifications.vue'
import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
export default {
name: 'topNavigation',
components: {
Notifications,
Dropdown,
ListSettingsDropdown,
Update,
Logo,
MenuButton,
},
computed: {
...mapState({
userInfo: state => state.auth.info,
userAvatar: state => state.auth.avatarUrl,
userAuthenticated: state => state.auth.authenticated,
currentList: CURRENT_LIST,
background: 'background',
imprintUrl: state => state.config.legal.imprintUrl,
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
canWriteCurrentList: state => state.currentList.maxRight > Rights.READ,
}),
},
mounted() {
this.$nextTick(() => {
if (typeof this.$refs.usernameDropdown === 'undefined' || typeof this.$refs.listTitle === 'undefined') {
return
}
const store = useStore()
const usernameWidth = this.$refs.usernameDropdown.$el.clientWidth
this.$refs.listTitle.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
},
methods: {
logout() {
this.$store.dispatch('auth/logout')
this.$router.push({name: 'user.login'})
},
openQuickActions() {
this.$store.commit(QUICK_ACTIONS_ACTIVE, true)
},
},
const userInfo = computed(() => store.state.auth.info)
const userAvatar = computed(() => store.state.auth.avatarUrl)
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
const canWriteCurrentList = computed(() => store.state.currentList.maxRight > Rights.READ)
const menuActive = computed(() => store.state.menuActive)
const usernameDropdown = ref()
const listTitle = ref()
onMounted(async () => {
await nextTick()
if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
return
}
const usernameWidth = usernameDropdown.value.$el.clientWidth
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
const router = useRouter()
function logout() {
store.dispatch('auth/logout')
router.push({name: 'user.login'})
}
function openQuickActions() {
store.commit(QUICK_ACTIONS_ACTIVE, true)
}
</script>
@ -142,10 +150,6 @@ $user-dropdown-width-mobile: 5rem;
$hamburger-menu-icon-spacing: 1rem;
$hamburger-menu-icon-width: 28px;
.navbar {
z-index: 4 !important;
}
.logo-link {
display: none;
padding: 0.5rem 0.75rem;
@ -170,7 +174,6 @@ $hamburger-menu-icon-width: 28px;
.navbar.main-theme {
background: var(--site-background);
z-index: 5 !important;
justify-content: space-between;
align-items: center;
@ -194,21 +197,20 @@ $hamburger-menu-icon-width: 28px;
}
@media screen and (max-width: $tablet) {
&.menu-active {
z-index: 0;
}
.user {
width: $user-dropdown-width-mobile;
display: flex;
align-items: center;
:deep(.dropdown-trigger) {
.username-dropdown-trigger {
line-height: 1;
padding: 0 0.25rem;
height: 1rem;
.button {
padding: 0 0.25rem;
height: 1rem;
.icon {
width: .5rem;
}
.icon {
width: .5rem;
}
}
@ -247,9 +249,10 @@ $hamburger-menu-icon-width: 28px;
border-radius: 100%;
vertical-align: middle;
height: 40px;
margin-right: .5rem;
}
:deep(.dropdown-trigger .button) {
.username-dropdown-trigger {
background: none;
&:focus:not(:active), &:active {

View File

@ -1,47 +1,68 @@
<template>
<div>
<a @click="$store.commit('menuActive', false)" class="menu-hide-button" v-if="menuActive">
<icon icon="times" />
</a>
<div class="content-auth">
<BaseButton
v-if="menuActive"
@click="$store.commit('menuActive', false)"
class="menu-hide-button d-print-none"
>
<icon icon="times"/>
</BaseButton>
<div
:class="{'has-background': background}"
:style="{'background-image': background && `url(${background})`}"
:class="{'has-background': background || blurHash}"
:style="{'background-image': blurHash && `url(${blurHash})`}"
class="app-container"
>
<navigation/>
<div
:class="{'is-visible': background}"
class="app-container-background background-fade-in d-print-none"
:style="{'background-image': background && `url(${background})`}"></div>
<navigation class="d-print-none"/>
<main
:class="[
{ 'is-menu-enabled': menuActive },
$route.name,
]"
class="app-content"
>
<a @click="$store.commit('menuActive', false)" class="mobile-overlay" v-if="menuActive"></a>
<BaseButton
v-if="menuActive"
@click="$store.commit('menuActive', false)"
class="mobile-overlay d-print-none"
/>
<quick-actions/>
<router-view/>
<router-view name="popup" v-slot="{ Component }">
<transition name="modal">
<component :is="Component" />
</transition>
<router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/>
</keep-alive>
</router-view>
<transition name="modal">
<modal
v-if="currentModal"
@close="closeModal()"
variant="scrolling"
class="task-detail-view-modal"
>
<component :is="currentModal"/>
</modal>
</transition>
<a
class="keyboard-shortcuts-button"
class="keyboard-shortcuts-button d-print-none"
@click="showKeyboardShortcuts()"
v-shortcut="'?'"
>
<icon icon="keyboard"/>
</a>
</div>
</main>
</div>
</div>
</template>
<script lang="ts" setup>
import {watch, computed} from 'vue'
import {watch, computed, shallowRef, watchEffect, VNode, h} from 'vue'
import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
@ -49,10 +70,63 @@ import {useEventListener} from '@vueuse/core'
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'
import BaseButton from '@/components/base/BaseButton.vue'
function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const routeWithModal = computed(() => {
return backdropView.value
? router.resolve(backdropView.value)
: route
})
const currentModal = shallowRef<VNode>()
watchEffect(() => {
if (!backdropView.value) {
currentModal.value = undefined
return
}
// logic from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
const routePropsOption = route.matched[0]?.props.default
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null
currentModal.value = h(
route.matched[0]?.components.default,
routeProps,
)
})
function closeModal() {
const historyState = computed(() => route.fullPath && window.history.state)
if (historyState.value) {
router.back()
} else {
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
router.push(backdropRoute)
}
}
return {routeWithModal, currentModal, closeModal}
}
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
const store = useStore()
const background = computed(() => store.state.background)
const blurHash = computed(() => store.state.blurHash)
const menuActive = computed(() => store.state.menuActive)
function showKeyboardShortcuts() {
@ -80,11 +154,11 @@ watch(() => route.name as string, (routeName) => {
'migrate.start',
'migrate.wunderlist',
'namespaces.index',
].includes(routeName) ||
].includes(routeName) ||
routeName.startsWith('user.settings')
)
) {
store.dispatch(CURRENT_LIST, null)
store.dispatch(CURRENT_LIST, {list: null})
}
})
@ -92,7 +166,7 @@ watch(() => route.name as string, (routeName) => {
function useRenewTokenOnFocus() {
const router = useRouter()
const userInfo = computed(() => store.state.auth.info)
const authenticated = computed(() => store.state.auth.authenticated)
@ -156,40 +230,47 @@ store.dispatch('labels/loadAllLabels')
}
.app-container {
min-height: calc(100vh - 65px);
min-height: calc(100vh - 65px);
@media screen and (max-width: $tablet) {
padding-top: $navbar-height;
}
.app-content {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
z-index: 2;
@media screen and (max-width: $tablet) {
margin-left: 0;
padding-top: 1.5rem;
min-height: calc(100vh - 4rem);
}
&.is-menu-enabled {
margin-left: $navbar-width;
@media screen and (max-width: $tablet) {
min-width: 100%;
margin-left: 0;
}
}
&.task\.detail {
padding-left: 0;
padding-right: 0;
@media screen and (max-width: $tablet) {
padding-top: $navbar-height;
}
.card {
background: var(--white);
}
}
.app-content {
z-index: 10;
position: relative;
padding-top: 1rem;
@media screen {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
}
// Used to make sure the spinner is always in the middle while loading
> .loader-container {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
}
@media screen and (max-width: $tablet) {
margin-left: 0;
padding-top: 1.5rem;
min-height: calc(100vh - 4rem);
}
@media screen {
&.is-menu-enabled {
margin-left: $navbar-width;
@media screen and (max-width: $tablet) {
min-width: 100%;
margin-left: 0;
}
}
}
.card {
background: var(--white);
}
}
}
.mobile-overlay {
@ -199,7 +280,9 @@ store.dispatch('labels/loadAllLabels')
bottom: 0;
left: 0;
right: 0;
background: rgba(250, 250, 250, 0.8);
height: 100vh;
width: 100vw;
background: hsla(var(--grey-100-hsl), 0.8);
z-index: 5;
opacity: 0;
transition: all $transition;
@ -218,9 +301,21 @@ store.dispatch('labels/loadAllLabels')
color: var(--grey-500);
transition: color $transition;
@media screen and (max-width: $tablet) {
display: none;
}
}
.content-auth {
position: relative;
z-index: 1;
}
.is-touch .content-auth,
.content-auth.z-unset {
z-index: unset;
}
@include modal-transition();
</style>

View File

@ -1,6 +1,6 @@
<template>
<div
:class="[background ? 'has-background' : '', $route.name+'-view']"
:class="[background ? 'has-background' : '', $route.name as string +'-view']"
:style="{'background-image': `url(${background})`}"
class="link-share-container"
>

View File

@ -1,12 +1,12 @@
<template>
<div :class="{'is-active': menuActive}" class="namespace-container">
<div class="menu top-menu">
<aside :class="{'is-active': menuActive}" class="namespace-container">
<nav class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48" />
<Logo width="164" height="48"/>
</router-link>
<ul class="menu-list">
<li>
<router-link :to="{ name: 'home'}">
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
<span class="icon">
<icon icon="calendar"/>
</span>
@ -14,7 +14,7 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'tasks.range'}">
<router-link :to="{ name: 'tasks.range'}" v-shortcut="'g u'">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
@ -22,7 +22,7 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'namespaces.index'}">
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
<span class="icon">
<icon icon="layer-group"/>
</span>
@ -30,7 +30,7 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'labels.index'}">
<router-link :to="{ name: 'labels.index'}" v-shortcut="'g a'">
<span class="icon">
<icon icon="tags"/>
</span>
@ -38,7 +38,7 @@
</router-link>
</li>
<li>
<router-link :to="{ name: 'teams.index'}">
<router-link :to="{ name: 'teams.index'}" v-shortcut="'g m'">
<span class="icon">
<icon icon="users"/>
</span>
@ -46,31 +46,35 @@
</router-link>
</li>
</ul>
</div>
</nav>
<aside class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id" >
<nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id">
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
<span
@click="toggleLists(n.id)"
class="menu-label"
v-tooltip="namespaceTitles[nk]">
v-tooltip="namespaceTitles[nk]"
>
<span
v-if="n.hexColor !== ''"
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
/>
<span class="name">
<span
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
v-if="n.hexColor !== ''">
</span>
{{ namespaceTitles[nk] }}
</span>
<a
class="icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
@click="toggleLists(n.id)"
>
<icon icon="chevron-down"/>
</a>
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
({{ namespaceListsCount[nk] }})
</span>
</span>
<a
class="icon is-small toggle-lists-icon"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
@click="toggleLists(n.id)"
>
<icon icon="chevron-down"/>
</a>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div>
<div
@ -81,18 +85,20 @@
<!--
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
triggered by the change needs to have access to the current namespace
-->
-->
<draggable
v-bind="dragOptions"
:modelValue="activeLists[nk]"
@update:modelValue="(lists) => updateActiveLists(n, lists)"
:group="`namespace-${n.id}-lists`"
group="namespace-lists"
@start="() => drag = true"
@end="e => saveListPosition(e, nk)"
@end="saveListPosition"
handle=".handle"
:disabled="n.id < 0 || null"
:disabled="n.id < 0 || undefined"
tag="transition-group"
item-key="id"
:data-namespace-id="n.id"
:data-namespace-index="nk"
:component-data="{
type: 'transition',
tag: 'ul',
@ -134,7 +140,7 @@
:class="{'is-favorite': l.isFavorite}"
@click.prevent.stop="toggleFavoriteList(l)"
class="favorite">
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']" />
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</span>
</a>
</router-link>
@ -145,136 +151,140 @@
</draggable>
</div>
</template>
</aside>
<PoweredByLink />
</div>
</nav>
<PoweredByLink/>
</aside>
</template>
<script>
import {mapState} from 'vuex'
import draggable from 'vuedraggable'
<script setup lang="ts">
import {ref, computed, onMounted, onBeforeMount} from 'vue'
import {useStore} from 'vuex'
import draggable from 'zhyswan-vuedraggable'
import {SortableEvent} from 'sortablejs'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
import {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types'
import {MENU_ACTIVE} from '@/store/mutation-types'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {useEventListener} from '@vueuse/core'
import NamespaceModel from '@/models/namespace'
import ListModel from '@/models/list'
const drag = ref(false)
const dragOptions = {
animation: 100,
ghostClass: 'ghost',
}
const store = useStore()
const currentList = computed(() => store.state.currentList)
const menuActive = computed(() => store.state.menuActive)
const loading = computed(() => store.state.loading && store.state.loadingModule === 'namespaces')
export default {
name: 'navigation',
const namespaces = computed(() => {
return (store.state.namespaces.namespaces as NamespaceModel[]).filter(n => !n.isArchived)
})
const activeLists = computed(() => {
return namespaces.value.map(({lists}) => {
return lists?.filter(item => {
return typeof item !== 'undefined' && !item.isArchived
})
})
})
components: {
ListSettingsDropdown,
NamespaceSettingsDropdown,
draggable,
Logo,
PoweredByLink,
},
const namespaceTitles = computed(() => {
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
})
data() {
return {
listsVisible: {},
drag: false,
dragOptions: {
animation: 100,
ghostClass: 'ghost',
},
listUpdating: {},
const namespaceListsCount = computed(() => {
return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0)
})
useEventListener('resize', resize)
onMounted(() => resize())
function toggleFavoriteList(list: ListModel) {
// 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)
}
function resize() {
// Hide the menu by default on mobile
store.commit(MENU_ACTIVE, window.innerWidth >= 770)
}
function toggleLists(namespaceId: number) {
listsVisible.value[namespaceId] = !listsVisible.value[namespaceId]
}
const listsVisible = ref<{ [id: NamespaceModel['id']]: boolean }>({})
// FIXME: async action will be unfinished when component mounts
onBeforeMount(async () => {
const namespaces = await store.dispatch('namespaces/loadNamespaces') as NamespaceModel[]
namespaces.forEach(n => {
if (typeof listsVisible.value[n.id] === 'undefined') {
listsVisible.value[n.id] = true
}
},
computed: {
...mapState({
namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived),
currentList: CURRENT_LIST,
background: 'background',
menuActive: MENU_ACTIVE,
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
}),
activeLists() {
return this.namespaces.map(({lists}) => lists?.filter(item => !item.isArchived))
},
namespaceTitles() {
return this.namespaces.map((namespace, index) => {
const title = this.getNamespaceTitle(namespace)
return `${title} (${this.activeLists[index]?.length ?? 0})`
})
},
},
beforeCreate() {
// FIXME: async action in beforeCreate, might be unfinished when component mounts
this.$store.dispatch('namespaces/loadNamespaces')
.then(namespaces => {
namespaces.forEach(n => {
if (typeof this.listsVisible[n.id] === 'undefined') {
this.listsVisible[n.id] = true
}
})
})
},
created() {
window.addEventListener('resize', this.resize)
},
mounted() {
this.resize()
},
methods: {
toggleFavoriteList(list) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
},
resize() {
// Hide the menu by default on mobile
this.$store.commit(MENU_ACTIVE, window.innerWidth >= 770)
},
toggleLists(namespaceId) {
this.listsVisible[namespaceId] = !this.listsVisible[namespaceId]
},
updateActiveLists(namespace, activeLists) {
// this is a bit hacky: since we do have to filter out the archived items from the list
// for vue draggable updating it is not as simple as replacing it.
// instead we iterate over the non archived items in the old list and replace them with the ones in their new order
const lists = namespace.lists.map((item) => {
if (item.isArchived) {
return item
}
return activeLists.shift()
})
})
})
const newNamespace = {
...namespace,
lists,
}
function updateActiveLists(namespace: NamespaceModel, activeLists: ListModel[]) {
// This is a bit hacky: since we do have to filter out the archived items from the list
// for vue draggable updating it is not as simple as replacing it.
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
// later when showing them anyway, and it makes the merging happening here a lot easier.
const lists = [
...activeLists,
...namespace.lists.filter(l => l.isArchived),
]
this.$store.commit('namespaces/setNamespaceById', newNamespace)
},
store.commit('namespaces/setNamespaceById', {
...namespace,
lists,
})
}
async saveListPosition(e, namespaceIndex) {
const listsActive = this.activeLists[namespaceIndex]
const list = listsActive[e.newIndex]
const listBefore = listsActive[e.newIndex - 1] ?? null
const listAfter = listsActive[e.newIndex + 1] ?? null
this.listUpdating[list.id] = true
const listUpdating = ref<{ [id: NamespaceModel['id']]: boolean }>({})
const position = calculateItemPosition(listBefore !== null ? listBefore.position : null, listAfter !== null ? listAfter.position : null)
async function saveListPosition(e: SortableEvent) {
if (!e.newIndex) return
try {
// create a copy of the list in order to not violate vuex mutations
await this.$store.dispatch('lists/updateList', {
...list,
position,
})
} finally {
this.listUpdating[list.id] = false
}
},
},
const namespaceId = parseInt(e.to.dataset.namespaceId as string)
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
const listsActive = activeLists.value[newNamespaceIndex]
const list = listsActive[e.newIndex]
const listBefore = listsActive[e.newIndex - 1] ?? null
const listAfter = listsActive[e.newIndex + 1] ?? null
listUpdating.value[list.id] = true
const position = calculateItemPosition(
listBefore !== null ? listBefore.position : null,
listAfter !== null ? listAfter.position : null,
)
try {
// create a copy of the list in order to not violate vuex mutations
await store.dispatch('lists/updateList', {
...list,
position,
namespaceId,
})
} finally {
listUpdating.value[list.id] = false
}
}
</script>
@ -285,7 +295,6 @@ $vikunja-nav-color: var(--grey-700);
$vikunja-nav-selected-width: 0.4rem;
.namespace-container {
z-index: 6;
background: $vikunja-nav-background;
color: $vikunja-nav-color;
padding: 0 0 1rem;
@ -298,10 +307,10 @@ $vikunja-nav-selected-width: 0.4rem;
overflow-x: auto;
width: $navbar-width;
@media screen and (max-width: $tablet) {
top: 0;
width: 70vw;
z-index: 20;
}
&.is-active {
@ -361,12 +370,17 @@ $vikunja-nav-selected-width: 0.4rem;
&:hover .favorite {
opacity: 1;
}
&:hover {
background: transparent;
}
}
.menu-label {
.color-bubble {
width: 14px !important;
height: 14px !important;
width: 14px;
height: 14px;
flex-basis: auto;
}
.is-archived {
@ -387,6 +401,12 @@ $vikunja-nav-selected-width: 0.4rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: auto;
}
.count {
color: var(--grey-500);
margin-right: .5rem;
}
}
@ -463,10 +483,6 @@ $vikunja-nav-selected-width: 0.4rem;
}
}
a:hover {
background: transparent;
}
span.list-menu-link, li > a {
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
transition: all 0.2s ease;
@ -482,7 +498,7 @@ $vikunja-nav-selected-width: 0.4rem;
height: 1rem;
vertical-align: middle;
padding-right: 0.5rem;
&.handle {
opacity: 0;
transition: opacity $transition;
@ -490,7 +506,7 @@ $vikunja-nav-selected-width: 0.4rem;
cursor: grab;
}
}
&:hover .icon.handle {
opacity: 1;
}
@ -513,8 +529,9 @@ $vikunja-nav-selected-width: 0.4rem;
.logo {
display: block;
padding-left: 2rem;
padding-left: 1rem;
margin-right: 1rem;
margin-bottom: 1rem;
@media screen and (min-width: $tablet) {
display: none;
@ -542,7 +559,7 @@ $vikunja-nav-selected-width: 0.4rem;
span.list-menu-link, li > a {
padding-left: 2rem;
display: inline-block;
.icon {
padding-bottom: .25rem;
}
@ -552,7 +569,15 @@ $vikunja-nav-selected-width: 0.4rem;
}
.list-setting-spacer {
width: 32px;
width: 2.5rem;
flex-shrink: 0;
}
.namespaces-list.loader-container.is-loading {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
}
a.dropdown-item:hover {
background: var(--dropdown-item-hover-background-color) !important;
}
</style>

View File

@ -7,54 +7,47 @@
</div>
</template>
<script>
export default {
name: 'update',
data() {
return {
updateAvailable: false,
registration: null,
refreshing: false,
}
},
created() {
document.addEventListener('swUpdated', this.showRefreshUI, {once: true})
<script lang="ts" setup>
import {ref} from 'vue'
if (navigator && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (this.refreshing) return
this.refreshing = true
window.location.reload()
},
)
}
},
methods: {
showRefreshUI(e) {
console.log('recieved refresh event', e)
this.registration = e.detail
this.updateAvailable = true
const updateAvailable = ref(false)
const registration = ref(null)
const refreshing = ref(false)
document.addEventListener('swUpdated', showRefreshUI, {once: true})
if (navigator && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (refreshing.value) return
refreshing.value = true
window.location.reload()
},
refreshApp() {
this.updateExists = false
if (!this.registration || !this.registration.waiting) {
return
}
// Notify the service worker to actually do the update
this.registration.waiting.postMessage('skipWaiting')
},
},
)
}
function showRefreshUI(e) {
console.log('recieved refresh event', e)
registration.value = e.detail
updateAvailable.value = true
}
function refreshApp() {
if (!registration.value || !registration.value.waiting) {
return
}
// Notify the service worker to actually do the update
registration.value.waiting.postMessage('skipWaiting')
}
</script>
<style lang="scss" scoped>
.update-notification {
margin: 1rem;
margin: .5rem;
display: flex;
align-items: center;
background: $warning;
padding: 0 0 0 .5rem;
padding: 0 .25rem;
border-radius: $radius;
font-size: .9rem;
color: var(--grey-900);
@ -66,6 +59,7 @@ export default {
margin: 0;
width: 450px;
left: calc(50vw - 225px);
padding: .5rem;
}
@media screen and (max-width: $tablet) {
@ -85,4 +79,8 @@ export default {
margin-left: .5rem;
}
}
.dark .update-notification {
color: var(--grey-200);
}
</style>

View File

@ -1,77 +1,65 @@
<template>
<a
<BaseButton
class="button"
: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"
:class="[
variantClass,
{
'is-loading': loading,
'has-no-shadow': !shadow || variant === 'tertiary',
}
]"
>
<icon :icon="icon" v-if="showIconOnly"/>
<span class="icon is-small" v-else-if="icon !== ''">
<icon :icon="icon"/>
</span>
<slot></slot>
</a>
<slot />
</BaseButton>
</template>
<script>
export default {
<script lang="ts">
import {defineComponent} from 'vue'
export default defineComponent({
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
}
})
</script>
if (this.to !== false) {
this.$router.push(this.to)
}
<script setup lang="ts">
import {computed, useSlots, PropType} from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
this.$emit('click', e)
},
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: {
type: [String, Array],
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>
@ -81,10 +69,10 @@ export default {
text-transform: uppercase;
font-size: 0.85rem;
font-weight: bold;
height: $button-height;
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
&.is-hovered,
&:hover {
box-shadow: var(--shadow-md);
}
@ -106,9 +94,10 @@ export default {
color: var(--white);
}
&.is-small {
border-radius: $radius;
}
}
.is-small {
border-radius: $radius;
}
.underline-none {

View File

@ -1,7 +1,7 @@
<template>
<div class="color-picker-container">
<datalist :id="colorListID">
<option v-for="color in defaultColors" :key="color" :value="color" />
<option v-for="defaultColor in defaultColors" :key="defaultColor" :value="defaultColor" />
</datalist>
<div class="picker">
@ -27,14 +27,16 @@
@click="reset"
class="is-small ml-2"
:shadow="false"
type="secondary"
variant="secondary"
>
{{ $t('input.resetColor') }}
</x-button>
</div>
</template>
<script>
<script lang="ts">
import {defineComponent} from 'vue'
import {createRandomID} from '@/helpers/randomId'
const DEFAULT_COLORS = [
@ -46,7 +48,7 @@ const DEFAULT_COLORS = [
'#00db60',
]
export default {
export default defineComponent({
name: 'colorPicker',
data() {
return {
@ -58,6 +60,7 @@ export default {
},
props: {
modelValue: {
type: String,
required: true,
},
menuPosition: {
@ -105,7 +108,7 @@ export default {
this.update(true)
},
},
}
})
</script>
<style lang="scss" scoped>

View File

@ -101,6 +101,7 @@
class="is-fullwidth"
:shadow="false"
@click="close"
v-cy="'closeDatepicker'"
>
{{ $t('misc.confirm') }}
</x-button>
@ -109,10 +110,12 @@
</div>
</template>
<script>
<script lang="ts">
import {defineComponent} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import { i18n } from '@/i18n'
import {i18n} from '@/i18n'
import {format} from 'date-fns'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
@ -120,17 +123,13 @@ import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {createDateFromString} from '@/helpers/time/createDateFromString'
export default {
export default defineComponent({
name: 'datepicker',
data() {
return {
date: null,
show: false,
changed: false,
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
flatPickrDate: null,
}
},
components: {
@ -163,10 +162,6 @@ export default {
handler: 'setDateValue',
immediate: true,
},
flatPickrDate(newVal) {
this.date = createDateFromString(newVal)
this.updateData()
},
},
computed: {
flatPickerConfig() {
@ -182,10 +177,25 @@ export default {
},
}
},
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
flatPickrDate: {
set(newValue) {
this.date = createDateFromString(newValue)
this.updateData()
},
get() {
if (!this.date) {
return ''
}
return format(this.date, 'yyy-LL-dd H:mm')
},
},
},
methods: {
setDateValue(newVal) {
if(newVal === null) {
if (newVal === null) {
this.date = null
return
}
@ -197,7 +207,7 @@ export default {
this.$emit('change', this.date)
},
toggleDatePopup() {
if(this.disabled) {
if (this.disabled) {
return
}
@ -209,12 +219,16 @@ export default {
}
},
close() {
this.show = false
this.$emit('close', this.changed)
if(this.changed) {
this.changed = false
this.$emit('close-on-change', this.changed)
}
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
// having to click on another input field before it is actually used.
setTimeout(() => {
this.show = false
this.$emit('close', this.changed)
if (this.changed) {
this.changed = false
this.$emit('close-on-change', this.changed)
}
}, 200)
},
setDate(date) {
if (this.date === null) {
@ -241,7 +255,7 @@ export default {
return format(newDate, 'E')
},
},
}
})
</script>
<style lang="scss" scoped>
@ -280,7 +294,7 @@ export default {
}
&:hover {
background: var(--light);
background: var(--grey-100);
}
.text {
@ -301,7 +315,7 @@ export default {
text-align: center;
}
}
a.button {
margin: 1rem;
width: calc(100% - 2rem);

View File

@ -1,7 +1,7 @@
<template>
<div class="editor">
<div class="clear"></div>
<vue-easymde
:configs="config"
@change="bubble"
@ -16,11 +16,11 @@
<p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText">
{{ emptyText }}
<template v-if="isEditEnabled">
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>.
<a @click="toggleEdit" class="d-print-none">{{ $t('input.editor.edit') }}</a>.
</template>
</p>
<ul class="actions" v-if="bottomActions.length > 0">
<ul class="actions d-print-none" v-if="bottomActions.length > 0">
<li v-if="isEditEnabled && !showPreviewText && showSave">
<a v-if="showEditButton" @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
<a v-else-if="isEditActive" @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</a>
@ -30,20 +30,27 @@
</li>
</ul>
<template v-else-if="isEditEnabled && showSave">
<ul v-if="showEditButton" class="actions">
<ul v-if="showEditButton" class="actions d-print-none">
<li>
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
</li>
</ul>
<x-button v-else-if="isEditActive" @click="toggleEdit" type="secondary" :shadow="false">
<x-button
v-else-if="isEditActive"
@click="toggleEdit"
variant="secondary"
:shadow="false"
v-cy="'saveEditor'">
{{ $t('misc.save') }}
</x-button>
</template>
</div>
</template>
<script>
import VueEasymde from './vue-easymde/vue-easymde.vue'
<script lang="ts">
import {defineComponent} from 'vue'
import VueEasymde from './vue-easymde.vue'
import {marked} from 'marked'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/common'
@ -55,7 +62,7 @@ import AttachmentService from '../../services/attachment'
import {findCheckboxesInText} from '../../helpers/checklistFromText'
import {createRandomID} from '@/helpers/randomId'
export default {
export default defineComponent({
name: 'editor',
components: {
VueEasymde,
@ -174,7 +181,7 @@ export default {
}
this.changeTimeout = setTimeout(() => {
this.$emit('update:modelValue',this.text)
this.$emit('update:modelValue', this.text)
this.$emit('change', this.text)
}, timeout)
},
@ -304,16 +311,13 @@ export default {
}
},
},
}
})
</script>
<style lang="scss">
@import 'codemirror/lib/codemirror.css';
@import './vue-easymde/vue-easymde.css';
@import 'highlight.js/scss/base16/equilibrium-gray-light';
$editor-border-color: #ddd;
.editor {
.clear {
clear: both;
@ -321,6 +325,8 @@ $editor-border-color: #ddd;
.preview.content {
margin-bottom: .5rem;
overflow-wrap: anywhere; // Safari does not understand "break-word" so we put that first to make sure it at least is able to show it somewhat properly there.
overflow-wrap: break-word;
ul li {
input[type="checkbox"] {
@ -328,7 +334,7 @@ $editor-border-color: #ddd;
}
&.has-checkbox {
margin-left: -2em;
margin-left: -1.25rem;
list-style: none;
}
}
@ -337,7 +343,7 @@ $editor-border-color: #ddd;
.CodeMirror {
padding: .5rem;
border: 1px solid $editor-border-color;
border: 1px solid var(--grey-200) !important;
background: var(--white);
&-lines pre {
@ -348,6 +354,10 @@ $editor-border-color: #ddd;
color: var(--grey-400) !important;
font-style: italic;
}
&-cursor {
border-color: var(--grey-700);
}
}
.editor-preview {
@ -359,12 +369,17 @@ $editor-border-color: #ddd;
}
.editor-toolbar {
background: #ffffff;
border-top: 1px solid $editor-border-color;
border-left: 1px solid $editor-border-color;
border-right: 1px solid $editor-border-color;
background: var(--grey-50);
border: 1px solid var(--grey-200);
border-bottom: none;
button {
color: var(--grey-700);
&.active {
background: var(--grey-200);
}
svg {
vertical-align: middle;
@ -379,6 +394,15 @@ $editor-border-color: #ddd;
top: 24px;
margin-left: -3px;
}
&:hover {
background: var(--grey-200);
border-color: var(--grey-300);
}
}
i.separator {
border-color: var(--grey-200) !important;
}
}

View File

@ -1,134 +0,0 @@
import EasyMDE from 'easymde'
import {i18n} from '@/i18n'
export function createEasyMDEConfig({ placeholder, uploadImage, imageUploadFunction }) {
return {
autoDownloadFontAwesome: false,
spellChecker: false,
placeholder,
uploadImage,
imageUploadFunction,
minHeight: '150px',
toolbar: [
{
name: 'heading-1',
action: EasyMDE.toggleHeading1,
title: i18n.global.t('input.editor.heading1'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-2',
action: EasyMDE.toggleHeading2,
title: i18n.global.t('input.editor.heading2'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-3',
action: EasyMDE.toggleHeading3,
title: i18n.global.t('input.editor.heading3'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-smaller',
action: EasyMDE.toggleHeadingSmaller,
title: i18n.global.t('input.editor.headingSmaller'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-bigger',
action: EasyMDE.toggleHeadingBigger,
title: i18n.global.t('input.editor.headingBigger'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
'|',
{
name: 'bold',
action: EasyMDE.toggleBold,
title: i18n.global.t('input.editor.bold'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 3H6.5H15.25C18.15 3 20.5 5.36 20.5 8.25C20.5 9.8 19.81 11.19 18.73 12.15C20.37 13.04 21.5 14.76 21.5 16.75C21.5 19.64 19.15 22 16.25 22H6.5H3.5C2.95 22 2.5 21.55 2.5 21C2.5 20.45 2.95 20 3.5 20H5.5V5H3.5C2.95 5 2.5 4.55 2.5 4C2.5 3.45 2.95 3 3.5 3ZM7.5 20H16.25C18.04 20 19.5 18.54 19.5 16.75C19.5 14.96 18.04 13.5 16.25 13.5H7.5V20ZM7.5 11.5H15.25C17.04 11.5 18.5 10.04 18.5 8.25C18.5 6.46 17.04 5 15.25 5H7.5V11.5Z"/></svg>',
},
{
name: 'italic',
action: EasyMDE.toggleItalic,
title: i18n.global.t('input.editor.italic'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M14.0967 4.2H17.0001C17.3301 4.2 17.6001 3.93 17.6001 3.6C17.6001 3.27 17.3301 3 17.0001 3H10.2001C9.8701 3 9.6001 3.27 9.6001 3.6C9.6001 3.93 9.8701 4.2 10.2001 4.2H12.8748L9.90335 19.8H6.9999C6.6699 19.8 6.3999 20.07 6.3999 20.4C6.3999 20.73 6.6699 21 6.9999 21H13.7999C14.1299 21 14.3999 20.73 14.3999 20.4C14.3999 20.07 14.1299 19.8 13.7999 19.8H11.1253L14.0967 4.2Z"/></svg>',
},
{
name: 'strikethrough',
action: EasyMDE.toggleStrikethrough,
title: i18n.global.t('input.editor.strikethrough'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.25 7.17005C18.25 7.50005 17.98 7.77005 17.65 7.77005C17.32 7.77005 17.05 7.50005 17.05 7.17005V5.96005C15.97 5.12005 14.17 4.56005 12.79 4.31005C11.1 4.00005 9.51 4.30005 8.41 5.12005C7.2 6.03005 6.67 7.67005 7.19 8.88005C7.56 9.73005 8.37 10.31 8.98 10.64C9.57215 10.9644 10.1961 11.2013 10.8465 11.3999H20.4C20.73 11.3999 21 11.6699 21 11.9999C21 12.3299 20.73 12.5999 20.4 12.5999H15.3012C16.6583 13.0929 17.5255 13.7765 17.95 14.69C18.73 16.36 17.74 18.33 16.36 19.41C15.05 20.4401 13.35 21 11.54 21H11.16C9.78 20.9401 8.34 20.5301 6.95 19.85V20.3601C6.95 20.6901 6.68 20.96 6.35 20.96C6.02 20.96 5.75 20.6901 5.75 20.3601V17.36C5.75 17.03 6.02 16.76 6.35 16.76C6.68 16.76 6.95 17.03 6.95 17.36V18.5C8.35 19.2801 9.81 19.74 11.21 19.8C12.86 19.89 14.46 19.39 15.62 18.48C16.6 17.71 17.37 16.3 16.86 15.21C16.55 14.54 15.8 14.0201 14.58 13.63C13.9711 13.4331 13.3222 13.2762 12.6906 13.1235C12.6168 13.1056 12.5432 13.0878 12.47 13.07C12.4313 13.0607 12.3925 13.0514 12.3537 13.0421C11.7861 12.9055 11.2108 12.767 10.6413 12.5999H3.6C3.27 12.5999 3 12.3299 3 11.9999C3 11.6699 3.27 11.3999 3.6 11.3999H7.90288C7.04984 10.8343 6.42752 10.1363 6.09 9.36005C5.34 7.63005 6.03 5.40005 7.69 4.16005C9.05 3.15005 10.99 2.77005 13 3.13005C13.64 3.25005 15.53 3.66005 17.05 4.53005V4.17005C17.05 3.84005 17.32 3.57005 17.65 3.57005C17.98 3.57005 18.25 3.84005 18.25 4.17005V7.17005Z"/></svg>',
},
{
name: 'code',
action: EasyMDE.toggleCodeBlock,
title: i18n.global.t('input.editor.code'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.57 20.9601C8.64 20.9901 8.71 21.0001 8.78 21.0001C9.02 21.0001 9.24 20.8501 9.34 20.6101L15.79 3.81005C15.9 3.50005 15.75 3.15005 15.44 3.03005C15.14 2.92005 14.79 3.07005 14.67 3.38005L8.22 20.1801C8.11 20.4901 8.26 20.8401 8.57 20.9601ZM7.00007 18.0001C6.85007 18.0001 6.69007 17.9401 6.58007 17.8201L1.18007 12.4201C0.950068 12.1901 0.950068 11.8101 1.18007 11.5701L6.58007 6.17006C6.81007 5.94006 7.19007 5.94006 7.43007 6.17006C7.66007 6.40006 7.66007 6.78006 7.43007 7.02006L2.45007 12.0001L7.43007 16.9801C7.66007 17.2101 7.66007 17.5901 7.43007 17.8301C7.31007 17.9401 7.15007 18.0001 7.00007 18.0001ZM17 18.0001C16.85 18.0001 16.69 17.9401 16.58 17.8201C16.35 17.5901 16.35 17.2101 16.58 16.9701L21.55 12.0001L16.57 7.02006C16.34 6.79006 16.34 6.41006 16.57 6.17006C16.81 5.94006 17.19 5.94006 17.42 6.17006L22.82 11.5701C23.05 11.8001 23.05 12.1801 22.82 12.4201L17.42 17.8201C17.31 17.9401 17.15 18.0001 17 18.0001Z"/></svg>',
},
{
name: 'quote',
action: EasyMDE.toggleBlockquote,
title: i18n.global.t('input.editor.quote'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.373 5.16357H5.62695C4.79102 5.16357 4.11133 5.84326 4.11133 6.6792V16.2095C4.11133 17.0464 4.79102 17.7261 5.62695 17.7261H6.8877V21.1245C6.8877 21.3667 7.0332 21.5854 7.25684 21.6782C7.33203 21.7095 7.41016 21.7241 7.4873 21.7241C7.64258 21.7241 7.7959 21.6636 7.91113 21.5493L11.748 17.7261H19.373C20.209 17.7261 20.8887 17.0464 20.8887 16.2095V6.6792C20.8887 5.84326 20.209 5.16357 19.373 5.16357ZM19.6895 16.2095C19.6895 16.3843 19.5469 16.5269 19.373 16.5269H11.5C11.3408 16.5269 11.1895 16.5894 11.0762 16.7017L8.08691 19.6802V17.1265C8.08691 16.7954 7.81836 16.5269 7.4873 16.5269H5.62695C5.45312 16.5269 5.31055 16.3843 5.31055 16.2095V6.6792C5.31055 6.50537 5.45312 6.36279 5.62695 6.36279H19.373C19.5469 6.36279 19.6895 6.50537 19.6895 6.6792V16.2095ZM10.3431 8.45264C9.46326 8.45264 8.75 9.16589 8.75 10.0458C8.75 10.9257 9.46326 11.639 10.3431 11.639C10.4775 11.639 10.6058 11.6173 10.7305 11.5861V11.6195C10.7305 12.1322 10.3135 12.5492 9.75586 12.5492C9.4248 12.5492 9.17871 12.8177 9.17871 13.1488C9.17871 13.4799 9.46973 13.7484 9.80078 13.7484C10.9746 13.7484 11.9297 12.7933 11.9297 11.6195V10.1176L11.9294 10.1165L11.9292 10.1155C11.9297 10.1049 11.9312 10.0946 11.9326 10.0843L11.9326 10.0843C11.9345 10.0716 11.9363 10.059 11.9363 10.0458C11.9363 9.16589 11.223 8.45264 10.3431 8.45264ZM13.0637 10.0458C13.0637 9.16589 13.7771 8.45264 14.657 8.45264C15.5369 8.45264 16.2501 9.16589 16.2501 10.0458C16.2501 10.0584 16.2484 10.0706 16.2466 10.0828C16.2452 10.0929 16.2437 10.103 16.2433 10.1134C16.2433 10.1149 16.2441 10.1161 16.2441 10.1176V11.6195C16.2441 12.7933 15.2891 13.7484 14.1152 13.7484C13.7842 13.7484 13.4922 13.4799 13.4922 13.1488C13.4922 12.8177 13.7383 12.5492 14.0693 12.5492C14.6279 12.5492 15.0449 12.1322 15.0449 11.6195V11.5858C14.9202 11.6173 14.7915 11.639 14.657 11.639C13.7771 11.639 13.0637 10.9257 13.0637 10.0458Z"/></svg>',
},
{
name: 'unordered-list',
action: EasyMDE.toggleUnorderedList,
title: i18n.global.t('input.editor.unorderedList'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.5281 3.7002H3.5281C3.1981 3.7002 2.9281 3.9702 2.9281 4.3002V7.3002C2.9281 7.6302 3.1981 7.9002 3.5281 7.9002H6.5281C6.8581 7.9002 7.1281 7.6302 7.1281 7.3002V4.3002C7.1281 3.9702 6.8581 3.7002 6.5281 3.7002ZM5.9281 6.7002H4.1281V4.9002H5.9281V6.7002ZM3.5281 9.90015H6.5281C6.8581 9.90015 7.1281 10.1701 7.1281 10.5001V13.5001C7.1281 13.8301 6.8581 14.1001 6.5281 14.1001H3.5281C3.1981 14.1001 2.9281 13.8301 2.9281 13.5001V10.5001C2.9281 10.1701 3.1981 9.90015 3.5281 9.90015ZM4.1281 12.9001H5.9281V11.1001H4.1281V12.9001ZM3.5281 16.1001H6.5281C6.8581 16.1001 7.1281 16.3701 7.1281 16.7001V19.7001C7.1281 20.0301 6.8581 20.3001 6.5281 20.3001H3.5281C3.1981 20.3001 2.9281 20.0301 2.9281 19.7001V16.7001C2.9281 16.3701 3.1981 16.1001 3.5281 16.1001ZM4.1281 19.1001H5.9281V17.3001H4.1281V19.1001ZM9.72817 6.4002H20.7282C21.0582 6.4002 21.3282 6.1302 21.3282 5.8002C21.3282 5.4702 21.0582 5.2002 20.7282 5.2002H9.72817C9.39817 5.2002 9.12817 5.4702 9.12817 5.8002C9.12817 6.1302 9.39817 6.4002 9.72817 6.4002ZM9.72817 11.4001H20.7282C21.0582 11.4001 21.3282 11.6701 21.3282 12.0001C21.3282 12.3301 21.0582 12.6001 20.7282 12.6001H9.72817C9.39817 12.6001 9.12817 12.3301 9.12817 12.0001C9.12817 11.6701 9.39817 11.4001 9.72817 11.4001ZM9.72817 17.6001H20.7282C21.0582 17.6001 21.3282 17.8701 21.3282 18.2001C21.3282 18.5301 21.0582 18.8001 20.7282 18.8001H9.72817C9.39817 18.8001 9.12817 18.5301 9.12817 18.2001C9.12817 17.8701 9.39817 17.6001 9.72817 17.6001Z"/></svg>',
},
{
name: 'ordered-list',
action: EasyMDE.toggleOrderedList,
title: i18n.global.t('input.editor.orderedList'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4.19494 8.29994H5.99494C6.26494 8.29994 6.49494 8.07995 6.49494 7.79994C6.49494 7.51995 6.27494 7.29994 5.99494 7.29994H5.59494V3.79994C5.59494 3.62994 5.50494 3.46994 5.36494 3.37994C5.22494 3.28994 5.04494 3.26994 4.89494 3.33994L3.89494 3.76994C3.64494 3.87994 3.52494 4.17994 3.63494 4.42994C3.74494 4.67994 4.03494 4.79994 4.29494 4.68994L4.59494 4.55994V7.29994H4.19494C3.91494 7.29994 3.69494 7.51995 3.69494 7.79994C3.69494 8.07995 3.91494 8.29994 4.19494 8.29994ZM20.195 6.39995H9.19497C8.86497 6.39995 8.59497 6.12995 8.59497 5.79995C8.59497 5.46995 8.86497 5.19995 9.19497 5.19995H20.195C20.525 5.19995 20.795 5.46995 20.795 5.79995C20.795 6.12995 20.525 6.39995 20.195 6.39995ZM3.78486 14.36H6.37486C6.65486 14.36 6.87486 14.14 6.87486 13.86C6.87486 13.58 6.65486 13.36 6.37486 13.36H4.88486C5.00486 13.23 5.12486 13.09 5.23486 12.95C5.26626 12.9151 5.29645 12.8802 5.32626 12.8458L5.32629 12.8457C5.38192 12.7814 5.43627 12.7186 5.49486 12.66C5.73486 12.4 5.98486 12.12 6.17486 11.79C6.47486 11.25 6.41486 10.63 6.01486 10.17C5.57486 9.66 4.86486 9.5 4.24486 9.74C3.74486 9.95 3.39486 10.35 3.22486 10.91C3.14486 11.18 3.29486 11.46 3.56486 11.54C3.82486 11.61 4.10486 11.46 4.18486 11.2C4.29486 10.85 4.48486 10.73 4.62486 10.67C4.88486 10.57 5.13486 10.68 5.26486 10.82C5.38486 10.96 5.40486 11.12 5.30486 11.29C5.17595 11.5202 4.99618 11.7165 4.80458 11.9257L4.75486 11.98C4.67298 12.0801 4.58283 12.1801 4.49946 12.2727L4.49945 12.2727L4.47486 12.3C4.12486 12.72 3.76486 13.13 3.40486 13.53C3.27486 13.68 3.23486 13.9 3.32486 14.07C3.41486 14.24 3.58486 14.36 3.78486 14.36ZM3.68486 20.3699C4.04486 20.5899 4.46486 20.6999 4.87486 20.6999C5.13486 20.6999 5.38486 20.6499 5.61486 20.5499C6.31486 20.2799 6.73486 19.5599 6.60486 18.8799C6.53486 18.5499 6.35486 18.2899 6.12486 18.0899C6.32486 17.8999 6.45486 17.6499 6.50486 17.3799C6.57486 17.0099 6.49486 16.6299 6.27486 16.3099C5.85486 15.6899 5.07486 15.5199 4.10486 15.8299C3.83486 15.9199 3.69486 16.1999 3.77486 16.4599C3.86486 16.7299 4.14486 16.8699 4.40486 16.7899C4.70486 16.6999 5.24486 16.5799 5.45486 16.8899C5.51486 16.9899 5.54486 17.0999 5.52486 17.1999C5.51486 17.2699 5.47486 17.3599 5.36486 17.4299C5.26486 17.4999 5.12486 17.5399 4.95486 17.5799L4.77486 17.6299C4.54486 17.6999 4.40486 17.9099 4.41486 18.1499C4.42486 18.3899 4.61486 18.5799 4.84486 18.6099C5.20486 18.6599 5.58486 18.8299 5.63486 19.0799C5.67486 19.2999 5.46486 19.5499 5.25486 19.6299C4.94486 19.7599 4.52486 19.7099 4.21486 19.5199C3.97486 19.3699 3.67486 19.4399 3.52486 19.6799C3.37486 19.9199 3.44486 20.2299 3.68486 20.3699ZM20.195 18.7999H9.19497C8.86497 18.7999 8.59497 18.5299 8.59497 18.1999C8.59497 17.8699 8.86497 17.5999 9.19497 17.5999H20.195C20.525 17.5999 20.795 17.8699 20.795 18.1999C20.795 18.5299 20.525 18.7999 20.195 18.7999ZM9.19497 12.5999H20.195C20.525 12.5999 20.795 12.3299 20.795 11.9999C20.795 11.6699 20.525 11.3999 20.195 11.3999H9.19497C8.86497 11.3999 8.59497 11.6699 8.59497 11.9999C8.59497 12.3299 8.86497 12.5999 9.19497 12.5999Z"/></svg>',
},
'|',
{
name: 'clean-block',
action: EasyMDE.cleanBlock,
title: i18n.global.t('input.editor.cleanBlock'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9.25989 6.18384H20.4513C20.7823 6.18384 21.0509 6.45239 21.0509 6.78345V17.9749C21.0509 18.3059 20.7823 18.5745 20.4513 18.5745H9.25989C9.0929 18.5745 8.93469 18.5061 8.82043 18.384L3.6095 12.7883C3.39563 12.5579 3.39563 12.2004 3.6095 11.97L8.82043 6.37427C8.93469 6.2522 9.0929 6.18384 9.25989 6.18384ZM9.52063 17.3752H19.8517V7.38306H9.52063L4.86926 12.3792L9.52063 17.3752ZM12.7755 15.0686C12.6222 15.0686 12.4679 15.01 12.3517 14.8928C12.1173 14.6584 12.1173 14.2786 12.3517 14.0452L14.0503 12.3469L12.3517 10.6487C12.1173 10.4153 12.1173 10.0354 12.3517 9.80103C12.5841 9.56665 12.965 9.56665 13.1993 9.80103L14.8981 11.4994L16.5968 9.80103C16.8312 9.56665 17.212 9.56665 17.4445 9.80103C17.6788 10.0354 17.6788 10.4153 17.4445 10.6487L15.7458 12.3469L17.4445 14.0452C17.6788 14.2786 17.6788 14.6584 17.4445 14.8928C17.3282 15.01 17.174 15.0686 17.0206 15.0686C16.8673 15.0686 16.714 15.01 16.5968 14.8928L14.8981 13.1945L13.1993 14.8928C13.0822 15.01 12.9288 15.0686 12.7755 15.0686Z"/></svg>',
},
{
name: 'link',
action: EasyMDE.drawLink,
title: i18n.global.t('input.editor.link'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.4399 15.3452C11.4999 15.3652 11.5699 15.3752 11.6299 15.3752C11.8799 15.3752 12.1199 15.2152 12.1999 14.9652C12.2999 14.6452 12.1299 14.3052 11.8199 14.2052C11.3499 14.0452 10.9299 13.7852 10.5699 13.4152C10.1999 13.0552 9.9399 12.6452 9.7799 12.1552C9.6599 11.8252 9.5999 11.4652 9.5999 11.0952C9.5999 10.2152 9.9399 9.38518 10.5699 8.75518L15.1599 4.15518C16.4499 2.87518 18.5399 2.87518 19.8299 4.15518C20.4499 4.78518 20.7899 5.61518 20.7899 6.49518C20.7899 7.37518 20.4499 8.20518 19.8299 8.82518L16.7399 11.9052C16.5099 12.1452 16.5099 12.5252 16.7399 12.7552C16.9799 12.9852 17.3599 12.9852 17.5899 12.7552L20.6799 9.67518C21.5299 8.83518 21.9999 7.69518 21.9999 6.49518C21.9999 5.29518 21.5299 4.16518 20.6899 3.30518C18.9299 1.55518 16.0799 1.55518 14.3199 3.30518L9.7299 7.90518C8.8699 8.75518 8.3999 9.88518 8.3999 11.0952C8.3999 11.6152 8.4899 12.1152 8.6499 12.5552C8.8599 13.1952 9.2399 13.7952 9.7199 14.2652C10.1999 14.7552 10.7999 15.1352 11.4399 15.3452ZM3.32 20.6851C4.2 21.5551 5.35 21.9951 6.5 21.9951C7.65 21.9951 8.81 21.5551 9.69 20.7051L14.28 16.1051C15.14 15.2551 15.61 14.1251 15.61 12.9151C15.61 12.4551 15.54 11.9951 15.4 11.5551C15.17 10.8651 14.8 10.2551 14.28 9.73509C13.76 9.21509 13.15 8.84509 12.46 8.61509C12.14 8.51509 11.8 8.68509 11.7 8.99509C11.6 9.30509 11.77 9.64509 12.1 9.75509C12.61 9.91509 13.06 10.1951 13.44 10.5751C13.82 10.9551 14.09 11.4051 14.26 11.9151C14.36 12.2351 14.41 12.5651 14.41 12.9051C14.41 13.7951 14.06 14.6251 13.43 15.2451L8.84 19.8451C7.55 21.1251 5.46 21.1251 4.17 19.8451C3.55 19.2151 3.21 18.3951 3.21 17.5051C3.21 16.6151 3.55 15.7851 4.17 15.1651L7.35 11.9851C7.58 11.7451 7.59 11.3651 7.35 11.1351C7.11 10.9051 6.73 10.9051 6.5 11.1351L3.32 14.3151C2.47 15.1551 2 16.2851 2 17.4951C2 18.7051 2.47 19.8351 3.32 20.6851Z"/></svg>',
},
{
name: 'image',
action: EasyMDE.drawImage,
title: i18n.global.t('input.editor.image'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4C2.89543 4 2 4.89543 2 6V16V17.5152V18C2 19.1046 2.89543 20 4 20H20C21.0528 20 21.9156 19.1866 21.9942 18.1539L22 18.1632V18V16V6C22 4.89543 21.1046 4 20 4H4ZM3.2 17.7V16.5642L6.78192 13.7254C6.8616 13.6622 6.97597 13.6689 7.04776 13.7409L10.3126 17.0146C10.7026 17.4056 11.3357 17.4065 11.7268 17.0165C11.7606 16.9827 11.792 16.9465 11.8207 16.9083L16.736 10.352C16.8023 10.2636 16.9277 10.2457 17.016 10.312C17.0355 10.3265 17.0521 10.3445 17.0651 10.365L20.8 16.2669V17.7C20.8 18.3075 20.3075 18.8 19.7 18.8H4.3C3.69249 18.8 3.2 18.3075 3.2 17.7ZM17.3865 8.61836L20.8 14.08V6.3C20.8 5.69249 20.3075 5.2 19.7 5.2H4.3C3.69249 5.2 3.2 5.69249 3.2 6.3V15.04L6.65054 12.2796C6.84949 12.1204 7.13629 12.1363 7.31645 12.3164L10.8369 15.8369C10.915 15.915 11.0417 15.915 11.1198 15.8369C11.1265 15.8302 16.5625 8.58336 16.5625 8.58336C16.7282 8.36245 17.0416 8.31768 17.2625 8.48336C17.3118 8.52034 17.3538 8.56611 17.3865 8.61836ZM8 8.5C8 9.32843 7.32843 10 6.5 10C5.67157 10 5 9.32843 5 8.5C5 7.67157 5.67157 7 6.5 7C7.32843 7 8 7.67157 8 8.5Z"/></svg>',
},
{
name: 'table',
action: EasyMDE.drawTable,
title: i18n.global.t('input.editor.table'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.18524 3.08496H19.4152C20.6752 3.08496 21.7152 4.11496 21.7152 5.38496V18.615C21.7152 19.885 20.6852 20.915 19.4152 20.915H6.18524C4.91524 20.915 3.88525 19.885 3.88525 18.615V5.38496C3.88525 4.11496 4.91524 3.08496 6.18524 3.08496ZM19.4052 19.705C20.0152 19.705 20.5052 19.215 20.5052 18.605H20.5153V5.38496C20.5153 4.77496 20.0252 4.28496 19.4152 4.28496H6.18524C5.57524 4.28496 5.08521 4.77496 5.08521 5.38496V18.605C5.08521 19.215 5.57524 19.705 6.18524 19.705H19.4052ZM17.4453 9.15503H8.15527C7.82527 9.15503 7.5553 9.42503 7.5553 9.75503C7.5553 10.085 7.82527 10.355 8.15527 10.355H17.4453C17.7753 10.355 18.0453 10.085 18.0453 9.75503C18.0453 9.42503 17.7753 9.15503 17.4453 9.15503ZM17.4453 13.635H8.15527C7.82527 13.635 7.5553 13.905 7.5553 14.235C7.5553 14.565 7.82527 14.835 8.15527 14.835H17.4453C17.7753 14.835 18.0453 14.565 18.0453 14.235C18.0453 13.905 17.7753 13.635 17.4453 13.635Z"/></svg>',
},
{
name: 'horizontal-rule',
action: EasyMDE.drawHorizontalRule,
title: i18n.global.t('input.editor.horizontalRule'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M21 13H3C2.45 13 2 12.55 2 12C2 11.45 2.45 11 3 11H21C21.55 11 22 11.45 22 12C22 12.55 21.55 13 21 13Z"/></svg>',
},
'|',
{
name: 'side-by-side',
action: EasyMDE.toggleSideBySide,
title: i18n.global.t('input.editor.sideBySide'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.4787 14.58C18.3587 14.69 18.2987 14.85 18.2987 15C18.2987 15.15 18.3587 15.31 18.4787 15.42C18.7187 15.65 19.0987 15.65 19.3287 15.42L22.3287 12.42C22.5587 12.18 22.5587 11.8 22.3287 11.57L19.3287 8.56996C19.0887 8.33996 18.7087 8.33996 18.4787 8.56996C18.2487 8.80996 18.2487 9.18996 18.4787 9.41996L20.451 11.3999L14.4487 11.3999L14.4487 4.6C14.4487 4.27 14.1787 4 13.8487 4C13.5187 4 13.2487 4.27 13.2487 4.6L13.2487 19.4C13.2487 19.73 13.5187 20 13.8487 20C14.1787 20 14.4487 19.73 14.4487 19.4L14.4487 12.5999L20.4511 12.5999L18.4787 14.58ZM9.54878 19.4L9.54878 12.5999L3.5486 12.5999L5.52867 14.58C5.75867 14.81 5.75867 15.19 5.52867 15.43C5.29867 15.66 4.91867 15.66 4.67867 15.43L1.67867 12.43C1.63274 12.384 1.5956 12.3323 1.56725 12.2774C1.53058 12.2077 1.50724 12.1299 1.50068 12.0477C1.49934 12.0317 1.49867 12.0158 1.49867 12C1.49867 11.9841 1.49933 11.9682 1.50067 11.9522C1.51454 11.778 1.60365 11.6242 1.73526 11.5234L4.67867 8.57997C4.90867 8.34997 5.28867 8.34997 5.52867 8.57997C5.75867 8.80997 5.75867 9.18997 5.52867 9.42997L3.55107 11.3999L9.54878 11.3999L9.54878 4.6C9.54878 4.27 9.81878 4 10.1488 4C10.4788 4 10.7488 4.27 10.7488 4.6L10.7488 11.9999L10.7488 19.4C10.7488 19.73 10.4788 20 10.1488 20C9.81878 20 9.54878 19.73 9.54878 19.4Z"/></svg>',
},
{
name: 'guide',
action() {
window.open('https://www.markdownguide.org/basic-syntax/', '_blank')
},
title: i18n.global.t('input.editor.guide'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.4999 2.3999H6.4999C5.0699 2.3999 3.8999 3.5699 3.8999 4.9999V18.9999C3.8999 20.4299 5.0699 21.5999 6.4999 21.5999H19.4999C19.8299 21.5999 20.0999 21.3299 20.0999 20.9999V16.9999V2.9999C20.0999 2.6699 19.8299 2.3999 19.4999 2.3999ZM5.0999 4.9999V16.8118C5.50468 16.5513 5.98546 16.3999 6.4999 16.3999H18.8999V3.5999H6.4999C5.7299 3.5999 5.0999 4.2299 5.0999 4.9999ZM6.4999 17.5999H18.8999V20.3999H6.4999C5.7299 20.3999 5.0999 19.7699 5.0999 18.9999C5.0999 18.2299 5.7299 17.5999 6.4999 17.5999ZM8.4999 8.5999H15.4999C15.8299 8.5999 16.0999 8.3299 16.0999 7.9999C16.0999 7.6699 15.8299 7.3999 15.4999 7.3999H8.4999C8.1699 7.3999 7.8999 7.6699 7.8999 7.9999C7.8999 8.3299 8.1699 8.5999 8.4999 8.5999ZM15.4999 11.3999H8.4999C8.1699 11.3999 7.8999 11.6699 7.8999 11.9999C7.8999 12.3299 8.1699 12.5999 8.4999 12.5999H15.4999C15.8299 12.5999 16.0999 12.3299 16.0999 11.9999C16.0999 11.6699 15.8299 11.3999 15.4999 11.3999Z"/></svg>',
},
],
}
}

View File

@ -0,0 +1,134 @@
import EasyMDE from 'easymde'
import {i18n} from '@/i18n'
export function createEasyMDEConfig({ placeholder, uploadImage, imageUploadFunction }) {
return {
autoDownloadFontAwesome: false,
spellChecker: false,
placeholder,
uploadImage,
imageUploadFunction,
minHeight: '150px',
toolbar: [
{
name: 'heading-1',
action: EasyMDE.toggleHeading1,
title: i18n.global.t('input.editor.heading1'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-2',
action: EasyMDE.toggleHeading2,
title: i18n.global.t('input.editor.heading2'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-3',
action: EasyMDE.toggleHeading3,
title: i18n.global.t('input.editor.heading3'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-smaller',
action: EasyMDE.toggleHeadingSmaller,
title: i18n.global.t('input.editor.headingSmaller'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-bigger',
action: EasyMDE.toggleHeadingBigger,
title: i18n.global.t('input.editor.headingBigger'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
'|',
{
name: 'bold',
action: EasyMDE.toggleBold,
title: i18n.global.t('input.editor.bold'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M3.5 3H6.5H15.25C18.15 3 20.5 5.36 20.5 8.25C20.5 9.8 19.81 11.19 18.73 12.15C20.37 13.04 21.5 14.76 21.5 16.75C21.5 19.64 19.15 22 16.25 22H6.5H3.5C2.95 22 2.5 21.55 2.5 21C2.5 20.45 2.95 20 3.5 20H5.5V5H3.5C2.95 5 2.5 4.55 2.5 4C2.5 3.45 2.95 3 3.5 3ZM7.5 20H16.25C18.04 20 19.5 18.54 19.5 16.75C19.5 14.96 18.04 13.5 16.25 13.5H7.5V20ZM7.5 11.5H15.25C17.04 11.5 18.5 10.04 18.5 8.25C18.5 6.46 17.04 5 15.25 5H7.5V11.5Z"/></svg>',
},
{
name: 'italic',
action: EasyMDE.toggleItalic,
title: i18n.global.t('input.editor.italic'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M14.0967 4.2H17.0001C17.3301 4.2 17.6001 3.93 17.6001 3.6C17.6001 3.27 17.3301 3 17.0001 3H10.2001C9.8701 3 9.6001 3.27 9.6001 3.6C9.6001 3.93 9.8701 4.2 10.2001 4.2H12.8748L9.90335 19.8H6.9999C6.6699 19.8 6.3999 20.07 6.3999 20.4C6.3999 20.73 6.6699 21 6.9999 21H13.7999C14.1299 21 14.3999 20.73 14.3999 20.4C14.3999 20.07 14.1299 19.8 13.7999 19.8H11.1253L14.0967 4.2Z"/></svg>',
},
{
name: 'strikethrough',
action: EasyMDE.toggleStrikethrough,
title: i18n.global.t('input.editor.strikethrough'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M18.25 7.17005C18.25 7.50005 17.98 7.77005 17.65 7.77005C17.32 7.77005 17.05 7.50005 17.05 7.17005V5.96005C15.97 5.12005 14.17 4.56005 12.79 4.31005C11.1 4.00005 9.51 4.30005 8.41 5.12005C7.2 6.03005 6.67 7.67005 7.19 8.88005C7.56 9.73005 8.37 10.31 8.98 10.64C9.57215 10.9644 10.1961 11.2013 10.8465 11.3999H20.4C20.73 11.3999 21 11.6699 21 11.9999C21 12.3299 20.73 12.5999 20.4 12.5999H15.3012C16.6583 13.0929 17.5255 13.7765 17.95 14.69C18.73 16.36 17.74 18.33 16.36 19.41C15.05 20.4401 13.35 21 11.54 21H11.16C9.78 20.9401 8.34 20.5301 6.95 19.85V20.3601C6.95 20.6901 6.68 20.96 6.35 20.96C6.02 20.96 5.75 20.6901 5.75 20.3601V17.36C5.75 17.03 6.02 16.76 6.35 16.76C6.68 16.76 6.95 17.03 6.95 17.36V18.5C8.35 19.2801 9.81 19.74 11.21 19.8C12.86 19.89 14.46 19.39 15.62 18.48C16.6 17.71 17.37 16.3 16.86 15.21C16.55 14.54 15.8 14.0201 14.58 13.63C13.9711 13.4331 13.3222 13.2762 12.6906 13.1235C12.6168 13.1056 12.5432 13.0878 12.47 13.07C12.4313 13.0607 12.3925 13.0514 12.3537 13.0421C11.7861 12.9055 11.2108 12.767 10.6413 12.5999H3.6C3.27 12.5999 3 12.3299 3 11.9999C3 11.6699 3.27 11.3999 3.6 11.3999H7.90288C7.04984 10.8343 6.42752 10.1363 6.09 9.36005C5.34 7.63005 6.03 5.40005 7.69 4.16005C9.05 3.15005 10.99 2.77005 13 3.13005C13.64 3.25005 15.53 3.66005 17.05 4.53005V4.17005C17.05 3.84005 17.32 3.57005 17.65 3.57005C17.98 3.57005 18.25 3.84005 18.25 4.17005V7.17005Z"/></svg>',
},
{
name: 'code',
action: EasyMDE.toggleCodeBlock,
title: i18n.global.t('input.editor.code'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M8.57 20.9601C8.64 20.9901 8.71 21.0001 8.78 21.0001C9.02 21.0001 9.24 20.8501 9.34 20.6101L15.79 3.81005C15.9 3.50005 15.75 3.15005 15.44 3.03005C15.14 2.92005 14.79 3.07005 14.67 3.38005L8.22 20.1801C8.11 20.4901 8.26 20.8401 8.57 20.9601ZM7.00007 18.0001C6.85007 18.0001 6.69007 17.9401 6.58007 17.8201L1.18007 12.4201C0.950068 12.1901 0.950068 11.8101 1.18007 11.5701L6.58007 6.17006C6.81007 5.94006 7.19007 5.94006 7.43007 6.17006C7.66007 6.40006 7.66007 6.78006 7.43007 7.02006L2.45007 12.0001L7.43007 16.9801C7.66007 17.2101 7.66007 17.5901 7.43007 17.8301C7.31007 17.9401 7.15007 18.0001 7.00007 18.0001ZM17 18.0001C16.85 18.0001 16.69 17.9401 16.58 17.8201C16.35 17.5901 16.35 17.2101 16.58 16.9701L21.55 12.0001L16.57 7.02006C16.34 6.79006 16.34 6.41006 16.57 6.17006C16.81 5.94006 17.19 5.94006 17.42 6.17006L22.82 11.5701C23.05 11.8001 23.05 12.1801 22.82 12.4201L17.42 17.8201C17.31 17.9401 17.15 18.0001 17 18.0001Z"/></svg>',
},
{
name: 'quote',
action: EasyMDE.toggleBlockquote,
title: i18n.global.t('input.editor.quote'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M19.373 5.16357H5.62695C4.79102 5.16357 4.11133 5.84326 4.11133 6.6792V16.2095C4.11133 17.0464 4.79102 17.7261 5.62695 17.7261H6.8877V21.1245C6.8877 21.3667 7.0332 21.5854 7.25684 21.6782C7.33203 21.7095 7.41016 21.7241 7.4873 21.7241C7.64258 21.7241 7.7959 21.6636 7.91113 21.5493L11.748 17.7261H19.373C20.209 17.7261 20.8887 17.0464 20.8887 16.2095V6.6792C20.8887 5.84326 20.209 5.16357 19.373 5.16357ZM19.6895 16.2095C19.6895 16.3843 19.5469 16.5269 19.373 16.5269H11.5C11.3408 16.5269 11.1895 16.5894 11.0762 16.7017L8.08691 19.6802V17.1265C8.08691 16.7954 7.81836 16.5269 7.4873 16.5269H5.62695C5.45312 16.5269 5.31055 16.3843 5.31055 16.2095V6.6792C5.31055 6.50537 5.45312 6.36279 5.62695 6.36279H19.373C19.5469 6.36279 19.6895 6.50537 19.6895 6.6792V16.2095ZM10.3431 8.45264C9.46326 8.45264 8.75 9.16589 8.75 10.0458C8.75 10.9257 9.46326 11.639 10.3431 11.639C10.4775 11.639 10.6058 11.6173 10.7305 11.5861V11.6195C10.7305 12.1322 10.3135 12.5492 9.75586 12.5492C9.4248 12.5492 9.17871 12.8177 9.17871 13.1488C9.17871 13.4799 9.46973 13.7484 9.80078 13.7484C10.9746 13.7484 11.9297 12.7933 11.9297 11.6195V10.1176L11.9294 10.1165L11.9292 10.1155C11.9297 10.1049 11.9312 10.0946 11.9326 10.0843L11.9326 10.0843C11.9345 10.0716 11.9363 10.059 11.9363 10.0458C11.9363 9.16589 11.223 8.45264 10.3431 8.45264ZM13.0637 10.0458C13.0637 9.16589 13.7771 8.45264 14.657 8.45264C15.5369 8.45264 16.2501 9.16589 16.2501 10.0458C16.2501 10.0584 16.2484 10.0706 16.2466 10.0828C16.2452 10.0929 16.2437 10.103 16.2433 10.1134C16.2433 10.1149 16.2441 10.1161 16.2441 10.1176V11.6195C16.2441 12.7933 15.2891 13.7484 14.1152 13.7484C13.7842 13.7484 13.4922 13.4799 13.4922 13.1488C13.4922 12.8177 13.7383 12.5492 14.0693 12.5492C14.6279 12.5492 15.0449 12.1322 15.0449 11.6195V11.5858C14.9202 11.6173 14.7915 11.639 14.657 11.639C13.7771 11.639 13.0637 10.9257 13.0637 10.0458Z"/></svg>',
},
{
name: 'unordered-list',
action: EasyMDE.toggleUnorderedList,
title: i18n.global.t('input.editor.unorderedList'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M6.5281 3.7002H3.5281C3.1981 3.7002 2.9281 3.9702 2.9281 4.3002V7.3002C2.9281 7.6302 3.1981 7.9002 3.5281 7.9002H6.5281C6.8581 7.9002 7.1281 7.6302 7.1281 7.3002V4.3002C7.1281 3.9702 6.8581 3.7002 6.5281 3.7002ZM5.9281 6.7002H4.1281V4.9002H5.9281V6.7002ZM3.5281 9.90015H6.5281C6.8581 9.90015 7.1281 10.1701 7.1281 10.5001V13.5001C7.1281 13.8301 6.8581 14.1001 6.5281 14.1001H3.5281C3.1981 14.1001 2.9281 13.8301 2.9281 13.5001V10.5001C2.9281 10.1701 3.1981 9.90015 3.5281 9.90015ZM4.1281 12.9001H5.9281V11.1001H4.1281V12.9001ZM3.5281 16.1001H6.5281C6.8581 16.1001 7.1281 16.3701 7.1281 16.7001V19.7001C7.1281 20.0301 6.8581 20.3001 6.5281 20.3001H3.5281C3.1981 20.3001 2.9281 20.0301 2.9281 19.7001V16.7001C2.9281 16.3701 3.1981 16.1001 3.5281 16.1001ZM4.1281 19.1001H5.9281V17.3001H4.1281V19.1001ZM9.72817 6.4002H20.7282C21.0582 6.4002 21.3282 6.1302 21.3282 5.8002C21.3282 5.4702 21.0582 5.2002 20.7282 5.2002H9.72817C9.39817 5.2002 9.12817 5.4702 9.12817 5.8002C9.12817 6.1302 9.39817 6.4002 9.72817 6.4002ZM9.72817 11.4001H20.7282C21.0582 11.4001 21.3282 11.6701 21.3282 12.0001C21.3282 12.3301 21.0582 12.6001 20.7282 12.6001H9.72817C9.39817 12.6001 9.12817 12.3301 9.12817 12.0001C9.12817 11.6701 9.39817 11.4001 9.72817 11.4001ZM9.72817 17.6001H20.7282C21.0582 17.6001 21.3282 17.8701 21.3282 18.2001C21.3282 18.5301 21.0582 18.8001 20.7282 18.8001H9.72817C9.39817 18.8001 9.12817 18.5301 9.12817 18.2001C9.12817 17.8701 9.39817 17.6001 9.72817 17.6001Z"/></svg>',
},
{
name: 'ordered-list',
action: EasyMDE.toggleOrderedList,
title: i18n.global.t('input.editor.orderedList'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M4.19494 8.29994H5.99494C6.26494 8.29994 6.49494 8.07995 6.49494 7.79994C6.49494 7.51995 6.27494 7.29994 5.99494 7.29994H5.59494V3.79994C5.59494 3.62994 5.50494 3.46994 5.36494 3.37994C5.22494 3.28994 5.04494 3.26994 4.89494 3.33994L3.89494 3.76994C3.64494 3.87994 3.52494 4.17994 3.63494 4.42994C3.74494 4.67994 4.03494 4.79994 4.29494 4.68994L4.59494 4.55994V7.29994H4.19494C3.91494 7.29994 3.69494 7.51995 3.69494 7.79994C3.69494 8.07995 3.91494 8.29994 4.19494 8.29994ZM20.195 6.39995H9.19497C8.86497 6.39995 8.59497 6.12995 8.59497 5.79995C8.59497 5.46995 8.86497 5.19995 9.19497 5.19995H20.195C20.525 5.19995 20.795 5.46995 20.795 5.79995C20.795 6.12995 20.525 6.39995 20.195 6.39995ZM3.78486 14.36H6.37486C6.65486 14.36 6.87486 14.14 6.87486 13.86C6.87486 13.58 6.65486 13.36 6.37486 13.36H4.88486C5.00486 13.23 5.12486 13.09 5.23486 12.95C5.26626 12.9151 5.29645 12.8802 5.32626 12.8458L5.32629 12.8457C5.38192 12.7814 5.43627 12.7186 5.49486 12.66C5.73486 12.4 5.98486 12.12 6.17486 11.79C6.47486 11.25 6.41486 10.63 6.01486 10.17C5.57486 9.66 4.86486 9.5 4.24486 9.74C3.74486 9.95 3.39486 10.35 3.22486 10.91C3.14486 11.18 3.29486 11.46 3.56486 11.54C3.82486 11.61 4.10486 11.46 4.18486 11.2C4.29486 10.85 4.48486 10.73 4.62486 10.67C4.88486 10.57 5.13486 10.68 5.26486 10.82C5.38486 10.96 5.40486 11.12 5.30486 11.29C5.17595 11.5202 4.99618 11.7165 4.80458 11.9257L4.75486 11.98C4.67298 12.0801 4.58283 12.1801 4.49946 12.2727L4.49945 12.2727L4.47486 12.3C4.12486 12.72 3.76486 13.13 3.40486 13.53C3.27486 13.68 3.23486 13.9 3.32486 14.07C3.41486 14.24 3.58486 14.36 3.78486 14.36ZM3.68486 20.3699C4.04486 20.5899 4.46486 20.6999 4.87486 20.6999C5.13486 20.6999 5.38486 20.6499 5.61486 20.5499C6.31486 20.2799 6.73486 19.5599 6.60486 18.8799C6.53486 18.5499 6.35486 18.2899 6.12486 18.0899C6.32486 17.8999 6.45486 17.6499 6.50486 17.3799C6.57486 17.0099 6.49486 16.6299 6.27486 16.3099C5.85486 15.6899 5.07486 15.5199 4.10486 15.8299C3.83486 15.9199 3.69486 16.1999 3.77486 16.4599C3.86486 16.7299 4.14486 16.8699 4.40486 16.7899C4.70486 16.6999 5.24486 16.5799 5.45486 16.8899C5.51486 16.9899 5.54486 17.0999 5.52486 17.1999C5.51486 17.2699 5.47486 17.3599 5.36486 17.4299C5.26486 17.4999 5.12486 17.5399 4.95486 17.5799L4.77486 17.6299C4.54486 17.6999 4.40486 17.9099 4.41486 18.1499C4.42486 18.3899 4.61486 18.5799 4.84486 18.6099C5.20486 18.6599 5.58486 18.8299 5.63486 19.0799C5.67486 19.2999 5.46486 19.5499 5.25486 19.6299C4.94486 19.7599 4.52486 19.7099 4.21486 19.5199C3.97486 19.3699 3.67486 19.4399 3.52486 19.6799C3.37486 19.9199 3.44486 20.2299 3.68486 20.3699ZM20.195 18.7999H9.19497C8.86497 18.7999 8.59497 18.5299 8.59497 18.1999C8.59497 17.8699 8.86497 17.5999 9.19497 17.5999H20.195C20.525 17.5999 20.795 17.8699 20.795 18.1999C20.795 18.5299 20.525 18.7999 20.195 18.7999ZM9.19497 12.5999H20.195C20.525 12.5999 20.795 12.3299 20.795 11.9999C20.795 11.6699 20.525 11.3999 20.195 11.3999H9.19497C8.86497 11.3999 8.59497 11.6699 8.59497 11.9999C8.59497 12.3299 8.86497 12.5999 9.19497 12.5999Z"/></svg>',
},
'|',
{
name: 'clean-block',
action: EasyMDE.cleanBlock,
title: i18n.global.t('input.editor.cleanBlock'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M9.25989 6.18384H20.4513C20.7823 6.18384 21.0509 6.45239 21.0509 6.78345V17.9749C21.0509 18.3059 20.7823 18.5745 20.4513 18.5745H9.25989C9.0929 18.5745 8.93469 18.5061 8.82043 18.384L3.6095 12.7883C3.39563 12.5579 3.39563 12.2004 3.6095 11.97L8.82043 6.37427C8.93469 6.2522 9.0929 6.18384 9.25989 6.18384ZM9.52063 17.3752H19.8517V7.38306H9.52063L4.86926 12.3792L9.52063 17.3752ZM12.7755 15.0686C12.6222 15.0686 12.4679 15.01 12.3517 14.8928C12.1173 14.6584 12.1173 14.2786 12.3517 14.0452L14.0503 12.3469L12.3517 10.6487C12.1173 10.4153 12.1173 10.0354 12.3517 9.80103C12.5841 9.56665 12.965 9.56665 13.1993 9.80103L14.8981 11.4994L16.5968 9.80103C16.8312 9.56665 17.212 9.56665 17.4445 9.80103C17.6788 10.0354 17.6788 10.4153 17.4445 10.6487L15.7458 12.3469L17.4445 14.0452C17.6788 14.2786 17.6788 14.6584 17.4445 14.8928C17.3282 15.01 17.174 15.0686 17.0206 15.0686C16.8673 15.0686 16.714 15.01 16.5968 14.8928L14.8981 13.1945L13.1993 14.8928C13.0822 15.01 12.9288 15.0686 12.7755 15.0686Z"/></svg>',
},
{
name: 'link',
action: EasyMDE.drawLink,
title: i18n.global.t('input.editor.link'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M11.4399 15.3452C11.4999 15.3652 11.5699 15.3752 11.6299 15.3752C11.8799 15.3752 12.1199 15.2152 12.1999 14.9652C12.2999 14.6452 12.1299 14.3052 11.8199 14.2052C11.3499 14.0452 10.9299 13.7852 10.5699 13.4152C10.1999 13.0552 9.9399 12.6452 9.7799 12.1552C9.6599 11.8252 9.5999 11.4652 9.5999 11.0952C9.5999 10.2152 9.9399 9.38518 10.5699 8.75518L15.1599 4.15518C16.4499 2.87518 18.5399 2.87518 19.8299 4.15518C20.4499 4.78518 20.7899 5.61518 20.7899 6.49518C20.7899 7.37518 20.4499 8.20518 19.8299 8.82518L16.7399 11.9052C16.5099 12.1452 16.5099 12.5252 16.7399 12.7552C16.9799 12.9852 17.3599 12.9852 17.5899 12.7552L20.6799 9.67518C21.5299 8.83518 21.9999 7.69518 21.9999 6.49518C21.9999 5.29518 21.5299 4.16518 20.6899 3.30518C18.9299 1.55518 16.0799 1.55518 14.3199 3.30518L9.7299 7.90518C8.8699 8.75518 8.3999 9.88518 8.3999 11.0952C8.3999 11.6152 8.4899 12.1152 8.6499 12.5552C8.8599 13.1952 9.2399 13.7952 9.7199 14.2652C10.1999 14.7552 10.7999 15.1352 11.4399 15.3452ZM3.32 20.6851C4.2 21.5551 5.35 21.9951 6.5 21.9951C7.65 21.9951 8.81 21.5551 9.69 20.7051L14.28 16.1051C15.14 15.2551 15.61 14.1251 15.61 12.9151C15.61 12.4551 15.54 11.9951 15.4 11.5551C15.17 10.8651 14.8 10.2551 14.28 9.73509C13.76 9.21509 13.15 8.84509 12.46 8.61509C12.14 8.51509 11.8 8.68509 11.7 8.99509C11.6 9.30509 11.77 9.64509 12.1 9.75509C12.61 9.91509 13.06 10.1951 13.44 10.5751C13.82 10.9551 14.09 11.4051 14.26 11.9151C14.36 12.2351 14.41 12.5651 14.41 12.9051C14.41 13.7951 14.06 14.6251 13.43 15.2451L8.84 19.8451C7.55 21.1251 5.46 21.1251 4.17 19.8451C3.55 19.2151 3.21 18.3951 3.21 17.5051C3.21 16.6151 3.55 15.7851 4.17 15.1651L7.35 11.9851C7.58 11.7451 7.59 11.3651 7.35 11.1351C7.11 10.9051 6.73 10.9051 6.5 11.1351L3.32 14.3151C2.47 15.1551 2 16.2851 2 17.4951C2 18.7051 2.47 19.8351 3.32 20.6851Z"/></svg>',
},
{
name: 'image',
action: EasyMDE.drawImage,
title: i18n.global.t('input.editor.image'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M4 4C2.89543 4 2 4.89543 2 6V16V17.5152V18C2 19.1046 2.89543 20 4 20H20C21.0528 20 21.9156 19.1866 21.9942 18.1539L22 18.1632V18V16V6C22 4.89543 21.1046 4 20 4H4ZM3.2 17.7V16.5642L6.78192 13.7254C6.8616 13.6622 6.97597 13.6689 7.04776 13.7409L10.3126 17.0146C10.7026 17.4056 11.3357 17.4065 11.7268 17.0165C11.7606 16.9827 11.792 16.9465 11.8207 16.9083L16.736 10.352C16.8023 10.2636 16.9277 10.2457 17.016 10.312C17.0355 10.3265 17.0521 10.3445 17.0651 10.365L20.8 16.2669V17.7C20.8 18.3075 20.3075 18.8 19.7 18.8H4.3C3.69249 18.8 3.2 18.3075 3.2 17.7ZM17.3865 8.61836L20.8 14.08V6.3C20.8 5.69249 20.3075 5.2 19.7 5.2H4.3C3.69249 5.2 3.2 5.69249 3.2 6.3V15.04L6.65054 12.2796C6.84949 12.1204 7.13629 12.1363 7.31645 12.3164L10.8369 15.8369C10.915 15.915 11.0417 15.915 11.1198 15.8369C11.1265 15.8302 16.5625 8.58336 16.5625 8.58336C16.7282 8.36245 17.0416 8.31768 17.2625 8.48336C17.3118 8.52034 17.3538 8.56611 17.3865 8.61836ZM8 8.5C8 9.32843 7.32843 10 6.5 10C5.67157 10 5 9.32843 5 8.5C5 7.67157 5.67157 7 6.5 7C7.32843 7 8 7.67157 8 8.5Z"/></svg>',
},
{
name: 'table',
action: EasyMDE.drawTable,
title: i18n.global.t('input.editor.table'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M6.18524 3.08496H19.4152C20.6752 3.08496 21.7152 4.11496 21.7152 5.38496V18.615C21.7152 19.885 20.6852 20.915 19.4152 20.915H6.18524C4.91524 20.915 3.88525 19.885 3.88525 18.615V5.38496C3.88525 4.11496 4.91524 3.08496 6.18524 3.08496ZM19.4052 19.705C20.0152 19.705 20.5052 19.215 20.5052 18.605H20.5153V5.38496C20.5153 4.77496 20.0252 4.28496 19.4152 4.28496H6.18524C5.57524 4.28496 5.08521 4.77496 5.08521 5.38496V18.605C5.08521 19.215 5.57524 19.705 6.18524 19.705H19.4052ZM17.4453 9.15503H8.15527C7.82527 9.15503 7.5553 9.42503 7.5553 9.75503C7.5553 10.085 7.82527 10.355 8.15527 10.355H17.4453C17.7753 10.355 18.0453 10.085 18.0453 9.75503C18.0453 9.42503 17.7753 9.15503 17.4453 9.15503ZM17.4453 13.635H8.15527C7.82527 13.635 7.5553 13.905 7.5553 14.235C7.5553 14.565 7.82527 14.835 8.15527 14.835H17.4453C17.7753 14.835 18.0453 14.565 18.0453 14.235C18.0453 13.905 17.7753 13.635 17.4453 13.635Z"/></svg>',
},
{
name: 'horizontal-rule',
action: EasyMDE.drawHorizontalRule,
title: i18n.global.t('input.editor.horizontalRule'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M21 13H3C2.45 13 2 12.55 2 12C2 11.45 2.45 11 3 11H21C21.55 11 22 11.45 22 12C22 12.55 21.55 13 21 13Z"/></svg>',
},
'|',
{
name: 'side-by-side',
action: EasyMDE.toggleSideBySide,
title: i18n.global.t('input.editor.sideBySide'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M18.4787 14.58C18.3587 14.69 18.2987 14.85 18.2987 15C18.2987 15.15 18.3587 15.31 18.4787 15.42C18.7187 15.65 19.0987 15.65 19.3287 15.42L22.3287 12.42C22.5587 12.18 22.5587 11.8 22.3287 11.57L19.3287 8.56996C19.0887 8.33996 18.7087 8.33996 18.4787 8.56996C18.2487 8.80996 18.2487 9.18996 18.4787 9.41996L20.451 11.3999L14.4487 11.3999L14.4487 4.6C14.4487 4.27 14.1787 4 13.8487 4C13.5187 4 13.2487 4.27 13.2487 4.6L13.2487 19.4C13.2487 19.73 13.5187 20 13.8487 20C14.1787 20 14.4487 19.73 14.4487 19.4L14.4487 12.5999L20.4511 12.5999L18.4787 14.58ZM9.54878 19.4L9.54878 12.5999L3.5486 12.5999L5.52867 14.58C5.75867 14.81 5.75867 15.19 5.52867 15.43C5.29867 15.66 4.91867 15.66 4.67867 15.43L1.67867 12.43C1.63274 12.384 1.5956 12.3323 1.56725 12.2774C1.53058 12.2077 1.50724 12.1299 1.50068 12.0477C1.49934 12.0317 1.49867 12.0158 1.49867 12C1.49867 11.9841 1.49933 11.9682 1.50067 11.9522C1.51454 11.778 1.60365 11.6242 1.73526 11.5234L4.67867 8.57997C4.90867 8.34997 5.28867 8.34997 5.52867 8.57997C5.75867 8.80997 5.75867 9.18997 5.52867 9.42997L3.55107 11.3999L9.54878 11.3999L9.54878 4.6C9.54878 4.27 9.81878 4 10.1488 4C10.4788 4 10.7488 4.27 10.7488 4.6L10.7488 11.9999L10.7488 19.4C10.7488 19.73 10.4788 20 10.1488 20C9.81878 20 9.54878 19.73 9.54878 19.4Z"/></svg>',
},
{
name: 'guide',
action() {
window.open('https://www.markdownguide.org/basic-syntax/', '_blank')
},
title: i18n.global.t('input.editor.guide'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M19.4999 2.3999H6.4999C5.0699 2.3999 3.8999 3.5699 3.8999 4.9999V18.9999C3.8999 20.4299 5.0699 21.5999 6.4999 21.5999H19.4999C19.8299 21.5999 20.0999 21.3299 20.0999 20.9999V16.9999V2.9999C20.0999 2.6699 19.8299 2.3999 19.4999 2.3999ZM5.0999 4.9999V16.8118C5.50468 16.5513 5.98546 16.3999 6.4999 16.3999H18.8999V3.5999H6.4999C5.7299 3.5999 5.0999 4.2299 5.0999 4.9999ZM6.4999 17.5999H18.8999V20.3999H6.4999C5.7299 20.3999 5.0999 19.7699 5.0999 18.9999C5.0999 18.2299 5.7299 17.5999 6.4999 17.5999ZM8.4999 8.5999H15.4999C15.8299 8.5999 16.0999 8.3299 16.0999 7.9999C16.0999 7.6699 15.8299 7.3999 15.4999 7.3999H8.4999C8.1699 7.3999 7.8999 7.6699 7.8999 7.9999C7.8999 8.3299 8.1699 8.5999 8.4999 8.5999ZM15.4999 11.3999H8.4999C8.1699 11.3999 7.8999 11.6699 7.8999 11.9999C7.8999 12.3299 8.1699 12.5999 8.4999 12.5999H15.4999C15.8299 12.5999 16.0999 12.3299 16.0999 11.9999C16.0999 11.6699 15.8299 11.3999 15.4999 11.3999Z"/></svg>',
},
],
}
}

View File

@ -2,7 +2,7 @@
<div :class="{'is-disabled': disabled}" class="fancycheckbox">
<input
:checked="checked"
:disabled="disabled || null"
:disabled="disabled || undefined"
:id="checkBoxId"
@change="(event) => updateData(event.target.checked)"
type="checkbox"/>
@ -19,10 +19,12 @@
</div>
</template>
<script>
<script lang="ts">
import {defineComponent} from 'vue'
import {createRandomID} from '@/helpers/randomId'
export default {
export default defineComponent({
name: 'fancycheckbox',
data() {
return {
@ -57,7 +59,7 @@ export default {
this.$emit('change', checked)
},
},
}
})
</script>

View File

@ -82,11 +82,12 @@
</div>
</template>
<script>
<script lang="ts">
import {defineComponent} from 'vue'
import {i18n} from '@/i18n'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
export default {
export default defineComponent({
name: 'multiselect',
data() {
return {
@ -366,7 +367,7 @@ export default {
this.$refs.searchInput.focus()
},
},
}
})
</script>
<style lang="scss" scoped>
@ -450,7 +451,6 @@ export default {
button {
background: transparent;
display: block;
text-align: left;
box-shadow: none;
border-radius: 0;
@ -460,6 +460,7 @@ export default {
padding: .5rem;
border: none;
cursor: pointer;
color: var(--grey-800);
display: flex;
justify-content: space-between;

View File

@ -0,0 +1,81 @@
<template>
<div class="password-field">
<input
class="input"
id="password"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
:type="passwordFieldType"
autocomplete="current-password"
@keyup.enter="e => $emit('submit', e)"
:tabindex="props.tabindex"
@focusout="validate"
@input="handleInput"
/>
<BaseButton
@click="togglePasswordFieldType"
class="password-field-type-toggle"
:aria-label="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')"
v-tooltip="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')">
<icon :icon="passwordFieldType === 'password' ? 'eye' : 'eye-slash'"/>
</BaseButton>
</div>
<p class="help is-danger" v-if="!isValid">
{{ $t('user.auth.passwordRequired') }}
</p>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useDebounceFn} from '@vueuse/core'
import BaseButton from '@/components/base/BaseButton.vue'
const props = defineProps({
tabindex: String,
modelValue: String,
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
validateInitially: Boolean,
})
const emit = defineEmits(['submit', 'update:modelValue'])
const passwordFieldType = ref('password')
const password = ref('')
const isValid = ref(!props.validateInitially)
watch(
props.validateInitially,
() => props.validateInitially && validate(),
{immediate: true},
)
const validate = useDebounceFn(() => {
isValid.value = password.value !== ''
}, 100)
function togglePasswordFieldType() {
passwordFieldType.value = passwordFieldType.value === 'password'
? 'text'
: 'password'
}
function handleInput(e: Event) {
password.value = (e.target as HTMLInputElement)?.value
emit('update:modelValue', password.value)
}
</script>
<style scoped>
.password-field {
position: relative;
}
.password-field-type-toggle {
position: absolute;
color: var(--grey-400);
top: 50%;
right: 1rem;
transform: translateY(-50%);
}
</style>

View File

@ -1,3 +1,150 @@
<template>
<div class="vue-easymde" ref="easymdeRef">
<textarea
class="vue-simplemde-textarea"
:name="name"
:value="modelValue"
@input="handleInput($event.target.value)"
/>
</div>
</template>
<script setup lang="ts">
import {ref, watch, onMounted, onDeactivated, onBeforeUnmount, nextTick, shallowReactive} from 'vue'
import type { ShallowReactive } from 'vue'
import EasyMDE from 'easymde'
import {marked} from 'marked'
const props = defineProps({
modelValue: String,
name: String,
previewClass: String,
autoinit: {
type: Boolean,
default: true,
},
highlight: {
type: Boolean,
default: false,
},
sanitize: {
type: Boolean,
default: false,
},
configs: {
type: Object,
default: () => ({}),
},
previewRender: {
type: Function,
},
})
const emit = defineEmits(['update:modelValue', 'blur', 'initialized'])
const isValueUpdateFromInner = ref(false)
let easymde: ShallowReactive<EasyMDE> | undefined
onMounted(() => {
if (props.autoinit) initialize()
})
onDeactivated(() => {
if (!easymde) return
const isFullScreen = easymde.codemirror.getOption('fullScreen')
if (isFullScreen) easymde.toggleFullScreen()
})
onBeforeUnmount(() => {
if (easymde) {
easymde.toTextArea()
easymde.cleanup()
easymde = undefined
}
})
const easymdeRef = ref<HTMLElement | null>(null)
function initialize() {
const configs = Object.assign({
element: easymdeRef.value?.firstElementChild,
initialValue: props.modelValue,
previewRender: props.previewRender,
renderingConfig: {},
}, props.configs)
// Synchronize the values of value and initialValue
if (configs.initialValue) {
emit('update:modelValue', configs.initialValue)
}
// Determine whether to enable code highlighting
if (props.highlight) {
configs.renderingConfig.codeSyntaxHighlighting = true
}
// Set whether to render the input html
marked.setOptions({ sanitize: props.sanitize })
// Instantiated editor
easymde = shallowReactive(new EasyMDE(configs))
// Add a custom previewClass
const className = props.previewClass || ''
addPreviewClass(className)
// Binding event
bindingEvents()
nextTick(() => emit('initialized', easymde))
}
function addPreviewClass(className: string) {
const wrapper = easymde.codemirror.getWrapperElement()
const preview = document.createElement('div')
wrapper.nextSibling.className += ` ${className}`
preview.className = `editor-preview ${className}`
wrapper.appendChild(preview)
}
function bindingEvents() {
easymde.codemirror.on('change', handleCodemirrorInput)
easymde.codemirror.on('blur', handleCodemirrorBlur)
}
function handleCodemirrorInput(instance, changeObj) {
if (changeObj.origin === 'setValue') {
return
}
const val = easymde.value()
handleInput(val)
}
function handleCodemirrorBlur() {
const val = easymde.value()
isValueUpdateFromInner.value = true
emit('blur', val)
}
function handleInput(val) {
isValueUpdateFromInner.value = true
emit('update:modelValue', val)
}
watch(
() => props.modelValue,
(val) => {
if (isValueUpdateFromInner.value) {
isValueUpdateFromInner.value = false
} else {
easymde.value(val)
}
},
)
</script>
<style lang="scss">
.EasyMDEContainer {
display: block;
}
@ -58,10 +205,6 @@
.editor-toolbar {
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-o-user-select: none;
user-select: none;
padding: 9px 10px;
border-top: 1px solid #bbb;
@ -89,11 +232,6 @@
.editor-toolbar.fullscreen::before {
width: 20px;
height: 50px;
background: -moz-linear-gradient(left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255, 255, 255, 1)), color-stop(100%, rgba(255, 255, 255, 0)));
background: -webkit-linear-gradient(left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: -o-linear-gradient(left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: -ms-linear-gradient(left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
background: linear-gradient(to right, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
position: fixed;
top: 0;
@ -105,11 +243,6 @@
.editor-toolbar.fullscreen::after {
width: 20px;
height: 50px;
background: -moz-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255, 255, 255, 0)), color-stop(100%, rgba(255, 255, 255, 1)));
background: -webkit-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
background: -o-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
background: -ms-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
position: fixed;
top: 0;
@ -371,4 +504,16 @@ span[data-img-src]::after{
padding-top: var(--height);
width: var(--width);
background-repeat: no-repeat;
}
}
</style>
<style lang="scss" scoped>
.vue-easymde .markdown-body {
padding: 0.5em
}
.vue-easymde .editor-preview-active,
.vue-easymde .editor-preview-active-side {
display: block;
}
</style>

View File

@ -1,157 +0,0 @@
<template>
<div class="vue-easymde">
<textarea
class="vue-simplemde-textarea"
:name="name"
:value="modelValue"
@input="handleInput($event.target.value)"
/>
</div>
</template>
<script>
import EasyMDE from 'easymde'
import {marked} from 'marked'
export default {
name: 'vue-easymde',
props: {
modelValue: String,
name: String,
previewClass: String,
autoinit: {
type: Boolean,
default: true,
},
highlight: {
type: Boolean,
default: false,
},
sanitize: {
type: Boolean,
default: false,
},
configs: {
type: Object,
default() {
return {}
},
},
previewRender: {
type: Function,
},
},
emits: ['update:modelValue', 'blur', 'initialized'],
data() {
return {
isValueUpdateFromInner: false,
easymde: null,
}
},
mounted() {
if (this.autoinit) this.initialize()
},
deactivated() {
const editor = this.easymde
if (!editor) return
const isFullScreen = editor.codemirror.getOption('fullScreen')
if (isFullScreen) editor.toggleFullScreen()
},
beforeUnmount() {
if (this.easymde) {
this.easymde.toTextArea()
this.easymde.cleanup()
this.easymde = null
}
},
methods: {
initialize() {
const configs = Object.assign({
element: this.$el.firstElementChild,
initialValue: this.modelValue,
previewRender: this.previewRender,
renderingConfig: {},
}, this.configs)
// Synchronize the values of value and initialValue
if (configs.initialValue) {
this.$emit('update:modelValue', configs.initialValue)
}
// Determine whether to enable code highlighting
if (this.highlight) {
configs.renderingConfig.codeSyntaxHighlighting = true
}
// Set whether to render the input html
marked.setOptions({ sanitize: this.sanitize })
// Instantiated editor
this.easymde = new EasyMDE(configs)
// Add a custom previewClass
const className = this.previewClass || ''
this.addPreviewClass(className)
// Binding event
this.bindingEvents()
this.$nextTick(() => {
this.$emit('initialized', this.easymde)
})
},
addPreviewClass(className) {
const wrapper = this.easymde.codemirror.getWrapperElement()
const preview = document.createElement('div')
wrapper.nextSibling.className += ` ${className}`
preview.className = `editor-preview ${className}`
wrapper.appendChild(preview)
},
bindingEvents() {
this.easymde.codemirror.on('change', this.handleCodemirrorInput)
this.easymde.codemirror.on('blur', this.handleCodemirrorBlur)
},
handleCodemirrorInput(instance, changeObj) {
if (changeObj.origin === 'setValue') {
return
}
const val = this.easymde.value()
this.handleInput(val)
},
handleCodemirrorBlur() {
const val = this.easymde.value()
this.isValueUpdateFromInner = true
this.$emit('blur', val)
},
handleInput(val) {
this.isValueUpdateFromInner = true
this.$emit('update:modelValue', val)
},
},
watch: {
modelValue(val) {
if (this.isValueUpdateFromInner) {
this.isValueUpdateFromInner = false
} else {
this.easymde.value(val)
}
},
},
}
</script>
<style lang="scss" scoped>
.vue-easymde .markdown-body {
padding: 0.5em
}
.vue-easymde .editor-preview-active, .vue-easymde .editor-preview-active-side {
display: block;
}
</style>

View File

@ -2,21 +2,22 @@
<dropdown>
<template v-if="isSavedFilter">
<dropdown-item
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }"
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }"
:to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
icon="trash-alt"
>
{{ $t('misc.delete') }}
</dropdown-item>
</template>
<template v-else-if="list.isArchived">
<dropdown-item
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }"
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
@ -24,32 +25,32 @@
</template>
<template v-else>
<dropdown-item
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }"
:to="{ name: 'list.settings.edit', params: { listId: list.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.background`, params: { listId: list.id } }"
v-if="backgroundsEnabled"
:to="{ name: 'list.settings.background', params: { listId: list.id } }"
icon="image"
>
{{ $t('menu.setBackground') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.share`, params: { listId: list.id } }"
:to="{ name: 'list.settings.share', params: { listId: list.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.duplicate`, params: { listId: list.id } }"
:to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
icon="paste"
>
{{ $t('menu.duplicate') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }"
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
@ -59,11 +60,11 @@
:is-button="false"
entity="list"
:entity-id="list.id"
:subscription="subscription"
:subscription="list.subscription"
@change="sub => subscription = sub"
/>
<dropdown-item
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }"
:to="{ name: 'list.settings.delete', params: { listId: list.id } }"
icon="trash-alt"
class="has-text-danger"
>
@ -73,56 +74,30 @@
</dropdown>
</template>
<script>
<script setup lang="ts">
import {ref, computed, watchEffect} from 'vue'
import {useStore} from 'vuex'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import TaskSubscription from '@/components/misc/subscription.vue'
import ListModel from '@/models/list'
import SubscriptionModel from '@/models/subscription'
export default {
name: 'list-settings-dropdown',
data() {
return {
subscription: null,
}
const props = defineProps({
list: {
type: ListModel,
required: true,
},
components: {
TaskSubscription,
DropdownItem,
Dropdown,
},
props: {
list: {
required: true,
},
},
mounted() {
this.subscription = this.list.subscription
},
computed: {
backgroundsEnabled() {
return this.$store.state.config.enabledBackgroundProviders !== null && this.$store.state.config.enabledBackgroundProviders.length > 0
},
listRoutePrefix() {
let name = 'list'
})
const subscription = ref<SubscriptionModel | null>(null)
watchEffect(() => {
subscription.value = props.list.subscription ?? null
})
if (this.$route.name !== null && this.$route.name.startsWith('list.')) {
// HACK: we should implement a better routing for the modals
const settingsRoutes = ['edit', 'delete', 'archive', 'background', 'share', 'duplicate']
const suffix = settingsRoutes.find((route) => this.$route.name.endsWith(`.settings.${route}`))
name = this.$route.name.replace(`.settings.${suffix}`,'')
}
if (this.isSavedFilter) {
name = name.replace('list.', 'filter.')
}
return `${name}.settings`
},
isSavedFilter() {
return getSavedFilterIdFromListId(this.list.id) > 0
},
},
}
const store = useStore()
const backgroundsEnabled = computed(() => store.state.config.enabledBackgroundProviders?.length > 0)
const isSavedFilter = computed(() => getSavedFilterIdFromListId(props.list.id) > 0)
</script>

View File

@ -1,41 +1,45 @@
<template>
<x-button
v-if="hasFilters"
type="secondary"
variant="secondary"
@click="clearFilters"
>
{{ $t('filters.clear') }}
</x-button>
<popup>
<template #trigger="{toggle}">
<x-button
@click.prevent.stop="toggle()"
type="secondary"
icon="filter"
>
{{ $t('filters.title') }}
</x-button>
</template>
<template #content="{isOpen}">
<filters
v-model="value"
ref="filters"
class="filter-popup"
:class="{'is-open': isOpen}"
/>
</template>
</popup>
<x-button
@click="() => modalOpen = true"
variant="secondary"
icon="filter"
>
{{ $t('filters.title') }}
</x-button>
<modal
@close="() => modalOpen = false"
:enabled="modalOpen"
transition-name="fade"
:overflow="true"
variant="hint-modal"
>
<filters
:has-title="true"
v-model="value"
ref="filters"
class="filter-popup"
:class="{'is-open': isOpen}"
/>
</modal>
</template>
<script>
import Filters from '@/components/list/partials/filters'
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
import Popup from '@/components/misc/popup'
<script lang="ts">
import {defineComponent, ref} from 'vue'
export default {
import Filters from '@/components/list/partials/filters.vue'
import {getDefaultParams} from '@/composables/taskList'
export default defineComponent({
name: 'filter-popup',
components: {
Popup,
Filters,
},
props: {
@ -79,12 +83,19 @@ export default {
immediate: true,
},
},
setup() {
const modalOpen = ref(false)
return {
modalOpen,
}
},
methods: {
clearFilters() {
this.value = {...getDefaultParams()}
},
},
}
})
</script>
<style scoped lang="scss">

View File

@ -1,5 +1,5 @@
<template>
<card class="filters has-overflow">
<card class="filters has-overflow" :title="hasTitle ? $t('filters.title') : ''">
<div class="field">
<fancycheckbox v-model="params.filter_include_nulls">
{{ $t('filters.attributes.includeNulls') }}
@ -36,7 +36,7 @@
<label class="label">{{ $t('task.attributes.priority') }}</label>
<div class="control single-value-control">
<priority-select
:disabled="!filters.usePriority || null"
:disabled="!filters.usePriority || undefined"
v-model.number="filters.priority"
@change="setPriority"
/>
@ -54,7 +54,7 @@
<percent-done-select
v-model.number="filters.percentDone"
@change="setPercentDoneFilter"
:disabled="!filters.usePercentDone || null"
:disabled="!filters.usePercentDone || undefined"
/>
<fancycheckbox
v-model="filters.usePercentDone"
@ -67,49 +67,49 @@
<div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setDueDateFilter"
class="input"
:placeholder="$t('filters.attributes.dueDateRange')"
v-model="filters.dueDate"
/>
<datepicker-with-range @dateChanged="values => setDateFilter('due_date', values)">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setStartDateFilter"
class="input"
:placeholder="$t('filters.attributes.startDateRange')"
v-model="filters.startDate"
/>
<datepicker-with-range @dateChanged="values => setDateFilter('start_date', values)">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setEndDateFilter"
class="input"
:placeholder="$t('filters.attributes.endDateRange')"
v-model="filters.endDate"
/>
<datepicker-with-range @dateChanged="values => setDateFilter('end_date', values)">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setReminderFilter"
class="input"
:placeholder="$t('filters.attributes.reminderRange')"
v-model="filters.reminders"
/>
<datepicker-with-range @dateChanged="values => setDateFilter('reminders', values)">
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</div>
</div>
@ -174,16 +174,17 @@
</card>
</template>
<script>
<script lang="ts">
import {defineComponent} from 'vue'
import DatepickerWithRange from '@/components/date/datepickerWithRange'
import Fancycheckbox from '../../input/fancycheckbox'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {includesById} from '@/helpers/utils'
import {formatISO} from 'date-fns'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import Multiselect from '@/components/input/multiselect.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import UserService from '@/services/user'
import ListService from '@/services/list'
@ -191,7 +192,7 @@ 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'
import {getDefaultParams} from '@/composables/taskList'
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = {
@ -222,15 +223,15 @@ const DEFAULT_FILTERS = {
namespace: '',
}
export const ALPHABETICAL_SORT = 'title'
export const ALPHABETICAL_SORT = 'title'
export default {
export default defineComponent({
name: 'filters',
components: {
DatepickerWithRange,
EditLabels,
PrioritySelect,
Fancycheckbox,
flatPickr,
PercentDoneSelect,
Multiselect,
},
@ -262,6 +263,10 @@ export default {
modelValue: {
required: true,
},
hasTitle: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue', 'change'],
watch: {
@ -281,7 +286,7 @@ export default {
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
},
set(sortAlphabetically) {
this.params.sort_by = sortAlphabetically
this.params.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by
@ -291,24 +296,13 @@ export default {
foundLabels() {
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
},
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
mode: 'range',
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
methods: {
change() {
this.$emit('update:modelValue', this.params)
this.$emit('change', this.params)
const params = {...this.params}
params.filter_value = params.filter_value.map(v => v instanceof Date ? v.toISOString() : v)
this.$emit('update:modelValue', params)
this.$emit('change', params)
},
prepareFilters() {
this.prepareDone()
@ -343,19 +337,12 @@ export default {
}
}
},
setDateFilter(filterName, variableName = null) {
if (variableName === null) {
variableName = filterName
}
// Only filter if we have a start and end due date
if (this.filters[variableName] !== '') {
setDateFilter(filterName, {dateFrom, dateTo}) {
dateFrom = parseDateOrString(dateFrom, null)
dateTo = parseDateOrString(dateTo, null)
const parts = this.filters[variableName].split(' to ')
if (parts.length < 2) {
return
}
// Only filter if we have a date
if (dateFrom !== null && dateTo !== null) {
// Check if we already have values in params and only update them if we do
let foundStart = false
@ -363,23 +350,23 @@ export default {
this.params.filter_by.forEach((f, i) => {
if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') {
foundStart = true
this.params.filter_value[i] = formatISO(new Date(parts[0]))
this.params.filter_value[i] = dateFrom
}
if (f === filterName && this.params.filter_comparator[i] === 'less_equals') {
foundEnd = true
this.params.filter_value[i] = formatISO(new Date(parts[1]))
this.params.filter_value[i] = dateTo
}
})
if (!foundStart) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push('greater_equals')
this.params.filter_value.push(formatISO(new Date(parts[0])))
this.params.filter_value.push(dateFrom)
}
if (!foundEnd) {
this.params.filter_by.push(filterName)
this.params.filter_comparator.push('less_equals')
this.params.filter_value.push(formatISO(new Date(parts[1])))
this.params.filter_value.push(dateTo)
}
this.change()
return
@ -513,24 +500,12 @@ export default {
this.params.filter_concat = 'or'
}
},
setDueDateFilter() {
this.setDateFilter('due_date', 'dueDate')
},
setPriority() {
this.setSingleValueFilter('priority', 'priority', 'usePriority')
},
setStartDateFilter() {
this.setDateFilter('start_date', 'startDate')
},
setEndDateFilter() {
this.setDateFilter('end_date', 'endDate')
},
setPercentDoneFilter() {
this.setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
},
setReminderFilter() {
this.setDateFilter('reminders')
},
clear(kind) {
this[`found${kind}`] = []
},
@ -606,10 +581,10 @@ export default {
this.setSingleValueFilter('labels', 'labels', '', 'in')
},
},
}
})
</script>
<style lang="scss">
<style lang="scss" scoped>
.single-value-control {
display: flex;
align-items: center;
@ -618,4 +593,8 @@ export default {
margin-left: .5rem;
}
}
:deep(.datepicker-with-range-container .popup) {
right: 0;
}
</style>

View File

@ -2,43 +2,54 @@
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor),
'has-background': background !== null
'has-background': blurHashUrl !== ''
}"
:style="{
'background-color': list.hexColor,
'background-image': background !== null ? `url(${background})` : false,
'background-image': blurHashUrl !== null ? `url(${blurHashUrl})` : false,
}"
:to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card"
v-if="list !== null && (showArchived ? true : !list.isArchived)"
>
<div class="is-archived-container">
<div
class="list-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : false}"></div>
<div class="list-content">
<div class="is-archived-container">
<span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }}
</span>
<span
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
@click.stop="toggleFavoriteList(list)"
class="favorite">
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
<span
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
@click.stop="toggleFavoriteList(list)"
class="favorite">
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']"/>
</span>
</div>
<div class="title">{{ list.title }}</div>
</div>
<div class="title">{{ list.title }}</div>
</router-link>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {PropType, ref, watch} from 'vue'
import {useStore} from 'vuex'
import ListService from '@/services/list'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
const background = ref(null)
import {colorIsDark} from '@/helpers/color/colorIsDark'
import ListModel from '@/models/list'
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
const blurHashUrl = ref('')
const props = defineProps({
list: {
type: Object,
type: Object as PropType<ListModel>,
required: true,
},
showArchived: {
@ -47,13 +58,18 @@ const props = defineProps({
},
})
watch(props.list, loadBackground, { immediate: true })
watch(props.list, loadBackground, {immediate: true})
async function loadBackground() {
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
return
}
const blurHash = await getBlobFromBlurHash(props.list.backgroundBlurHash)
if (blurHash) {
blurHashUrl.value = window.URL.createObjectURL(blurHash)
}
backgroundLoading.value = true
const listService = new ListService()
@ -66,7 +82,7 @@ async function loadBackground() {
const store = useStore()
function toggleFavoriteList(list) {
function toggleFavoriteList(list: ListModel) {
// The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) {
@ -78,129 +94,145 @@ function toggleFavoriteList(list) {
<style lang="scss" scoped>
.list-card {
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height;
background: var(--white);
margin: 0 $list-spacing $list-spacing 0;
padding: 1rem;
border-radius: $radius;
box-shadow: var(--shadow-sm);
transition: box-shadow $transition;
cursor: pointer;
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
height: $list-height;
background: var(--white);
margin: 0 $list-spacing $list-spacing 0;
border-radius: $radius;
box-shadow: var(--shadow-sm);
transition: box-shadow $transition;
position: relative;
overflow: hidden;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
&.has-light-text .title {
color: var(--grey-100);
}
&:hover {
box-shadow: var(--shadow-md);
}
&.has-background, .list-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
&.has-background .list-content .title {
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
color: var(--white);
}
@media screen and (min-width: $widescreen) {
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
.list-background {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
$lists-per-row: 3;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
&:hover {
box-shadow: var(--shadow-md);
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
&:active,
&:focus,
&:focus:not(:active) {
box-shadow: var(--shadow-xs) !important;
}
@media screen and (max-width: $tablet) {
$lists-per-row: 2;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
@media screen and (min-width: $widescreen) {
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
$lists-per-row: 3;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
@media screen and (max-width: $mobile) {
$lists-per-row: 1;
& {
width: 100%;
margin-right: 0;
}
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
.is-archived-container {
width: 100%;
text-align: right;
@media screen and (max-width: $tablet) {
$lists-per-row: 2;
& {
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
}
.is-archived {
font-size: .75rem;
float: left;
}
}
&:nth-child(#{$lists-per-row}n) {
margin-right: 0;
}
}
.title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
color: var(--text);
width: 100%;
margin-bottom: 0;
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
@media screen and (max-width: $mobile) {
$lists-per-row: 1;
& {
width: 100%;
margin-right: 0;
}
}
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.list-content {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
padding: 1rem;
position: absolute;
height: 100%;
width: 100%;
&.has-light-text .title {
color: var(--light);
}
.is-archived-container {
width: 100%;
text-align: right;
&.has-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
.is-archived {
font-size: .75rem;
float: left;
}
}
.title {
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
color: var(--white);
}
}
.title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
font-size: 1.5rem;
color: var(--text);
width: 100%;
margin-bottom: 0;
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
.favorite {
transition: opacity $transition, color $transition;
opacity: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
&:hover {
color: var(--warning);
}
.favorite {
transition: opacity $transition, color $transition;
opacity: 0;
&.is-archived {
display: none;
}
&:hover {
color: var(--warning);
}
&.is-favorite {
display: inline-block;
opacity: 1;
color: var(--warning);
}
}
&.is-archived {
display: none;
}
&:hover .favorite {
opacity: 1;
}
&.is-favorite {
display: inline-block;
opacity: 1;
color: var(--warning);
}
}
&:hover .favorite {
opacity: 1;
}
}
}
</style>

View File

@ -16,7 +16,7 @@
/>
</div>
<div class="control">
<x-button @click="setApiUrl" :disabled="apiUrl === '' || null">
<x-button @click="setApiUrl" :disabled="apiUrl === '' || undefined">
{{ $t('apiConfig.change') }}
</x-button>
</div>
@ -39,79 +39,68 @@
</div>
</template>
<script>
import Message from '@/components/misc/message'
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {parseURL} from 'ufo'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import {success} from '@/message'
export default {
name: 'apiConfig',
components: {
Message,
import Message from '@/components/misc/message.vue'
const props = defineProps({
configureOpen: {
type: Boolean,
required: false,
default: false,
},
data() {
return {
configureApi: false,
apiUrl: window.API_URL,
errorMsg: '',
successMsg: '',
})
const emit = defineEmits(['foundApi'])
const apiUrl = ref(window.API_URL)
const configureApi = ref(apiUrl.value === '')
// Because we're only using this to parse the hostname, it should be fine to just prefix with http://
// regardless of whether the url is actually reachable under http.
const apiDomain = computed(() => parseURL(apiUrl.value, 'http://').host || parseURL(window.location.href).host)
watch(() => props.configureOpen, (value) => {
configureApi.value = value
}, {immediate: true})
const {t} = useI18n({useScope: 'global'})
const errorMsg = ref('')
const successMsg = ref('')
async function setApiUrl() {
if (apiUrl.value === '') {
// Don't try to check and set an empty url
errorMsg.value = t('apiConfig.urlRequired')
return
}
try {
const url = await checkAndSetApiUrl(apiUrl.value)
if (url === '') {
// If the config setter function could not figure out a url
throw new Error('URL cannot be empty.')
}
},
emits: ['foundApi'],
created() {
if (this.apiUrl === '') {
this.configureApi = true
}
},
computed: {
apiDomain() {
return parseURL(this.apiUrl).host || parseURL(window.location.href).host
},
},
props: {
configureOpen: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
configureOpen: {
handler(value) {
this.configureApi = value
},
immediate: true,
},
},
methods: {
async setApiUrl() {
if (this.apiUrl === '') {
// Don't try to check and set an empty url
this.errorMsg = this.$t('apiConfig.urlRequired')
return
}
try {
const url = await checkAndSetApiUrl(this.apiUrl)
if (url === '') {
// If the config setter function could not figure out a url
throw new Error('URL cannot be empty.')
}
// Set it + save it to local storage to save us the hoops
this.errorMsg = ''
this.$message.success({message: this.$t('apiConfig.success', {domain: this.apiDomain})})
this.configureApi = false
this.apiUrl = url
this.$emit('foundApi', this.apiUrl)
} catch (e) {
// Still not found, url is still invalid
this.successMsg = ''
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
}
},
},
// Set it + save it to local storage to save us the hoops
errorMsg.value = ''
apiUrl.value = url
success({message: t('apiConfig.success', {domain: apiDomain.value})})
configureApi.value = false
emit('foundApi', apiUrl.value)
} catch (e) {
// Still not found, url is still invalid
successMsg.value = ''
errorMsg.value = t('apiConfig.error', {domain: apiDomain.value})
}
}
</script>

View File

@ -14,76 +14,69 @@
</div>
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
<x-button
v-if="tertiary !== ''"
:shadow="false"
type="tertary"
@click.prevent.stop="$emit('tertary')"
v-if="tertary !== ''"
variant="tertiary"
@click.prevent.stop="$emit('tertiary')"
>
{{ tertary }}
{{ tertiary }}
</x-button>
<x-button
type="secondary"
variant="secondary"
@click.prevent.stop="$router.back()"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
type="primary"
@click.prevent.stop="primary"
variant="primary"
@click.prevent.stop="primary()"
:icon="primaryIcon"
:disabled="primaryDisabled"
v-if="primaryLabel !== ''"
>
{{ primaryLabel }}
{{ primaryLabel || $t('misc.create') }}
</x-button>
</footer>
</card>
</modal>
</template>
<script>
import { i18n } from '@/i18n'
<script lang="ts">
</script>
<script setup lang="ts">
defineProps({
title: {
type: String,
default: '',
},
primaryLabel: {
type: String,
},
primaryIcon: {
type: String,
default: 'plus',
},
primaryDisabled: {
type: Boolean,
default: false,
},
tertiary: {
type: String,
default: '',
},
wide: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
})
export default {
name: 'create-edit',
props: {
title: {
type: String,
default: '',
},
primaryLabel: {
type: String,
default() {
return i18n.global.t('misc.create')
},
},
primaryIcon: {
type: String,
default: 'plus',
},
primaryDisabled: {
type: Boolean,
default: false,
},
tertary: {
type: String,
default: '',
},
wide: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
},
emits: ['create', 'primary', 'tertary'],
methods: {
primary() {
this.$emit('create')
this.$emit('primary')
},
},
const emit = defineEmits(['create', 'primary', 'tertiary'])
function primary() {
emit('create')
emit('primary')
}
</script>

View File

@ -1,10 +1,11 @@
<template>
<div class="dropdown is-right is-active" ref="dropdown">
<div class="dropdown-trigger is-flex" @click="open = !open">
<slot name="trigger" :close="close">
<slot name="trigger" :close="close" :toggleOpen="toggleOpen">
<BaseButton class="dropdown-trigger is-flex" @click="toggleOpen">
<icon :icon="triggerIcon" class="icon"/>
</slot>
</div>
</BaseButton>
</slot>
<transition name="fade">
<div class="dropdown-menu" v-if="open">
<div class="dropdown-content">
@ -15,45 +16,42 @@
</div>
</template>
<script>
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
<script setup lang="ts">
import {ref} from 'vue'
import {onClickOutside} from '@vueuse/core'
export default {
name: 'dropdown',
data() {
return {
open: false,
}
},
mounted() {
document.addEventListener('click', this.handleClickOutside)
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutside)
},
props: {
triggerIcon: {
type: String,
default: 'ellipsis-h',
},
},
emits: ['close'],
methods: {
close() {
this.open = false
},
toggleOpen() {
this.open = !this.open
},
handleClickOutside(e) {
if (!this.open) {
return
}
closeWhenClickedOutside(e, this.$refs.dropdown, () => {
this.open = false
this.$emit('close', e)
})
},
import BaseButton from '@/components/base/BaseButton.vue'
defineProps({
triggerIcon: {
type: String,
default: 'ellipsis-h',
},
})
const emit = defineEmits(['close'])
const open = ref(false)
function close() {
open.value = false
}
function toggleOpen() {
open.value = !open.value
}
const dropdown = ref()
onClickOutside(dropdown, (e: Event) => {
if (!open.value) {
return
}
close()
emit('close', e)
})
</script>
<style lang="scss" scoped>
.dropdown-menu .dropdown-content {
box-shadow: var(--shadow-md);
}
</style>

View File

@ -4,9 +4,15 @@
<template v-for="(s, i) in shortcuts" :key="i">
<h3>{{ $t(s.title) }}</h3>
<message>
<message class="mb-4" v-if="s.available">
{{
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages')
typeof s.available === 'undefined' ?
$t('keyboardShortcuts.allPages') :
(
s.available($route)
? $t('keyboardShortcuts.currentPageOnly')
: $t('keyboardShortcuts.somePagesOnly')
)
}}
</message>
@ -17,7 +23,8 @@
class="shortcut-keys"
is="dd"
:keys="sc.keys"
:combination="typeof sc.combination !== 'undefined' ? $t(`keyboardShortcuts.${sc.combination}`) : null"/>
:combination="sc.combination && $t(`keyboardShortcuts.${sc.combination}`)"
/>
</template>
</dl>
</template>
@ -25,28 +32,19 @@
</modal>
</template>
<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'
<script lang="ts" setup>
import {useStore} from 'vuex'
export default {
name: 'keyboard-shortcuts',
components: {
Message,
Shortcut,
},
data() {
return {
shortcuts: KEYBOARD_SHORTCUTS,
}
},
methods: {
close() {
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
},
},
import Shortcut from '@/components/misc/shortcut.vue'
import Message from '@/components/misc/message.vue'
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
import {KEYBOARD_SHORTCUTS as shortcuts} from './shortcuts'
const store = useStore()
function close() {
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
}
</script>

View File

@ -1,11 +1,24 @@
import {RouteLocation} from 'vue-router'
import {isAppleDevice} from '@/helpers/isAppleDevice'
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
export const KEYBOARD_SHORTCUTS = [
interface Shortcut {
title: string
keys: string[]
combination?: 'then'
}
interface ShortcutGroup {
title: string
available?: (route: RouteLocation) => boolean
shortcuts: Shortcut[]
}
export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
{
title: 'keyboardShortcuts.general',
available: () => null,
shortcuts: [
{
title: 'keyboardShortcuts.toggleMenu',
@ -17,6 +30,36 @@ export const KEYBOARD_SHORTCUTS = [
},
],
},
{
title: 'keyboardShortcuts.navigation.title',
shortcuts: [
{
title: 'keyboardShortcuts.navigation.overview',
keys: ['g', 'o'],
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.upcoming',
keys: ['g', 'u'],
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.namespaces',
keys: ['g', 'n'],
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.labels',
keys: ['g', 'a'],
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.teams',
keys: ['g', 'm'],
combination: 'then',
},
],
},
{
title: 'list.kanban.title',
available: (route) => route.name === 'list.kanban',
@ -29,7 +72,7 @@ export const KEYBOARD_SHORTCUTS = [
},
{
title: 'keyboardShortcuts.list.title',
available: (route) => route.name.startsWith('list.'),
available: (route) => (route.name as string)?.startsWith('list.'),
shortcuts: [
{
title: 'keyboardShortcuts.list.switchToListView',
@ -55,14 +98,12 @@ export const KEYBOARD_SHORTCUTS = [
},
{
title: 'keyboardShortcuts.task.title',
available: (route) => [
'task.detail',
'task.list.detail',
'task.gantt.detail',
'task.kanban.detail',
'task.detail',
].includes(route.name),
available: (route) => route.name === 'task.detail',
shortcuts: [
{
title: 'keyboardShortcuts.task.done',
keys: ['t'],
},
{
title: 'keyboardShortcuts.task.assign',
keys: ['a'],
@ -83,6 +124,18 @@ export const KEYBOARD_SHORTCUTS = [
title: 'keyboardShortcuts.task.related',
keys: ['r'],
},
{
title: 'keyboardShortcuts.task.move',
keys: ['m'],
},
{
title: 'keyboardShortcuts.task.color',
keys: ['c'],
},
{
title: 'keyboardShortcuts.task.reminder',
keys: ['alt', 'r'],
},
],
},
]

View File

@ -2,12 +2,17 @@
<div class="loader-container is-loading"></div>
</template>
<style scoped>
<style scoped lang="scss">
.loader-container {
height: 100%;
min-height: 200px;
width: 100%;
min-width: 600px;
max-width: 100vw;
&.is-loading-small {
min-height: 50px;
min-width: 100px;
}
}
</style>

View File

@ -1,18 +1,35 @@
<template>
<div class="message-wrapper">
<div class="message" :class="variant">
<div class="message" :class="[variant, textAlignClass]">
<slot/>
</div>
</div>
</template>
<script lang="ts" setup>
defineProps({
import {computed, PropType} from 'vue'
const TEXT_ALIGN_MAP = Object.freeze({
left: '',
center: 'has-text-centered',
right: 'has-text-right',
})
type textAlignVariants = keyof typeof TEXT_ALIGN_MAP
const props = defineProps({
variant: {
type: String,
default: 'info',
},
textAlign: {
type: String as PropType<textAlignVariants>,
default: 'left',
},
})
const textAlignClass = computed(() => TEXT_ALIGN_MAP[props.textAlign])
</script>
<style lang="scss" scoped>

View File

@ -0,0 +1,205 @@
<template>
<Teleport to="body">
<!-- FIXME: transition should not be included in the modal -->
<transition name="modal">
<section
v-if="enabled"
class="modal-mask"
:class="[
{ 'has-overflow': overflow },
variant,
]"
ref="modal"
>
<div
class="modal-container"
@click.self.prevent.stop="$emit('close')"
v-shortcut="'Escape'"
>
<div
class="modal-content"
:class="{
'has-overflow': overflow,
'is-wide': wide
}"
>
<BaseButton
@click="$emit('close')"
class="close"
>
<icon icon="times"/>
</BaseButton>
<slot>
<div class="header">
<slot name="header"></slot>
</div>
<div class="content">
<slot name="text"></slot>
</div>
<div class="actions">
<x-button
@click="$emit('close')"
variant="tertiary"
class="has-text-danger"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
@click="$emit('submit')"
variant="primary"
v-cy="'modalPrimary'"
:shadow="false"
>
{{ $t('misc.doit') }}
</x-button>
</div>
</slot>
</div>
</div>
</section>
</transition>
</Teleport>
</template>
<script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue'
import {onUnmounted, ref, watch} from 'vue'
import {useScrollLock} from '@vueuse/core'
const props = withDefaults(defineProps<{
enabled?: boolean,
overflow?: boolean,
wide?: boolean,
transitionName?: 'modal' | 'fade',
variant?: 'default' | 'hint-modal' | 'scrolling',
}>(), {
enabled: true,
transitionName: 'modal',
variant: 'default',
})
defineEmits(['close', 'submit'])
const modal = ref<HTMLElement | null>(null)
const scrollLock = useScrollLock(modal)
watch(
() => props.enabled,
enabled => {
scrollLock.value = enabled
},
)
onUnmounted(() => scrollLock.value = false)
</script>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
z-index: 4000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, .8);
transition: opacity 150ms ease;
color: #fff;
}
.modal-container {
transition: all 150ms ease;
position: relative;
width: 100%;
height: 100%;
max-height: 100vh;
overflow: auto;
}
.default .modal-content,
.hint-modal .modal-content {
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@media screen and (max-width: $tablet) {
margin: 0;
top: 25%;
transform: translate(-50%, -25%);
}
.header {
font-size: 2rem;
font-weight: 700;
}
.button {
margin: 0 0.5rem;
}
}
// scrolling-content
// used e.g. for <TaskDetailViewModal>
.scrolling .modal-content {
max-width: 1024px;
width: 100%;
margin: 4rem auto;
max-height: none; // reset bulma
overflow: visible; // reset bulma
@media screen and (min-width: $tablet) {
max-height: none; // reset bulma
margin: 4rem auto; // reset bulma
width: 100%;
}
@media screen and (max-width: $desktop) {
margin: 0;
}
}
.is-wide {
max-width: $desktop;
width: calc(100% - 2rem);
}
.hint-modal {
z-index: 4600;
:deep(.card-content) {
text-align: left;
.info {
font-style: italic;
}
p {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}
.close {
position: fixed;
top: 5px;
right: 26px;
color: var(--white);
font-size: 2rem;
@media screen and (max-width: $desktop) {
color: var(--grey-900);
}
}
</style>
<style lang="scss">
// Close icon SVG uses currentColor, change the color to keep it visible
.dark .close {
color: var(--grey-900);
}
</style>

View File

@ -13,7 +13,10 @@
<section class="content">
<div>
<h2 class="title" v-if="title">{{ title }}</h2>
<api-config @foundApi="hasApiUrl = true"/>
<api-config/>
<Message v-if="motd !== ''" class="is-hidden-tablet mb-4">
{{ motd }}
</Message>
<slot/>
</div>
<legal/>
@ -23,9 +26,9 @@
</template>
<script lang="ts" setup>
import Logo from '@/components/home/Logo'
import Message from '@/components/misc/message'
import Legal from '@/components/misc/legal'
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'
@ -35,11 +38,11 @@ import {useTitle} from '@/composables/useTitle'
const route = useRoute()
const store = useStore()
const {t} = useI18n()
const {t} = useI18n({useScope: 'global'})
const motd = computed(() => store.state.config.motd)
// @ts-ignore
const title = computed(() => t(route.meta.title ?? ''))
const title = computed(() => t(route.meta?.title as string || ''))
useTitle(() => title.value)
</script>

View File

@ -2,10 +2,4 @@
<p class="has-text-centered has-text-grey is-italic p-4 mb-4">
<slot></slot>
</p>
</template>
<script>
export default {
name: 'nothing',
}
</script>
</template>

View File

@ -26,7 +26,7 @@
@click="action.callback"
:shadow="false"
class="is-small"
type="secondary"
variant="secondary"
v-for="(action, i) in item.data.actions"
>
{{ action.title }}
@ -37,12 +37,6 @@
</notifications>
</template>
<script>
export default {
name: 'notification',
}
</script>
<style scoped>
.vue-notification {
z-index: 9999;

View File

@ -6,13 +6,13 @@
v-if="totalPages > 1"
>
<router-link
:disabled="currentPage === 1 || null"
:disabled="currentPage === 1 || undefined"
:to="getRouteForPagination(currentPage - 1)"
class="pagination-previous">
{{ $t('misc.previous') }}
</router-link>
<router-link
:disabled="currentPage === totalPages || null"
:disabled="currentPage === totalPages || undefined"
:to="getRouteForPagination(currentPage + 1)"
class="pagination-next">
{{ $t('misc.next') }}

View File

@ -1,11 +1,11 @@
<template>
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
<div class="popup" :class="{'is-open': open}" ref="popup">
<div class="popup" :class="{'is-open': open, 'has-overflow': props.hasOverflow && open}" ref="popup">
<slot name="content" :isOpen="open"/>
</div>
</template>
<script setup>
<script setup lang="ts">
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {onBeforeUnmount, onMounted, ref} from 'vue'
@ -16,6 +16,13 @@ const toggle = () => {
open.value = !open.value
}
const props = defineProps({
hasOverflow: {
type: Boolean,
default: false,
},
})
function hidePopup(e) {
if (!open.value) {
return

View File

@ -50,11 +50,18 @@ import Message from '@/components/misc/message.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
import {useOnline} from '@/composables/useOnline'
import {useRouter, useRoute} from 'vue-router'
import {getAuthForRoute} from '@/router'
const router = useRouter()
const route = useRoute()
const store = useStore()
const ready = computed(() => store.state.vikunjaReady)
const online = computed(() => store.state.online)
const ready = ref(false)
const online = useOnline()
const error = ref('')
const showLoading = computed(() => !ready.value && error.value === '')
@ -62,7 +69,12 @@ const showLoading = computed(() => !ready.value && error.value === '')
async function load() {
try {
await store.dispatch('loadApp')
} catch(e: any) {
const redirectTo = getAuthForRoute(route)
if (typeof redirectTo !== 'undefined') {
await router.push(redirectTo)
}
ready.value = true
} catch (e: any) {
error.value = e
}
}

View File

@ -1,63 +1,62 @@
<template>
<x-button
type="secondary"
:icon="icon"
v-if="isButton"
variant="secondary"
:icon="iconName"
v-tooltip="tooltipText"
@click="changeSubscription"
:disabled="disabled || null"
v-if="isButton"
:disabled="disabled || undefined"
>
{{ buttonText }}
</x-button>
<a
<BaseButton
v-else
v-tooltip="tooltipText"
@click="changeSubscription"
:class="{'is-disabled': disabled}"
v-else
>
<span class="icon">
<icon :icon="icon"/>
<icon :icon="iconName"/>
</span>
{{ buttonText }}
</a>
</BaseButton>
</template>
<script lang="ts" setup>
import {computed, shallowRef} from 'vue'
import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import SubscriptionService from '@/services/subscription'
import SubscriptionModel from '@/models/subscription'
import {success} from '@/message'
const props = defineProps({
entity: {
required: true,
type: String,
},
subscription: {
required: true,
},
entityId: {
required: true,
},
isButton: {
type: Boolean,
default: true,
},
interface Props {
entity: string
entityId: number
subscription: SubscriptionModel | null
isButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isButton: true,
subscription: null,
})
const subscriptionEntity = computed<string | null>(() => props.subscription?.entity ?? null)
const emit = defineEmits(['change'])
const subscriptionService = shallowRef(new SubscriptionService())
const {t} = useI18n()
const {t} = useI18n({useScope: 'global'})
const tooltipText = computed(() => {
if (disabled.value) {
return t('task.subscription.subscribedThroughParent', {
entity: props.entity,
parent: props.subscription.entity,
parent: subscriptionEntity.value,
})
}
@ -67,13 +66,13 @@ const tooltipText = computed(() => {
})
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 iconName = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
const disabled = computed(() => {
if (props.subscription === null) {
return false
}
return props.subscription.entity !== props.entity
return subscriptionEntity.value !== props.entity
})
function changeSubscription() {

View File

@ -1,212 +0,0 @@
<template>
<transition name="modal">
<section
v-if="enabled"
class="modal-mask"
:class="[
{ 'has-overflow': overflow },
variant,
]"
>
<div
class="modal-container"
:class="{'has-overflow': overflow}"
@click.self.prevent.stop="$emit('close')"
v-shortcut="'Escape'"
>
<div
class="modal-content"
:class="{
'has-overflow': overflow,
'is-wide': wide
}"
>
<slot>
<div class="header">
<slot name="header"></slot>
</div>
<div class="content">
<slot name="text"></slot>
</div>
<div class="actions">
<x-button
@click="$emit('close')"
type="tertary"
class="has-text-danger"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
@click="$emit('submit')"
type="primary"
:shadow="false"
>
{{ $t('misc.doit') }}
</x-button>
</div>
</slot>
</div>
</div>
</section>
</transition>
</template>
<script>
export const TRANSITION_NAMES = {
MODAL: 'modal',
FADE: 'fade',
}
export const VARIANTS = {
DEFAULT: 'default',
HINT_MODAL: 'hint-modal',
SCROLLING: 'scrolling',
}
function validValue(values) {
return (value) => Object.values(values).includes(value)
}
export default {
name: 'modal',
mounted() {
document.addEventListener('keydown', (e) => {
// Close the model when escape is pressed
if (e.keyCode === 27) {
this.$emit('close')
}
})
},
props: {
enabled: {
type: Boolean,
default: true,
},
overflow: {
type: Boolean,
default: false,
},
wide: {
type: Boolean,
default: false,
},
transitionName: {
type: String,
default: TRANSITION_NAMES.MODAL,
validator: validValue(TRANSITION_NAMES),
},
variant: {
type: String,
default: VARIANTS.DEFAULT,
validator: validValue(VARIANTS),
},
},
emits: ['close', 'submit'],
}
</script>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
z-index: 4000;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, .8);
transition: opacity 150ms ease;
color: #fff;
}
.modal-container {
transition: all 150ms ease;
position: relative;
width: 100%;
height: 100%;
max-height: 100vh;
overflow: auto;
}
.default .modal-content,
.hint-modal .modal-content {
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@media screen and (max-width: $tablet) {
margin: 0;
top: 25%;
transform: translate(-50%, -25%);
}
.header {
font-size: 2rem;
font-weight: 700;
}
.button {
margin: 0 0.5rem;
}
}
// scrolling-content
// used e.g. for <TaskDetailViewModal>
.scrolling .modal-content {
max-width: 1024px;
width: 100%;
margin: 4rem auto;
max-height: none; // reset bulma
overflow: visible; // reset bulma
@media screen and (min-width: $tablet) {
max-height: none; // reset bulma
margin: 4rem auto; // reset bulma
width: 100%;
}
@media screen and (max-width: $desktop) {
margin: 0;
}
}
.is-wide {
max-width: $desktop;
width: calc(100% - 2rem);
}
.hint-modal {
z-index: 4600;
:deep(.card-content) {
text-align: left;
.info {
font-style: italic;
}
p {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}
/* Transitions */
.modal-enter,
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
transform: scale(0.9);
}
</style>

View File

@ -1,31 +0,0 @@
<template>
<multiselect
:placeholder="$t('namespace.search')"
@search="findNamespaces"
:search-results="namespaces"
@select="select"
label="title"
:search-delay="10"
/>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useStore} from 'vuex'
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)
}
</script>

View File

@ -16,13 +16,13 @@
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.share', params: { id: namespace.id } }"
:to="{ name: 'namespace.settings.share', params: { namespaceId: namespace.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'list.create', params: { id: namespace.id } }"
:to="{ name: 'list.create', params: { namespaceId: namespace.id } }"
icon="plus"
>
{{ $t('menu.newList') }}

View File

@ -21,20 +21,19 @@
:show-username="false"
:avatar-size="16"
v-if="n.notification.doer"/>
<span class="detail">
<p>
<span class="has-text-weight-bold" v-if="n.notification.doer">
{{ n.notification.doer.getDisplayName() }}
<div class="detail">
<div>
<span class="has-text-weight-bold mr-1" v-if="n.notification.doer">
{{ n.notification.doer.getDisplayName() }}
</span>
<a @click="() => to(n, index)()">
{{ n.toText(userInfo) }}
</a>
</div>
<span class="created" v-tooltip="formatDate(n.created)">
{{ formatDateSince(n.created) }}
</span>
<a @click="() => to(n, index)()">
{{ n.toText(userInfo) }}
</a>
</p>
<div class="created" v-tooltip="formatDate(n.created)">
{{ formatDateSince(n.created) }}
</div>
</span>
</div>
<p class="nothing" v-if="notifications.length === 0">
{{ $t('notification.none') }}<br/>
@ -47,90 +46,92 @@
</div>
</template>
<script>
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import NotificationService from '@/services/notification'
import User from '@/components/misc/user.vue'
import names from '@/models/constants/notificationNames.json'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {mapState} from 'vuex'
import {useStore} from 'vuex'
import {useRouter} from 'vue-router'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
export default {
name: 'notifications',
components: {User},
data() {
return {
notificationService: new NotificationService(),
allNotifications: [],
showNotifications: false,
interval: null,
const store = useStore()
const router = useRouter()
const allNotifications = ref([])
const showNotifications = ref(false)
const popup = ref(null)
const unreadNotifications = computed(() => {
return notifications.value.filter(n => n.readAt === null).length
})
const notifications = computed(() => {
return allNotifications.value ? allNotifications.value.filter(n => n.name !== '') : []
})
const userInfo = computed(() => store.state.auth.info)
let interval: number
onMounted(() => {
loadNotifications()
document.addEventListener('click', hidePopup)
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
})
onUnmounted(() => {
document.removeEventListener('click', hidePopup)
clearInterval(interval)
})
async function loadNotifications() {
// We're recreating the notification service here to make sure it uses the latest api user token
const notificationService = new NotificationService()
allNotifications.value = await notificationService.getAll()
}
function hidePopup(e) {
if (showNotifications.value) {
closeWhenClickedOutside(e, popup.value, () => showNotifications.value = false)
}
}
function to(n, index) {
const to = {
name: '',
params: {},
}
switch (n.name) {
case names.TASK_COMMENT:
case names.TASK_ASSIGNED:
to.name = 'task.detail'
to.params.id = n.notification.task.id
break
case names.TASK_DELETED:
// Nothing
break
case names.LIST_CREATED:
to.name = 'task.index'
to.params.listId = n.notification.list.id
break
case names.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
to.params.id = n.notification.team.id
break
}
return async () => {
if (to.name !== '') {
router.push(to)
}
},
mounted() {
this.loadNotifications()
document.addEventListener('click', this.hidePopup)
this.interval = setInterval(this.loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
},
beforeUnmount() {
document.removeEventListener('click', this.hidePopup)
clearInterval(this.interval)
},
computed: {
unreadNotifications() {
return this.notifications.filter(n => n.readAt === null).length
},
notifications() {
return this.allNotifications.filter(n => n.name !== '')
},
...mapState({
userInfo: state => state.auth.info,
}),
},
methods: {
hidePopup(e) {
if (this.showNotifications) {
closeWhenClickedOutside(e, this.$refs.popup, () => this.showNotifications = false)
}
},
async loadNotifications() {
this.allNotifications = await this.notificationService.getAll()
},
to(n, index) {
const to = {
name: '',
params: {},
}
switch (n.name) {
case names.TASK_COMMENT:
case names.TASK_ASSIGNED:
to.name = 'task.detail'
to.params.id = n.notification.task.id
break
case names.TASK_DELETED:
// Nothing
break
case names.LIST_CREATED:
to.name = 'task.index'
to.params.listId = n.notification.list.id
break
case names.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
to.params.id = n.notification.team.id
break
}
return async () => {
if (to.name !== '') {
this.$router.push(to)
}
n.read = true
this.allNotifications[index] = await this.notificationService.update(n)
}
},
},
n.read = true
const notificationService = new NotificationService()
allNotifications.value[index] = await notificationService.update(n)
}
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<modal v-if="active" class="quick-actions" @close="closeQuickActions" :overflow="isNewTaskCommand">
<div class="card">
<modal v-if="active" @close="closeQuickActions" :overflow="isNewTaskCommand">
<div class="card quick-actions">
<div class="action-input" :class="{'has-active-cmd': selectedCmd !== null}">
<div class="active-cmd tag" v-if="selectedCmd !== null">
{{ selectedCmd.title }}
@ -52,7 +52,9 @@
</modal>
</template>
<script>
<script lang="ts">
import {defineComponent} from 'vue'
import TaskService from '@/services/task'
import TeamService from '@/services/team'
@ -82,7 +84,7 @@ const SEARCH_MODE_TASKS = 'tasks'
const SEARCH_MODE_LISTS = 'lists'
const SEARCH_MODE_TEAMS = 'teams'
export default {
export default defineComponent({
name: 'quick-actions',
components: {QuickAddMagic},
data() {
@ -480,7 +482,7 @@ export default {
this.selectedCmd = null
},
},
}
})
</script>
<style lang="scss" scoped>

View File

@ -26,13 +26,13 @@
<div class="control">
<div class="select">
<select v-model="selectedRight" id="linkShareRight">
<option :value="rights.READ">
<option :value="RIGHTS.READ">
{{ $t('list.share.right.read') }}
</option>
<option :value="rights.READ_WRITE">
<option :value="RIGHTS.READ_WRITE">
{{ $t('list.share.right.readWrite') }}
</option>
<option :value="rights.ADMIN">
<option :value="RIGHTS.ADMIN">
{{ $t('list.share.right.admin') }}
</option>
</select>
@ -121,13 +121,13 @@
{{ s.sharedBy.getDisplayName() }}
</td>
<td class="type">
<template v-if="s.right === rights.ADMIN">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('list.share.right.admin') }}
</template>
<template v-else-if="s.right === rights.READ_WRITE">
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>&nbsp;
@ -175,87 +175,87 @@
</div>
</template>
<script>
import rights from '../../models/constants/rights'
<script setup lang="ts">
import {ref, watch, computed, shallowReactive} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import LinkShareService from '../../services/linkShare'
import LinkShareModel from '../../models/linkShare'
import RIGHTS from '@/models/constants/rights.json'
import LinkShareModel from '@/models/linkShare'
import copy from 'copy-to-clipboard'
import {mapState} from 'vuex'
import LinkShareService from '@/services/linkShare'
export default {
name: 'linkSharing',
props: {
listId: {
default: 0,
required: true,
},
},
data() {
return {
linkShares: [],
linkShareService: new LinkShareService(),
rights: rights,
selectedRight: rights.READ,
name: '',
password: '',
showDeleteModal: false,
linkIdToDelete: 0,
showNewForm: false,
}
},
watch: {
listId: {
handler: 'load',
immediate: true,
},
},
computed: mapState({
frontendUrl: (state) => state.config.frontendUrl,
}),
methods: {
async load(listId) {
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (listId === 0) {
return
}
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
this.linkShares = await this.linkShareService.getAll({listId})
},
async add(listId) {
const newLinkShare = new LinkShareModel({
right: this.selectedRight,
listId,
name: this.name,
password: this.password,
})
await this.linkShareService.create(newLinkShare)
this.selectedRight = rights.READ
this.name = ''
this.password = ''
this.showNewForm = false
this.$message.success({message: this.$t('list.share.links.createSuccess')})
await this.load(listId)
},
async remove(listId) {
const linkshare = new LinkShareModel({
id: this.linkIdToDelete,
listId,
})
try {
await this.linkShareService.delete(linkshare)
this.$message.success({message: this.$t('list.share.links.deleteSuccess')})
await this.load(listId)
} finally {
this.showDeleteModal = false
}
},
copy,
getShareLink(hash) {
return this.frontendUrl + 'share/' + hash + '/auth'
},
const props = defineProps({
listId: {
default: 0,
required: true,
},
})
const {t} = useI18n({useScope: 'global'})
const linkShares = ref([])
const linkShareService = shallowReactive(new LinkShareService())
const selectedRight = ref(RIGHTS.READ)
const name = ref('')
const password = ref('')
const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
const copy = useCopyToClipboard()
watch(
() => props.listId,
load,
{immediate: true},
)
const store = useStore()
const frontendUrl = computed(() => store.state.config.frontendUrl)
async function load(listId) {
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (listId === 0) {
return
}
linkShares.value = await linkShareService.getAll({listId})
}
async function add(listId) {
const newLinkShare = new LinkShareModel({
right: selectedRight.value,
listId,
name: name.value,
password: password.value,
})
await linkShareService.create(newLinkShare)
selectedRight.value = RIGHTS.READ
name.value = ''
password.value = ''
showNewForm.value = false
success({message: t('list.share.links.createSuccess')})
await load(listId)
}
async function remove(listId) {
try {
await linkShareService.delete(new LinkShareModel({
id: linkIdToDelete.value,
listId,
}))
success({message: t('list.share.links.deleteSuccess')})
await load(listId)
} finally {
showDeleteModal.value = false
}
}
function getShareLink(hash: string) {
return frontendUrl.value + 'share/' + hash + '/auth'
}
</script>
@ -264,4 +264,6 @@ export default {
.sharables-list:not(.card-content) {
overflow-y: auto
}
@include modal-transition();
</style>

View File

@ -9,7 +9,7 @@
class="control is-expanded"
:class="{ 'is-loading': searchService.loading }"
>
<multiselect
<Multiselect
:loading="searchService.loading"
:placeholder="$t('misc.searchPlaceholder')"
@search="find"
@ -130,238 +130,237 @@
</div>
</template>
<script>
import UserNamespaceService from '../../services/userNamespace'
import UserNamespaceModel from '../../models/userNamespace'
import UserListModel from '../../models/userList'
import UserListService from '../../services/userList'
import UserService from '../../services/user'
import UserModel from '../../models/user'
<script lang="ts">
import {defineComponent, ShallowReactive, shallowReactive} from 'vue'
export default defineComponent({ name: 'userTeamShare' })
</script>
import TeamNamespaceService from '../../services/teamNamespace'
import TeamNamespaceModel from '../../models/teamNamespace'
import TeamListModel from '../../models/teamList'
import TeamListService from '../../services/teamList'
import TeamService from '../../services/team'
import TeamModel from '../../models/team'
<script setup lang="ts">
import {ref, reactive, computed} from 'vue'
import type {PropType} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import rights from '../../models/constants/rights.json'
import UserNamespaceService from '@/services/userNamespace'
import UserNamespaceModel from '@/models/userNamespace'
import UserListModel from '@/models/userList'
import UserListService from '@/services/userList'
import UserService from '@/services/user'
import UserModel from '@/models/user'
import TeamNamespaceService from '@/services/teamNamespace'
import TeamNamespaceModel from '@/models/teamNamespace'
import TeamListModel from '@/models/teamList'
import TeamListService from '@/services/teamList'
import TeamService from '@/services/team'
import TeamModel from '@/models/team'
import RIGHTS from '@/models/constants/rights.json'
import Multiselect from '@/components/input/multiselect.vue'
import Nothing from '@/components/misc/nothing.vue'
import { success } from '@/message'
export default {
name: 'userTeamShare',
props: {
type: {
type: String,
default: '',
},
shareType: {
type: String,
default: '',
},
id: {
type: Number,
default: 0,
},
userIsAdmin: {
type: Boolean,
default: false,
},
const props = defineProps({
type: {
type: String as PropType<'list' | 'namespace'>,
default: '',
},
data() {
return {
stuffService: Object, // This user service is either a userNamespaceService or a userListService, depending on the type we are using
stuffModel: Object,
searchService: Object,
sharable: Object,
found: [],
searchLabel: '',
rights: rights,
selectedRight: {},
typeString: '',
sharables: [], // This holds either teams or users who this namepace or list is shared with
showDeleteModal: false,
}
shareType: {
type: String as PropType<'user' | 'team' | 'namespace'>,
default: '',
},
components: {
Nothing,
Multiselect,
id: {
type: Number,
default: 0,
},
computed: {
userInfo() {
return this.$store.state.auth.info
},
shareTypeNames() {
if (this.shareType === 'user') {
return this.$tc('list.share.userTeam.typeUser', 2)
}
if (this.shareType === 'team') {
return this.$tc('list.share.userTeam.typeTeam', 2)
}
return ''
},
shareTypeName() {
if (this.shareType === 'user') {
return this.$tc('list.share.userTeam.typeUser', 1)
}
if (this.shareType === 'team') {
return this.$tc('list.share.userTeam.typeTeam', 1)
}
return ''
},
sharableName() {
if (this.type === 'list') {
return this.$t('list.list.title')
}
if (this.shareType === 'namespace') {
return this.$t('namespace.namespace')
}
return ''
},
userIsAdmin: {
type: Boolean,
default: false,
},
created() {
if (this.shareType === 'user') {
this.searchService = new UserService()
this.sharable = new UserModel()
this.searchLabel = 'username'
})
if (this.type === 'list') {
this.typeString = 'list'
this.stuffService = new UserListService()
this.stuffModel = new UserListModel({listId: this.id})
} else if (this.type === 'namespace') {
this.typeString = 'namespace'
this.stuffService = new UserNamespaceService()
this.stuffModel = new UserNamespaceModel({
namespaceId: this.id,
})
} else {
throw new Error('Unknown type: ' + this.type)
}
} else if (this.shareType === 'team') {
this.searchService = new TeamService()
this.sharable = new TeamModel()
this.searchLabel = 'name'
const {t} = useI18n({useScope: 'global'})
if (this.type === 'list') {
this.typeString = 'list'
this.stuffService = new TeamListService()
this.stuffModel = new TeamListModel({listId: this.id})
} else if (this.type === 'namespace') {
this.typeString = 'namespace'
this.stuffService = new TeamNamespaceService()
this.stuffModel = new TeamNamespaceModel({
namespaceId: this.id,
})
} else {
throw new Error('Unknown type: ' + this.type)
}
} else {
throw new Error('Unkown share type')
// This user service is either a userNamespaceService or a userListService, depending on the type we are using
let stuffService: ShallowReactive<UserNamespaceService | UserListService | TeamListService | TeamNamespaceService>
let stuffModel: UserNamespaceModel | UserListModel | TeamListModel | TeamNamespaceModel
let searchService: ShallowReactive<UserService | TeamService>
let sharable: UserModel | TeamModel
const searchLabel = ref('')
const selectedRight = ref({})
// This holds either teams or users who this namepace or list is shared with
const sharables = ref([])
const showDeleteModal = ref(false)
const store = useStore()
const userInfo = computed(() => store.state.auth.info)
function createShareTypeNameComputed(count: number) {
return computed(() => {
if (props.shareType === 'user') {
return t('list.share.userTeam.typeUser', count)
}
this.load()
},
methods: {
async load() {
this.sharables = await this.stuffService.getAll(this.stuffModel)
this.sharables.forEach((s) =>
this.selectedRight[s.id] = s.right,
)
},
if (props.shareType === 'team') {
return t('list.share.userTeam.typeTeam', count)
}
async deleteSharable() {
if (this.shareType === 'user') {
this.stuffModel.userId = this.sharable.username
} else if (this.shareType === 'team') {
this.stuffModel.teamId = this.sharable.id
}
return ''
})
}
await this.stuffService.delete(this.stuffModel)
this.showDeleteModal = false
for (const i in this.sharables) {
if (
(this.sharables[i].username === this.stuffModel.userId && this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamId && this.shareType === 'team')
) {
this.sharables.splice(i, 1)
}
}
this.$message.success({message: this.$t('list.share.userTeam.removeSuccess', {type: this.shareTypeName, sharable: this.sharableName})})
},
const shareTypeNames = createShareTypeNameComputed(2)
const shareTypeName = createShareTypeNameComputed(1)
async add(admin) {
if (admin === null) {
admin = false
}
this.stuffModel.right = rights.READ
if (admin) {
this.stuffModel.right = rights.ADMIN
}
const sharableName = computed(() => {
if (props.type === 'list') {
return t('list.list.title')
}
if (this.shareType === 'user') {
this.stuffModel.userId = this.sharable.username
} else if (this.shareType === 'team') {
this.stuffModel.teamId = this.sharable.id
}
if (props.shareType === 'namespace') {
return t('namespace.namespace')
}
await this.stuffService.create(this.stuffModel)
this.$message.success({message: this.$t('list.share.userTeam.addedSuccess', {type: this.shareTypeName})})
await this.load()
},
return ''
})
async toggleType(sharable) {
if (
this.selectedRight[sharable.id] !== rights.ADMIN &&
this.selectedRight[sharable.id] !== rights.READ &&
this.selectedRight[sharable.id] !== rights.READ_WRITE
) {
this.selectedRight[sharable.id] = rights.READ
}
this.stuffModel.right = this.selectedRight[sharable.id]
if (props.shareType === 'user') {
searchService = shallowReactive(new UserService())
sharable = reactive(new UserModel())
searchLabel.value = 'username'
if (this.shareType === 'user') {
this.stuffModel.userId = sharable.username
} else if (this.shareType === 'team') {
this.stuffModel.teamId = sharable.id
}
if (props.type === 'list') {
stuffService = shallowReactive(new UserListService())
stuffModel = reactive(new UserListModel({listId: props.id}))
} else if (props.type === 'namespace') {
stuffService = shallowReactive(new UserNamespaceService())
stuffModel = reactive(new UserNamespaceModel({
namespaceId: props.id,
}))
} else {
throw new Error('Unknown type: ' + props.type)
}
} else if (props.shareType === 'team') {
searchService = new TeamService()
sharable = reactive(new TeamModel())
searchLabel.value = 'name'
const r = await this.stuffService.update(this.stuffModel)
for (const i in this.sharables) {
if (
(this.sharables[i].username ===
this.stuffModel.userId &&
this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamId &&
this.shareType === 'team')
) {
this.sharables[i].right = r.right
}
}
this.$message.success({message: this.$t('list.share.userTeam.updatedSuccess', {type: this.shareTypeName})})
},
if (props.type === 'list') {
stuffService = shallowReactive(new TeamListService())
stuffModel = reactive(new TeamListModel({listId: props.id}))
} else if (props.type === 'namespace') {
stuffService = shallowReactive(new TeamNamespaceService())
stuffModel = reactive(new TeamNamespaceModel({
namespaceId: props.id,
}))
} else {
throw new Error('Unknown type: ' + props.type)
}
} else {
throw new Error('Unkown share type')
}
async find(query) {
if (query === '') {
this.clearAll()
return
}
load()
this.found = await this.searchService.getAll({}, {s: query})
},
async function load() {
sharables.value = await stuffService.getAll(stuffModel)
sharables.value.forEach(({id, right}) =>
selectedRight.value[id] = right,
)
}
clearAll() {
this.found = []
},
},
async function deleteSharable() {
if (props.shareType === 'user') {
stuffModel.userId = sharable.username
} else if (props.shareType === 'team') {
stuffModel.teamId = sharable.id
}
await stuffService.delete(stuffModel)
showDeleteModal.value = false
for (const i in sharables.value) {
if (
(sharables.value[i].username === stuffModel.userId && props.shareType === 'user') ||
(sharables.value[i].id === stuffModel.teamId && props.shareType === 'team')
) {
sharables.value.splice(i, 1)
}
}
success({message: t('list.share.userTeam.removeSuccess', {
type: shareTypeName.value,
sharable: sharableName.value,
})})
}
async function add(admin) {
if (admin === null) {
admin = false
}
stuffModel.right = RIGHTS.READ
if (admin) {
stuffModel.right = RIGHTS.ADMIN
}
if (props.shareType === 'user') {
stuffModel.userId = sharable.username
} else if (props.shareType === 'team') {
stuffModel.teamId = sharable.id
}
await stuffService.create(stuffModel)
success({message: t('list.share.userTeam.addedSuccess', {type: shareTypeName.value})})
await load()
}
async function toggleType(sharable) {
if (
selectedRight.value[sharable.id] !== RIGHTS.ADMIN &&
selectedRight.value[sharable.id] !== RIGHTS.READ &&
selectedRight.value[sharable.id] !== RIGHTS.READ_WRITE
) {
selectedRight.value[sharable.id] = RIGHTS.READ
}
stuffModel.right = selectedRight.value[sharable.id]
if (props.shareType === 'user') {
stuffModel.userId = sharable.username
} else if (props.shareType === 'team') {
stuffModel.teamId = sharable.id
}
const r = await stuffService.update(stuffModel)
for (const i in sharables.value) {
if (
(sharables.value[i].username ===
stuffModel.userId &&
props.shareType === 'user') ||
(sharables.value[i].id === stuffModel.teamId &&
props.shareType === 'team')
) {
sharables.value[i].right = r.right
}
}
success({message: t('list.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
}
const found = ref([])
async function find(query) {
if (query === '') {
clearAll()
return
}
found.value = await searchService.getAll({}, {s: query})
}
function clearAll() {
found.value = []
}
</script>
<style lang="scss" scoped>
@include modal-transition();
</style>

View File

@ -5,12 +5,13 @@
<textarea
:disabled="taskService.loading || undefined"
class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('list.list.addPlaceholder')"
rows="1"
v-focus
v-model="newTaskTitle"
ref="newTaskInput"
@keyup="errorMessage = ''"
@keyup="resetEmptyTitleError"
@keydown.enter="handleEnter"
/>
<span class="icon is-small is-left">
@ -24,15 +25,18 @@
@click="addTask()"
icon="plus"
:loading="taskService.loading"
:aria-label="$t('list.list.add')"
>
{{ $t('list.list.add') }}
<span class="button-text">
{{ $t('list.list.add') }}
</span>
</x-button>
</p>
</div>
<p class="help is-danger" v-if="errorMessage !== ''">
{{ errorMessage }}
</p>
<quick-add-magic v-else />
<quick-add-magic v-else/>
</div>
</template>
@ -40,9 +44,9 @@
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 {tryOnMounted, debouncedWatch, useWindowSize, MaybeRef} from '@vueuse/core'
import TaskService from '../../services/task'
import TaskService from '@/services/task'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
function cleanupTitle(title: string) {
@ -54,12 +58,12 @@ function useAutoHeightTextarea(value: MaybeRef<string>) {
const minHeight = ref(0)
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
function resize(textareaEl: HTMLInputElement|undefined) {
function resize(textareaEl: HTMLInputElement | undefined) {
if (!textareaEl) return
let empty
// the value here is the the attribute value
// the value here is the attribute value
if (!textareaEl.value && textareaEl.placeholder) {
empty = true
textareaEl.value = textareaEl.placeholder
@ -95,12 +99,12 @@ function useAutoHeightTextarea(value: MaybeRef<string>) {
}
})
const { width: windowWidth } = useWindowSize()
const {width: windowWidth} = useWindowSize()
debouncedWatch(
windowWidth,
() => resize(textarea.value),
{ debounce: 200 },
{debounce: 200},
)
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
@ -117,9 +121,6 @@ function useAutoHeightTextarea(value: MaybeRef<string>) {
return textarea
}
const emit = defineEmits(['taskAdded'])
const props = defineProps({
defaultPosition: {
type: Number,
@ -127,15 +128,26 @@ const props = defineProps({
},
})
const taskService = shallowReactive(new TaskService())
const errorMessage = ref('')
const emit = defineEmits(['taskAdded'])
const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
const { t } = useI18n()
const {t} = useI18n({useScope: 'global'})
const store = useStore()
const taskService = shallowReactive(new TaskService())
const errorMessage = ref('')
function resetEmptyTitleError(e) {
if (
(e.which <= 90 && e.which >= 48 || e.which >= 96 && e.which <= 105)
&& newTaskTitle.value !== ''
) {
errorMessage.value = ''
}
}
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('list.create.addTitleRequired')
@ -195,9 +207,26 @@ function handleEnter(e: KeyboardEvent) {
.add-task-button {
height: 2.5rem;
@media screen and (max-width: $mobile) {
.button-text {
display: none;
}
:deep(.icon) {
margin: 0 !important;
}
}
}
.add-task-textarea {
transition: border-color $transition;
resize: none;
}
// Adding this class when the textarea has no text prevents the textarea from wrapping the placeholder.
.textarea-empty {
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@ -11,7 +11,7 @@
<div class="control">
<input
:class="{ disabled: taskService.loading }"
:disabled="taskService.loading || null"
:disabled="taskService.loading || undefined"
@change="editTaskSubmit()"
class="input"
id="tasktext"
@ -67,7 +67,7 @@
<router-link
class="mt-2 has-text-centered is-block"
:to="{name: 'task.detail', params: {id: taskEditTask.id}}"
:to="taskDetailRoute"
>
{{ $t('task.openDetail') }}
</router-link>
@ -75,78 +75,81 @@
</card>
</template>
<script>
import AsyncEditor from '@/components/input/AsyncEditor'
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, watch, nextTick} from 'vue'
import {useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import priorities from '../../models/constants/priorities'
import EditLabels from './partials/editLabels'
import Reminders from './partials/reminders'
import ColorPicker from '../input/colorPicker'
import Editor from '@/components/input/AsyncEditor'
export default {
name: 'edit-task',
data() {
return {
listId: this.$route.params.id,
listService: new ListService(),
taskService: new TaskService(),
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
import EditLabels from './partials/editLabels.vue'
import Reminders from './partials/reminders.vue'
import ColorPicker from '../input/colorPicker.vue'
priorities: priorities,
list: {},
editorActive: false,
newTask: new TaskModel(),
isTaskEdit: false,
taskEditTask: TaskModel,
import {success} from '@/message'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const props = defineProps({
task: {
type: TaskModel,
required: true,
},
})
const taskService = shallowReactive(new TaskService())
const editorActive = ref(false)
let taskEditTask: TaskModel | undefined
// FIXME: this initialization should not be necessary here
function initTaskFields() {
taskEditTask.dueDate =
+new Date(props.task.dueDate) === 0 ? null : props.task.dueDate
taskEditTask.startDate =
+new Date(props.task.startDate) === 0
? null
: props.task.startDate
taskEditTask.endDate =
+new Date(props.task.endDate) === 0 ? null : props.task.endDate
// This makes the editor trigger its mounted function again which makes it forget every input
// it currently has in its textarea. This is a counter-hack to a hack inside of vue-easymde
// which made it impossible to detect change from the outside. Therefore the component would
// not update if new content from the outside was made available.
// See https://github.com/NikulinIlya/vue-easymde/issues/3
editorActive.value = false
nextTick(() => (editorActive.value = true))
}
watch(
() => props.task,
() => {
if (!taskEditTask) {
taskEditTask = reactive(props.task)
} else {
Object.assign(taskEditTask, new TaskModel(props.task))
}
initTaskFields()
},
components: {
ColorPicker,
Reminders,
EditLabels,
editor: AsyncEditor,
},
props: {
task: {
type: TaskModel,
required: true,
},
},
watch: {
task: {
handler() {
this.taskEditTask = this.task
this.initTaskFields()
},
immediate: true,
},
},
methods: {
initTaskFields() {
this.taskEditTask.dueDate =
+new Date(this.task.dueDate) === 0 ? null : this.task.dueDate
this.taskEditTask.startDate =
+new Date(this.task.startDate) === 0
? null
: this.task.startDate
this.taskEditTask.endDate =
+new Date(this.task.endDate) === 0 ? null : this.task.endDate
// This makes the editor trigger its mounted function again which makes it forget every input
// it currently has in its textarea. This is a counter-hack to a hack inside of vue-easymde
// which made it impossible to detect change from the outside. Therefore the component would
// not update if new content from the outside was made available.
// See https://github.com/NikulinIlya/vue-easymde/issues/3
this.editorActive = false
this.$nextTick(() => (this.editorActive = true))
},
async editTaskSubmit() {
this.taskEditTask = await this.taskService.update(this.taskEditTask)
this.initTaskFields()
this.$message.success({message: this.$t('task.detail.updateSuccess')})
},
},
{immediate: true },
)
const taskDetailRoute = computed(() => {
return {
name: 'task.detail',
params: { id: taskEditTask.id },
state: { backdropView: router.currentRoute.value.fullPath },
}
})
async function editTaskSubmit() {
const newTask = await taskService.update(taskEditTask)
Object.assign(taskEditTask, newTask)
initTaskFields()
success({message: t('task.detail.updateSuccess')})
}
</script>

View File

@ -170,7 +170,9 @@
</div>
</template>
<script>
<script lang="ts">
import {defineComponent} from 'vue'
import VueDragResize from 'vue-drag-resize'
import EditTask from './edit-task'
@ -183,7 +185,9 @@ import {mapState} from 'vuex'
import Rights from '../../models/constants/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
export default {
import {colorIsDark} from '@/helpers/color/colorIsDark'
export default defineComponent({
name: 'GanttChart',
components: {
FilterPopup,
@ -252,6 +256,7 @@ export default {
canWrite: (state) => state.currentList.maxRight > Rights.READ,
}),
methods: {
colorIsDark,
buildTheGanttChart() {
this.setDates()
this.prepareGanttDays()
@ -432,7 +437,7 @@ export default {
return this.format(date, 'MMMM, yyyy')
},
},
}
})
</script>
<style lang="scss" scoped>
@ -515,14 +520,14 @@ $gantt-vertical-border-color: var(--grey-100);
}
&.has-light-text {
color: var(--light);
color: var(--grey-100);
&.done span:after {
border-top: 1px solid var(--light);
border-top: 1px solid var(--grey-100);
}
.edit-toggle {
color: var(--light);
color: var(--grey-100);
}
}

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