Compare commits

...

129 Commits

Author SHA1 Message Date
kolaente 652d3c7384
chore: add archival note
continuous-integration/drone/push Build is passing Details
2024-02-07 15:03:24 +01:00
kolaente 447641c222 chore: apply lint fixes
continuous-integration/drone/push Build is passing Details
2024-02-07 12:23:09 +00:00
Dominik Pschenitschni 362be53a47 feat: use recommended vue-linting 2024-02-07 12:23:09 +00:00
renovate 46eabdfe6b fix(deps): update sentry-javascript monorepo to v7.100.1
continuous-integration/drone/push Build is passing Details
2024-02-07 11:53:00 +00:00
kolaente a0c5a464a5
feat(progress): less rounding
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-02-07 11:36:57 +01:00
kolaente e78ab476fc
chore(progress): cleanup unused css 2024-02-07 11:35:32 +01:00
kolaente aebb047d18
fix(progress): move customizations into progress bar component
continuous-integration/drone/pr Build is passing Details
2024-02-07 11:24:20 +01:00
Dominik Pschenitschni 7bb110b20e
feat: add ProgressBar component
continuous-integration/drone/pr Build is passing Details
2024-02-07 11:12:21 +01:00
renovate f148a43390 fix(deps): update dependency ufo to v1.4.0
continuous-integration/drone/push Build is passing Details
2024-02-07 10:08:12 +00:00
renovate aac70d3823 fix(deps): update dependency @kyvg/vue3-notification to v3.1.4
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-02-07 09:20:36 +00:00
kolaente 21126793ab
fix(test): make test assertion work again
continuous-integration/drone/push Build is passing Details
2024-02-06 23:13:38 +01:00
kolaente b057fb2784
fix(reminders): set reminder date on datepicker when editing a reminder
continuous-integration/drone/push Build is failing Details
Setting an actual reminder date (not a relative one) flowed only from the component to the outside when setting it. When editing it, the reminder date would not be populated, causing the datepicker date to stay at the current date.
2024-02-06 18:46:15 +01:00
kolaente 58c7da019d
fix(notifications): mark all notifications as read in ui directly when marking as read on the server
continuous-integration/drone/push Build is failing Details
This caused the notifications to stay on "unread" when marking them as read, making an unpleasant user experience
2024-02-06 18:34:42 +01:00
kolaente 70f48eaaca
fix(task): make sure the drag handle is shown as intended
continuous-integration/drone/push Build is failing Details
Due to a previous refactoring, the drag handle was always shown instead of only on hover. The css class was moved out of the task component, but its styles weren't

Related to #3934
2024-02-06 18:29:17 +01:00
kolaente 6cc75928d8
fix(task): remove default task color
continuous-integration/drone/push Build is failing Details
Previously, the task would use the default color. This was now removed, as this resulted in the default color not being visible on tasks.

Resolves https://github.com/go-vikunja/frontend/issues/135#issuecomment-1917576392
2024-02-06 18:18:44 +01:00
kolaente dc360d4a18
chore(editor): don't set editor content intitially
continuous-integration/drone/push Build is failing Details
2024-02-06 18:03:27 +01:00
kolaente 45ca0602f5
feat(editor): use primary color for currently selected node 2024-02-06 16:09:38 +01:00
kolaente 9d39ccf15c
fix(assignees): use correct amount of spacing in assignee selection
continuous-integration/drone/push Build is failing Details
2024-02-06 15:44:39 +01:00
kolaente 28e83325d7
fix(kanban): assignee spacing 2024-02-06 15:39:05 +01:00
kolaente aff48ddd9d
fix(kanban): bottom spacing of labels 2024-02-06 15:34:22 +01:00
kolaente 5b2a9a42c0
fix(gantt): correctly import languages from dayjs
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/error-in-gannt-with-spanish-language/1973/3
2024-02-05 21:57:21 +01:00
kolaente 45f5d522d1
docs: update readme
continuous-integration/drone/push Build is failing Details
Copied from https://github.com/go-vikunja/frontend/pull/146
2024-02-05 21:16:30 +01:00
renovate 4f27e4a477 fix(deps): update tiptap to v2.2.1 2024-02-05 10:37:28 +00:00
renovate d0dc86fd58 fix(deps): update dependency vue-i18n to v9.9.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-31 02:20:29 +00:00
renovate 0484923b8a fix(deps): update sentry-javascript monorepo to v7.99.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-30 17:18:57 +00:00
renovate 5f2fb01e90 fix(deps): update dependency floating-vue to v5.2.2
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-30 14:19:37 +00:00
renovate bd18524f36 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-30 00:19:43 +00:00
renovate 7375a87f2f fix(deps): update dependency @fortawesome/vue-fontawesome to v3.0.6
continuous-integration/drone/push Build is passing Details
2024-01-29 21:18:09 +00:00
renovate ccff276397 chore(deps): update pnpm to v8.15.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-29 20:19:25 +00:00
renovate 30b21fc11c fix(deps): update dependency floating-vue to v5.2.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-29 14:22:11 +00:00
renovate 7c98ddc20b fix(deps): update tiptap to v2.2.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-29 13:21:38 +00:00
renovate 6ba02a0f10 chore(deps): update pnpm to v8.15.0
continuous-integration/drone/push Build is passing Details
2024-01-29 07:32:55 +00:00
renovate 676d2b6215 chore(deps): update dependency @types/node to v20.11.10
continuous-integration/drone/push Build is passing Details
2024-01-29 07:32:26 +00:00
Frederick [Bot] 85e612451f chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-01-29 00:25:58 +00:00
kolaente d411de99f1
chore: release preparation
continuous-integration/drone/push Build is passing Details
2024-01-28 17:43:53 +01:00
kolaente 228d652b03
fix(kanban): make sure spacing between assignees and other task details works out evenly
continuous-integration/drone/push Build is passing Details
2024-01-28 16:41:24 +01:00
kolaente b3e2107503
fix(task): don't show assignee edit buttons and input when the user does not have the permission to edit
continuous-integration/drone/push Build is passing Details
2024-01-28 13:30:29 +01:00
kolaente a579a8e65f
fix(task): don't show edit button when the user does not have permission to edit the task 2024-01-28 13:24:58 +01:00
kolaente ee980e2a00
fix(openid): use the calculated redirect url when authenticating with openid providers
continuous-integration/drone/push Build is passing Details
Resolves https://github.com/go-vikunja/desktop/issues/12
2024-01-28 12:42:45 +01:00
renovate 394dbe0055 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-28 04:19:06 +00:00
renovate 30d599369f chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-27 00:18:54 +00:00
kolaente 631b02d2ee
chore: only show webhooks overview table when there are webhooks
continuous-integration/drone/push Build is passing Details
2024-01-27 00:01:11 +01:00
kolaente 326bfb557a
chore: only show webhooks overview table when there are webhooks
continuous-integration/drone/push Build is passing Details
2024-01-27 00:00:31 +01:00
kolaente cd0149ef69
fix(kanban): make sure the checklist summary uses the correct text color
continuous-integration/drone/push Build is passing Details
Related-To https://github.com/go-vikunja/frontend/issues/135
2024-01-26 21:44:20 +01:00
kolaente 78d4a518a3
fix(tasks): don't load tasks multiple times when viewing list or gantt view
continuous-integration/drone/push Build is passing Details
2024-01-26 21:33:20 +01:00
kolaente 3c1041902e
fix(table view): make sure popup does not overlap
continuous-integration/drone/push Build is passing Details
2024-01-26 21:22:51 +01:00
kolaente e3cae0ed7f
fix(filter): validate filter title field after loading a filter for edit
continuous-integration/drone/push Build is passing Details
Related to #3866
2024-01-26 11:29:46 +01:00
renovate fc8bd6a9ca chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-26 05:19:03 +00:00
renovate 5a6e5619e3 fix(deps): update dependency axios to v1.6.7
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-25 20:20:22 +00:00
renovate 9c9f806e62 fix(deps): update sentry-javascript monorepo to v7.98.0
continuous-integration/drone/push Build is passing Details
2024-01-25 13:55:42 +00:00
kolaente 67216579bc
fix(auth): correctly construct redirect url from current window href
continuous-integration/drone/push Build is passing Details
2024-01-25 14:24:30 +01:00
renovate a8df935ddb fix(deps): update sentry-javascript monorepo to v7.97.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-25 11:20:20 +00:00
renovate bb4746f226 chore(deps): update dev-dependencies
continuous-integration/drone/push Build is passing Details
2024-01-25 07:50:16 +00:00
renovate 31590236aa fix(deps): update dependency axios to v1.6.6
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-24 23:19:33 +00:00
renovate 00d48a6178 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-24 06:19:41 +00:00
renovate 5169cca8d8 fix(deps): update sentry-javascript monorepo to v7.95.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-23 19:18:41 +00:00
renovate 255a7d565c chore(deps): update pnpm to v8.14.3
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-23 10:20:44 +00:00
renovate 8dbaee5dfb chore(deps): update dev-dependencies to v6.19.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-23 00:19:30 +00:00
renovate 69b0b19482 fix(deps): update dependency date-fns to v3.3.1
continuous-integration/drone/push Build is passing Details
2024-01-22 10:44:49 +00:00
renovate eae89d37f1 chore(deps): update pnpm to v8.14.2
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-22 10:19:22 +00:00
renovate 7d19859816 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-22 00:18:43 +00:00
kolaente c7b70844c6
fix(color picker): when picking a color, the color picker should not be black afterwards
continuous-integration/drone/push Build is passing Details
2024-01-21 20:25:19 +01:00
kolaente b8c21c2ade
fix(labels): text and background combination in dark mode
continuous-integration/drone/push Build is passing Details
2024-01-21 20:20:00 +01:00
kolaente 57c99a22a0
fix(editor): use manual input prompt instead of window.prompt
continuous-integration/drone/push Build is passing Details
Resolves vikunja/desktop#184
2024-01-21 20:08:10 +01:00
kolaente 8ea97f3ffc
fix(editor): use a stable image id to prevent constant re-rendering
continuous-integration/drone/push Build is passing Details
2024-01-21 15:45:18 +01:00
kolaente 0b3604d167
fix(editor): render images without crashing
continuous-integration/drone/push Build is passing Details
2024-01-21 15:00:26 +01:00
kolaente c5ba7fcb73
fix(editor): focus the editor when clicking on the whole edit container
continuous-integration/drone/push Build is passing Details
2024-01-21 13:52:13 +01:00
kolaente 5a25685d53
fix(editor): don't bubble up changes when no changes were made
continuous-integration/drone/push Build is passing Details
Related https://community.vikunja.io/t/saving-an-empty-description-in-kanban-view-break-ui/1914/3
2024-01-21 13:44:40 +01:00
kolaente da311fce9e
fix(kanban): ensure text and icon color only depends on the card background, not on the color scheme
continuous-integration/drone/push Build is passing Details
Related https://github.com/go-vikunja/frontend/issues/135#issuecomment-1900701258
2024-01-21 00:10:05 +01:00
kolaente 0fdf1ca027
fix(notifications): read indicator size
continuous-integration/drone/push Build is passing Details
2024-01-21 00:01:04 +01:00
kolaente f8e907a8c1
fix(notifications): always left-align notification text
continuous-integration/drone/push Build is passing Details
2024-01-20 23:59:57 +01:00
kolaente af7ca8ad8f
fix(project): always use the appropriate color for task estimate during deletion dialoge
continuous-integration/drone/push Build is passing Details
2024-01-20 23:54:03 +01:00
nor 92f7d9ded5 feat: datepicker locale support (#3878)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3878
Reviewed-by: konrad <k@knt.li>
Co-authored-by: nor <zorodey@outlook.com>
Co-committed-by: nor <zorodey@outlook.com>
2024-01-20 18:50:00 +00:00
renovate 41ccaea78b fix(deps): update dependency date-fns to v3.3.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-20 06:19:03 +00:00
renovate c5696f3e2a chore(deps): update dependency vite to v5.0.12
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-20 00:19:28 +00:00
renovate 898707664c fix(deps): update sentry-javascript monorepo to v7.94.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-19 13:19:38 +00:00
renovate d0b5bef68a chore(deps): update dependency happy-dom to v13.2.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build was killed Details
2024-01-19 00:20:24 +00:00
renovate e395d4efdb fix(deps): update dependency vue to v3.4.15
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-18 14:19:39 +00:00
renovate ce54132868 chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-18 07:18:54 +00:00
renovate 07d4d1e537 fix(deps): update dependency floating-vue to v5.2.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-17 13:19:31 +00:00
renovate a701b0452e fix(deps): update dependency floating-vue to v5.1.1
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-17 12:18:43 +00:00
renovate af65efcd27 chore(deps): update dev-dependencies (major) (#3890)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3890
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-17 09:17:35 +00:00
renovate dc2afb9e8d chore(deps): update dev-dependencies
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-17 07:18:31 +00:00
WofWca e123d4f825 chore(perf): import some modules dynamically (#3179)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3179
Reviewed-by: konrad <k@knt.li>
Co-authored-by: WofWca <wofwca@protonmail.com>
Co-committed-by: WofWca <wofwca@protonmail.com>
2024-01-16 14:24:24 +00:00
renovate b72c963256 fix(deps): update dependency vue to v3.4.14
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2024-01-16 12:10:13 +00:00
renovate 149bbf17eb fix(deps): update dependency floating-vue to v5.1.0
continuous-integration/drone/push Build is passing Details
2024-01-16 12:01:54 +00:00
renovate 265d60cf42 fix(deps): update vueuse to v10.7.2
continuous-integration/drone/push Build is failing Details
2024-01-16 12:01:44 +00:00
renovate 23c9f51e73 fix(deps): update dependency sortablejs to v1.15.2
continuous-integration/drone/push Build is passing Details
2024-01-16 12:01:11 +00:00
renovate ff697d0c7a chore(deps): update dev-dependencies
continuous-integration/drone/push Build is passing Details
2024-01-16 11:50:52 +00:00
renovate 00588cf59f chore(deps): pin node.js (#3895)
continuous-integration/drone/push Build is failing Details
Reviewed-on: #3895
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-16 11:49:00 +00:00
renovate 01089f4f3d fix(deps): update tiptap to v2.1.16 (#3892)
continuous-integration/drone/push Build was killed Details
Reviewed-on: #3892
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-16 11:17:28 +00:00
kolaente a7461d1ddd
chore(deps): increase renovate timeout
continuous-integration/drone/push Build is passing Details
2024-01-16 12:15:04 +01:00
kolaente a451189bb6
fix(test): make date assertion not brittle
continuous-integration/drone/push Build is passing Details
2024-01-16 10:36:29 +01:00
kolaente bf9af27fc3
fix(task): update due date when marking a task done
continuous-integration/drone/push Build is failing Details
2024-01-15 23:33:02 +01:00
kolaente 5619fda0f2
fix(task): bubble date changes from the picker up
continuous-integration/drone/push Build is failing Details
Resolves https://github.com/go-vikunja/frontend/issues/142
2024-01-15 23:23:57 +01:00
kolaente 167953b26b
fix(editor): use higher-contrast colors for links and code
continuous-integration/drone/push Build is passing Details
2024-01-15 22:11:24 +01:00
kolaente 664bf0a5f4
fix(tasks): make sure tasks show up if their parent task is not available in the current view
continuous-integration/drone/push Build is passing Details
Related https://github.com/go-vikunja/frontend/issues/136
Related https://community.vikunja.io/t/subtasks-are-hidden-when-parent-tasks-are-moved/1911
2024-01-15 21:46:47 +01:00
kolaente 5e991f3024
fix: lint
continuous-integration/drone/push Build is passing Details
2024-01-15 16:21:00 +01:00
kolaente 28050d9cd5
fix(labels): make color reset work
continuous-integration/drone/push Build is failing Details
2024-01-15 14:00:08 +01:00
kolaente e94b71d577
fix(editor): list icons
continuous-integration/drone/push Build is passing Details
2024-01-15 13:39:17 +01:00
renovate 336ce217d3 chore(deps): update node.js to v20.11 (#3888)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3888
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-11 11:30:14 +00:00
Frederick [Bot] ce01085951 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-01-11 00:11:57 +00:00
kolaente 96a6d43a3f
fix(quick add magic): ensure month is removed from task text
continuous-integration/drone/push Build is passing Details
Resolves #3874
2024-01-10 23:54:42 +01:00
kolaente 13d63e34aa
fix(task): don't immediately re-trigger date change when nothing changed
continuous-integration/drone/push Build is passing Details
Resolves https://community.vikunja.io/t/reminder-duplication/76/21?u=kolaente
2024-01-10 23:27:14 +01:00
renovate a8441c72b8 fix(deps): update dependency vue to v3.4.8 (#3886)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3886
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 20:16:50 +00:00
renovate 230fa6ce66 fix(deps): update dependency floating-vue to v5 (#3887)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3887
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 18:18:53 +00:00
renovate 069c491fbd fix(deps): update sentry-javascript monorepo to v7.93.0 (#3859)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3859
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 16:01:02 +00:00
renovate a9eae95d67 chore(deps): update pnpm to v8.14.1 (#3885)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3885
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 15:57:10 +00:00
renovate 50502d9d11 fix(deps): update vueuse to v10.7.1 (#3872)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3872
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 15:04:27 +00:00
renovate 18af6edc82 fix(deps): update tiptap to v2.1.15 (#3884)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3884
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 14:49:03 +00:00
renovate d048b61eb3 fix(deps): update dependency floating-vue to v2.0.0 (#3883)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3883
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 14:36:14 +00:00
renovate 996607e670 fix(deps): update dependency dompurify to v3.0.8 (#3881)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3881
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 14:35:50 +00:00
renovate e33ebe1831 fix(deps): update dependency vue-i18n to v9.9.0 (#3880)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3880
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 14:27:02 +00:00
renovate 557b0ffec7 chore(deps): update dependency node to v20.11.0
continuous-integration/drone/push Build is passing Details
2024-01-10 12:04:17 +00:00
renovate dae6cdb9d7 fix(deps): update dependency @kyvg/vue3-notification to v3.1.3 (#3864)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3864
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 11:59:31 +00:00
renovate 158e4d690f chore(deps): update dev-dependencies (#3861)
continuous-integration/drone/push Build is failing Details
Reviewed-on: #3861
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 11:51:20 +00:00
renovate 691eb84a99 fix(deps): update dependency date-fns to v3 (#3857)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3857
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 11:29:48 +00:00
renovate 698ee7e163 fix(deps): update dependency axios to v1.6.5 (#3871)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3871
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 11:28:28 +00:00
renovate ce822573df fix(deps): update dependency vue to v3.4.7 (#3873)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3873
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2024-01-10 11:19:30 +00:00
renovate 198abee01d chore(deps): update pnpm to v8.14.0
continuous-integration/drone/push Build is passing Details
2024-01-10 10:59:39 +00:00
Frederick [Bot] e5bea087be chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-01-09 00:10:45 +00:00
Frederick [Bot] 4956fbb669 chore(i18n): update translations via Crowdin
continuous-integration/drone/push Build is passing Details
2024-01-08 11:50:29 +00:00
kolaente 0351148288
fix(ci): use working crowdin image
continuous-integration/drone/push Build is passing Details
2024-01-07 20:20:14 +01:00
kolaente 654806211e
fix(ci): use working image for crowdin update step
continuous-integration/drone/push Build is passing Details
2024-01-04 13:22:27 +01:00
kolaente 09572dbe61
fix(ci): use working crowdin image
continuous-integration/drone/push Build is passing Details
2023-12-27 15:44:36 +01:00
kolaente fae5b764dd
fix(notifications): unread indicator spacing
continuous-integration/drone/push Build is passing Details
2023-12-23 15:53:17 +01:00
kolaente 7f70471894
feat(reminders): show reminders in notifications bar
continuous-integration/drone/push Build is passing Details
2023-12-23 15:48:29 +01:00
kolaente e98e5a0d2f
fix(openid): use the full path when building the redirect url, not only the host
continuous-integration/drone/push Build is passing Details
Resolves vikunja/api#1661
2023-12-20 13:23:56 +01:00
renovate 21e34d6d54 fix(deps): update dependency @intlify/unplugin-vue-i18n to v2 (#3862)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3862
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-12-20 11:22:56 +00:00
208 changed files with 7632 additions and 5167 deletions

View File

@ -42,7 +42,7 @@ steps:
# - .cache
- name: dependencies
image: node:20.10-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -55,7 +55,7 @@ steps:
# - restore-cache
- name: lint
image: node:20.10-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -66,7 +66,7 @@ steps:
- dependencies
- name: build-prod
image: node:20.10-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -77,7 +77,7 @@ steps:
- dependencies
- name: test-unit
image: node:20.10-alpine
image: node:20.11.0-alpine
pull: always
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
@ -87,7 +87,7 @@ steps:
- name: typecheck
failure: ignore
image: node:20.10-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -202,7 +202,7 @@ steps:
# - .cache
- name: build
image: node:20.10-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -285,7 +285,7 @@ steps:
# - .cache
- name: build
image: node:20.10-alpine
image: node:20.11.0-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -486,7 +486,7 @@ trigger:
steps:
- name: download
pull: always
image: git.lcomrade.su/root/drone-crowdin-v2
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest
settings:
crowdin_key:
from_secret: crowdin_key
@ -513,14 +513,14 @@ steps:
author_name: Frederick [Bot]
branch: main
commit: true
commit_message: "[skip ci] Updated translations via Crowdin"
commit_message: "chore(i18n): update translations via Crowdin"
remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
ssh_key:
from_secret: git_push_ssh_key
- name: upload
pull: always
image: git.lcomrade.su/root/drone-crowdin-v2
image: ghcr.io/kolaente/kolaente/drone-crowdin-v2:latest
depends_on:
- clone
settings:
@ -532,6 +532,6 @@ steps:
src/i18n/lang/en.json: en.json
---
kind: signature
hmac: 3380c4283256eea047e6228817161991d23457d09abe9d99f06e018b1eb047f4
hmac: a044c7c4db3c2a11299d4d118397e9d25be36db241723a1bbd0a2f9cc90ffdac
...

View File

@ -1,5 +1,5 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
'root': true,
@ -7,52 +7,54 @@ module.exports = {
'browser': true,
'es2022': true,
'node': true,
'vue/setup-compiler-macros': true,
},
'extends': [
'eslint:recommended',
'plugin:vue/vue3-essential',
'plugin:vue/vue3-recommended',
'@vue/eslint-config-typescript/recommended',
],
'rules': {
'vue/html-quotes': [
'error',
'double',
],
'quotes': [
'error',
'single',
],
'comma-dangle': [
'error',
'always-multiline',
],
'semi': [
'error',
'never',
],
'quotes': ['error', 'single'],
'comma-dangle': ['error', 'always-multiline'],
'semi': ['error', 'never'],
// see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }],
'vue/v-on-event-hyphenation': ['warn', 'never', { 'autofix': true }],
'vue/multi-word-component-names': 'off',
'vue/multi-word-component-names': 0,
// disabled until we have support for reactivityTransform
// See https://github.com/vuejs/eslint-plugin-vue/issues/1948
// see also setting in `vite.config`
'vue/no-setup-props-destructure': 0,
// uncategorized rules:
'vue/component-api-style': ['error', ['script-setup']],
'vue/component-name-in-template-casing': ['warn', 'PascalCase'],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-macros-order': 'error',
'vue/match-component-file-name': ['error', {
'extensions': ['.js', '.jsx', '.ts', '.tsx', '.vue'],
'shouldMatchCase': true,
}],
'vue/no-boolean-default': ['warn', 'default-false'],
'vue/match-component-import-name': 'error',
'vue/prefer-separate-static-class': 'warn',
'vue/padding-line-between-blocks': 'error',
'vue/next-tick-style': ['error', 'promise'],
'vue/block-lang': [
'error',
{ 'script': { 'lang': 'ts' } },
],
'vue/no-required-prop-with-default': ['error', { 'autofix': true }],
'vue/no-duplicate-attr-inheritance': 'error',
'vue/no-empty-component-block': 'error',
'vue/html-indent': ['error', 'tab'],
// vue3
'vue/no-ref-object-destructure': 'error',
},
'parser': 'vue-eslint-parser',
'parserOptions': {
'parser': '@typescript-eslint/parser',
'ecmaVersion': 2022,
'sourceType': 'module',
'ecmaVersion': 'latest',
},
'ignorePatterns': [
'*.test.*',
'cypress/*',
],
'globals': {
'defineProps': 'readonly',
},
}

2
.nvmrc
View File

@ -1 +1 @@
20.10.0
20.11.0

View File

@ -9,6 +9,116 @@ All releases can be found on https://code.vikunja.io/frontend/releases.
The releases aim at the api versions which is why there are missing versions.
## [0.22.1] - 2024-01-28
### Bug Fixes
* *(auth)* Correctly construct redirect url from current window href
* *(ci)* Use working crowdin image
* *(ci)* Use working image for crowdin update step
* *(ci)* Use working crowdin image
* *(color picker)* When picking a color, the color picker should not be black afterwards
* *(editor)* List icons
* *(editor)* Use higher-contrast colors for links and code
* *(editor)* Don't bubble up changes when no changes were made
* *(editor)* Focus the editor when clicking on the whole edit container
* *(editor)* Render images without crashing
* *(editor)* Use a stable image id to prevent constant re-rendering
* *(editor)* Use manual input prompt instead of window.prompt
* *(filter)* Validate filter title field after loading a filter for edit
* *(kanban)* Ensure text and icon color only depends on the card background, not on the color scheme
* *(kanban)* Make sure the checklist summary uses the correct text color
* *(kanban)* Make sure spacing between assignees and other task details works out evenly
* *(labels)* Make color reset work
* *(labels)* Text and background combination in dark mode
* *(notifications)* Unread indicator spacing
* *(notifications)* Always left-align notification text
* *(notifications)* Read indicator size
* *(openid)* Use the full path when building the redirect url, not only the host
* *(openid)* Use the calculated redirect url when authenticating with openid providers
* *(project)* Always use the appropriate color for task estimate during deletion dialoge
* *(quick add magic)* Ensure month is removed from task text
* *(table view)* Make sure popup does not overlap
* *(task)* Don't immediately re-trigger date change when nothing changed
* *(task)* Bubble date changes from the picker up
* *(task)* Update due date when marking a task done
* *(task)* Don't show edit button when the user does not have permission to edit the task
* *(task)* Don't show assignee edit buttons and input when the user does not have the permission to edit
* *(tasks)* Make sure tasks show up if their parent task is not available in the current view
* *(tasks)* Don't load tasks multiple times when viewing list or gantt view
* *(test)* Make date assertion not brittle
* Lint ([5e991f3](5e991f3024f7856420614171ec66468eb2e2df63))
### Dependencies
* *(deps)* Update dependency @intlify/unplugin-vue-i18n to v2 (#3862)
* *(deps)* Update pnpm to v8.14.0
* *(deps)* Update dependency vue to v3.4.7 (#3873)
* *(deps)* Update dependency axios to v1.6.5 (#3871)
* *(deps)* Update dependency date-fns to v3 (#3857)
* *(deps)* Update dev-dependencies (#3861)
* *(deps)* Update dependency @kyvg/vue3-notification to v3.1.3 (#3864)
* *(deps)* Update dependency node to v20.11.0
* *(deps)* Update dependency vue-i18n to v9.9.0 (#3880)
* *(deps)* Update dependency dompurify to v3.0.8 (#3881)
* *(deps)* Update dependency floating-vue to v2.0.0 (#3883)
* *(deps)* Update tiptap to v2.1.15 (#3884)
* *(deps)* Update vueuse to v10.7.1 (#3872)
* *(deps)* Update pnpm to v8.14.1 (#3885)
* *(deps)* Update sentry-javascript monorepo to v7.93.0 (#3859)
* *(deps)* Update dependency floating-vue to v5 (#3887)
* *(deps)* Update dependency vue to v3.4.8 (#3886)
* *(deps)* Update node.js to v20.11 (#3888)
* *(deps)* Increase renovate timeout
* *(deps)* Update tiptap to v2.1.16 (#3892)
* *(deps)* Pin node.js (#3895)
* *(deps)* Update dev-dependencies
* *(deps)* Update dependency sortablejs to v1.15.2
* *(deps)* Update vueuse to v10.7.2
* *(deps)* Update dependency floating-vue to v5.1.0
* *(deps)* Update dependency vue to v3.4.14
* *(deps)* Update dev-dependencies
* *(deps)* Update dev-dependencies (major) (#3890)
* *(deps)* Update dependency floating-vue to v5.1.1
* *(deps)* Update dependency floating-vue to v5.2.0
* *(deps)* Update dev-dependencies
* *(deps)* Update dependency vue to v3.4.15
* *(deps)* Update dependency happy-dom to v13.2.0
* *(deps)* Update sentry-javascript monorepo to v7.94.1
* *(deps)* Update dependency vite to v5.0.12
* *(deps)* Update dependency date-fns to v3.3.0
* *(deps)* Update dev-dependencies
* *(deps)* Update pnpm to v8.14.2
* *(deps)* Update dependency date-fns to v3.3.1
* *(deps)* Update dev-dependencies to v6.19.1
* *(deps)* Update pnpm to v8.14.3
* *(deps)* Update sentry-javascript monorepo to v7.95.0
* *(deps)* Update dev-dependencies
* *(deps)* Update dependency axios to v1.6.6
* *(deps)* Update dev-dependencies
* *(deps)* Update sentry-javascript monorepo to v7.97.0
* *(deps)* Update sentry-javascript monorepo to v7.98.0
* *(deps)* Update dependency axios to v1.6.7
* *(deps)* Update dev-dependencies
* *(deps)* Update dev-dependencies
* *(deps)* Update dev-dependencies
### Features
* *(reminders)* Show reminders in notifications bar
* Datepicker locale support (#3878) ([92f7d9d](92f7d9ded5d56b95ba7d647eba01372f6ef682ad))
### Miscellaneous Tasks
* *(i18n)* Update translations via Crowdin
* *(i18n)* Update translations via Crowdin
* *(i18n)* Update translations via Crowdin
* *(perf)* Import some modules dynamically (#3179)
* Only show webhooks overview table when there are webhooks ([326bfb5](326bfb557ab359fa154b163f5dd957928f46d3ec))
* Only show webhooks overview table when there are webhooks ([631b02d](631b02d2eedc4a403b7c55f1c56ceaeca5379bf5))
## [0.22.0] - 2023-12-19
### Bug Fixes

View File

@ -3,7 +3,7 @@
# │─││ │││ │ │
# ┘─┘┘─┘┘┘─┘┘─┘
FROM --platform=$BUILDPLATFORM node:20.10-alpine AS builder
FROM --platform=$BUILDPLATFORM node:20.11.0-alpine AS builder
WORKDIR /build

View File

@ -1,10 +1,14 @@
# This repository was merged with the api and is now archived
You can find the new (old) code over on [vikunja/vikunja](https://kolaente.dev/vikunja/vikunja).
# Web frontend for Vikunja
> The todo app to organize your life.
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.22.0-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.22.1-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js.
@ -25,7 +29,7 @@ export DOCKER_BUILDKIT=1
docker build -t vikunja/frontend .
```
Refer to Refer [to multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for the different platform.
Refer to [multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for different platforms.
## Project setup

View File

@ -541,6 +541,86 @@ describe('Task', () => {
.should('contain', 'Success')
})
it('Can set a due date to a specific date for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Due Date')
.click()
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker .show')
.click()
cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
.click()
cy.get('[data-cy="closeDatepicker"]')
.contains('Confirm')
.click()
const today = new Date()
const day = today.toLocaleString('default', {day: 'numeric'})
const month = today.toLocaleString('default', {month: 'short'})
const year = today.toLocaleString('default', {year: 'numeric'})
const date = `${day} ${month} ${year}, 12:00:00`
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker-popup')
.should('not.exist')
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input')
.should('contain.text', date)
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can change a due date to a specific date for a task', () => {
const dueDate = new Date()
dueDate.setHours(12)
dueDate.setMinutes(0)
dueDate.setSeconds(0)
dueDate.setDate(1)
const tasks = TaskFactory.create(1, {
id: 1,
done: false,
due_date: dueDate.toISOString(),
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Due Date')
.click()
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker .show')
.click()
cy.get('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
.click()
cy.get('[data-cy="closeDatepicker"]')
.contains('Confirm')
.click()
const today = new Date()
const day = today.toLocaleString('default', {day: 'numeric'})
const month = today.toLocaleString('default', {month: 'short'})
const year = today.toLocaleString('default', {year: 'numeric'})
const date = `${day} ${month} ${year}, 12:00:00`
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input .datepicker-popup')
.should('not.exist')
cy.get('.task-view .columns.details .column')
.contains('Due Date')
.get('.date-input')
.should('contain.text', date)
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a reminder', () => {
TaskReminderFactory.truncate()
const tasks = TaskFactory.create(1, {
@ -645,7 +725,7 @@ describe('Task', () => {
.click()
cy.get('.reminder-options-popup .card-content .reminder-period input')
.first()
.type('10')
.type('{selectall}10')
cy.get('.reminder-options-popup .card-content .reminder-period select')
.first()
.select('days')
@ -771,7 +851,7 @@ describe('Task', () => {
.should('exist')
})
it.only('Can check items off a checklist', () => {
it('Can check items off a checklist', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: `
@ -858,7 +938,7 @@ describe('Task', () => {
method: 'PUT',
url: `${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments`,
headers: {
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
'Content-Type': 'multipart/form-data',
},
body: formData,

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@8.12.1",
"packageManager": "pnpm@8.15.1",
"keywords": [
"todo",
"productivity",
@ -22,6 +22,7 @@
"gantt",
"kanban"
],
"type": "module",
"scripts": {
"serve": "vite",
"preview": "vite preview --port 4173",
@ -29,7 +30,8 @@
"build": "vite build && workbox copyLibraries dist/",
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
"build:dev": "vite build --mode development --outDir dist-dev/",
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
"lint": "eslint 'src/**/*.{js,ts,vue}'",
"lint:fix": "pnpm run lint --fix",
"test:e2e": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome'",
"test:e2e-record": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
@ -48,61 +50,61 @@
"@fortawesome/fontawesome-svg-core": "6.5.1",
"@fortawesome/free-regular-svg-icons": "6.5.1",
"@fortawesome/free-solid-svg-icons": "6.5.1",
"@fortawesome/vue-fontawesome": "3.0.5",
"@fortawesome/vue-fontawesome": "3.0.6",
"@github/hotkey": "3.1.0",
"@infectoone/vue-ganttastic": "2.2.0",
"@intlify/unplugin-vue-i18n": "1.6.0",
"@kyvg/vue3-notification": "3.1.2",
"@sentry/tracing": "7.88.0",
"@sentry/vue": "7.88.0",
"@tiptap/core": "2.1.13",
"@tiptap/extension-blockquote": "2.1.13",
"@tiptap/extension-bold": "2.1.13",
"@tiptap/extension-bullet-list": "2.1.13",
"@tiptap/extension-code": "2.1.13",
"@tiptap/extension-code-block-lowlight": "2.1.13",
"@tiptap/extension-document": "2.1.13",
"@tiptap/extension-dropcursor": "2.1.13",
"@tiptap/extension-gapcursor": "2.1.13",
"@tiptap/extension-hard-break": "2.1.13",
"@tiptap/extension-heading": "2.1.13",
"@tiptap/extension-history": "2.1.13",
"@tiptap/extension-horizontal-rule": "2.1.13",
"@tiptap/extension-image": "2.1.13",
"@tiptap/extension-italic": "2.1.13",
"@tiptap/extension-link": "2.1.13",
"@tiptap/extension-list-item": "2.1.13",
"@tiptap/extension-ordered-list": "2.1.13",
"@tiptap/extension-paragraph": "2.1.13",
"@tiptap/extension-placeholder": "2.1.13",
"@tiptap/extension-strike": "2.1.13",
"@tiptap/extension-table": "2.1.13",
"@tiptap/extension-table-cell": "2.1.13",
"@tiptap/extension-table-header": "2.1.13",
"@tiptap/extension-table-row": "2.1.13",
"@tiptap/extension-task-item": "2.1.13",
"@tiptap/extension-task-list": "2.1.13",
"@tiptap/extension-text": "2.1.13",
"@tiptap/extension-typography": "2.1.13",
"@tiptap/extension-underline": "2.1.13",
"@tiptap/pm": "2.1.13",
"@tiptap/suggestion": "2.1.13",
"@tiptap/vue-3": "2.1.13",
"@intlify/unplugin-vue-i18n": "2.0.0",
"@kyvg/vue3-notification": "3.1.4",
"@sentry/tracing": "7.100.1",
"@sentry/vue": "7.100.1",
"@tiptap/core": "2.2.1",
"@tiptap/extension-blockquote": "2.2.1",
"@tiptap/extension-bold": "2.2.1",
"@tiptap/extension-bullet-list": "2.2.1",
"@tiptap/extension-code": "2.2.1",
"@tiptap/extension-code-block-lowlight": "2.2.1",
"@tiptap/extension-document": "2.2.1",
"@tiptap/extension-dropcursor": "2.2.1",
"@tiptap/extension-gapcursor": "2.2.1",
"@tiptap/extension-hard-break": "2.2.1",
"@tiptap/extension-heading": "2.2.1",
"@tiptap/extension-history": "2.2.1",
"@tiptap/extension-horizontal-rule": "2.2.1",
"@tiptap/extension-image": "2.2.1",
"@tiptap/extension-italic": "2.2.1",
"@tiptap/extension-link": "2.2.1",
"@tiptap/extension-list-item": "2.2.1",
"@tiptap/extension-ordered-list": "2.2.1",
"@tiptap/extension-paragraph": "2.2.1",
"@tiptap/extension-placeholder": "2.2.1",
"@tiptap/extension-strike": "2.2.1",
"@tiptap/extension-table": "2.2.1",
"@tiptap/extension-table-cell": "2.2.1",
"@tiptap/extension-table-header": "2.2.1",
"@tiptap/extension-table-row": "2.2.1",
"@tiptap/extension-task-item": "2.2.1",
"@tiptap/extension-task-list": "2.2.1",
"@tiptap/extension-text": "2.2.1",
"@tiptap/extension-typography": "2.2.1",
"@tiptap/extension-underline": "2.2.1",
"@tiptap/pm": "2.2.1",
"@tiptap/suggestion": "2.2.1",
"@tiptap/vue-3": "2.2.1",
"@types/is-touch-device": "1.0.2",
"@types/lodash.clonedeep": "4.5.9",
"@vueuse/core": "10.7.0",
"@vueuse/router": "10.7.0",
"axios": "1.6.2",
"@vueuse/core": "10.7.2",
"@vueuse/router": "10.7.2",
"axios": "1.6.7",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"date-fns": "2.30.0",
"date-fns": "3.3.1",
"dayjs": "1.11.10",
"dompurify": "3.0.6",
"dompurify": "3.0.8",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
"flexsearch": "0.7.31",
"floating-vue": "2.0.0-beta.24",
"floating-vue": "5.2.2",
"is-touch-device": "1.0.1",
"klona": "2.0.6",
"lodash.debounce": "4.0.8",
@ -110,25 +112,25 @@
"pinia": "2.1.7",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"sortablejs": "1.15.1",
"sortablejs": "1.15.2",
"tippy.js": "6.3.7",
"ufo": "1.3.2",
"vue": "3.3.13",
"ufo": "1.4.0",
"vue": "3.4.15",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.3",
"vue-i18n": "9.8.0",
"vue-i18n": "9.9.1",
"vue-router": "4.2.5",
"workbox-precaching": "7.0.0",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.2.5",
"@cypress/vite-dev-server": "5.0.6",
"@cypress/vite-dev-server": "5.0.7",
"@cypress/vue": "6.0.0",
"@faker-js/faker": "8.3.1",
"@histoire/plugin-screenshot": "0.17.6",
"@histoire/plugin-vue": "0.17.6",
"@rushstack/eslint-patch": "1.6.1",
"@faker-js/faker": "8.4.0",
"@histoire/plugin-screenshot": "0.17.8",
"@histoire/plugin-vue": "0.17.9",
"@rushstack/eslint-patch": "1.7.2",
"@tsconfig/node18": "18.2.2",
"@types/codemirror": "5.60.15",
"@types/dompurify": "3.0.5",
@ -136,44 +138,44 @@
"@types/is-touch-device": "1.0.2",
"@types/lodash.debounce": "4.0.9",
"@types/marked": "5.0.2",
"@types/node": "20.10.5",
"@types/node": "20.11.10",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.7",
"@typescript-eslint/eslint-plugin": "6.15.0",
"@typescript-eslint/parser": "6.15.0",
"@vitejs/plugin-legacy": "5.2.0",
"@vitejs/plugin-vue": "4.5.2",
"@typescript-eslint/eslint-plugin": "6.20.0",
"@typescript-eslint/parser": "6.20.0",
"@vitejs/plugin-legacy": "5.3.0",
"@vitejs/plugin-vue": "5.0.3",
"@vue/eslint-config-typescript": "12.0.0",
"@vue/test-utils": "2.4.3",
"@vue/test-utils": "2.4.4",
"@vue/tsconfig": "0.5.1",
"autoprefixer": "10.4.16",
"browserslist": "4.22.2",
"caniuse-lite": "1.0.30001570",
"autoprefixer": "10.4.17",
"browserslist": "4.22.3",
"caniuse-lite": "1.0.30001581",
"css-has-pseudo": "6.0.1",
"csstype": "3.1.3",
"cypress": "13.6.1",
"esbuild": "0.19.10",
"cypress": "13.6.3",
"esbuild": "0.20.0",
"eslint": "8.56.0",
"eslint-plugin-vue": "9.19.2",
"happy-dom": "12.10.3",
"histoire": "0.17.6",
"postcss": "8.4.32",
"eslint-plugin-vue": "9.20.1",
"happy-dom": "13.3.5",
"histoire": "0.17.9",
"postcss": "8.4.33",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "4.0.0",
"postcss-focus-within": "8.0.1",
"postcss-preset-env": "9.3.0",
"rollup": "4.9.1",
"rollup-plugin-visualizer": "5.11.0",
"sass": "1.69.5",
"rollup": "4.9.6",
"rollup-plugin-visualizer": "5.12.0",
"sass": "1.70.0",
"start-server-and-test": "2.0.3",
"typescript": "5.3.3",
"vite": "5.0.10",
"vite": "5.0.12",
"vite-plugin-inject-preload": "1.3.3",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-pwa": "0.17.5",
"vite-plugin-sentry": "1.3.0",
"vite-svg-loader": "5.1.0",
"vitest": "1.0.4",
"vue-tsc": "1.8.25",
"vitest": "1.2.2",
"vue-tsc": "1.8.27",
"wait-on": "7.2.0",
"workbox-cli": "7.0.0"
},

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,11 @@
"extends": [
"config:js-app"
],
"hostRules": [
{
"timeout": 600000
}
],
"packageRules": [
{
"matchPackageNames": ["happy-dom"],

View File

@ -1,23 +1,23 @@
<template>
<ready>
<Ready>
<template v-if="authUser">
<TheNavigation/>
<content-auth/>
<TheNavigation />
<ContentAuth />
</template>
<content-link-share v-else-if="authLinkShare"/>
<no-auth-wrapper v-else>
<router-view/>
</no-auth-wrapper>
<ContentLinkShare v-else-if="authLinkShare" />
<NoAuthWrapper v-else>
<router-view />
</NoAuthWrapper>
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
<KeyboardShortcuts v-if="keyboardShortcutsActive" />
<Teleport to="body">
<AddToHomeScreen/>
<UpdateNotification/>
<Notification/>
<DemoMode/>
<AddToHomeScreen />
<UpdateNotification />
<Notification />
<DemoMode />
</Teleport>
</ready>
</Ready>
</template>
<script lang="ts" setup>
@ -37,8 +37,6 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import Ready from '@/components/misc/ready.vue'
import {setLanguage} from '@/i18n'
import AccountDeleteService from '@/services/accountDelete'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
@ -48,6 +46,9 @@ import {useBodyClass} from '@/composables/useBodyClass'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
import DemoMode from '@/components/home/DemoMode.vue'
const importAccountDeleteService = () => import('@/services/accountDelete')
const importMessage = () => import('@/message')
const baseStore = useBaseStore()
const authStore = useAuthStore()
const router = useRouter()
@ -68,8 +69,11 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
return
}
const messageP = importMessage()
const AccountDeleteService = (await importAccountDeleteService()).default
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
const {success} = await messageP
success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo()
}, { immediate: true })

View File

@ -21,10 +21,16 @@ const state = reactive({
</script>
<template>
<Story :setup-app="setupApp" :layout="{ type: 'grid', width: '200px' }">
<Story
:setup-app="setupApp"
:layout="{ type: 'grid', width: '200px' }"
>
<Variant title="custom">
<template #controls>
<HstCheckbox v-model="state.disabled" title="Disabled" />
<HstCheckbox
v-model="state.disabled"
title="Disabled"
/>
</template>
<BaseButton :disabled="state.disabled">
Hello!

View File

@ -6,38 +6,39 @@
<template>
<div
v-if="disabled === true && (to !== undefined || href !== undefined)"
ref="button"
class="base-button"
:aria-disabled="disabled || undefined"
ref="button"
>
<slot/>
<slot />
</div>
<router-link
v-else-if="to !== undefined"
ref="button"
:to="to"
class="base-button"
ref="button"
>
<slot/>
<slot />
</router-link>
<a v-else-if="href !== undefined"
<a
v-else-if="href !== undefined"
ref="button"
class="base-button"
:href="href"
rel="noreferrer noopener nofollow"
target="_blank"
ref="button"
>
<slot/>
<slot />
</a>
<button
v-else
ref="button"
:type="type"
class="base-button base-button--type-button"
:disabled="disabled || undefined"
ref="button"
@click="(event: MouseEvent) => emit('click', event)"
>
<slot/>
<slot />
</button>
</template>

View File

@ -1,17 +1,26 @@
<template>
<div class="base-checkbox" v-cy="'checkbox'">
<div
v-cy="'checkbox'"
class="base-checkbox"
>
<input
type="checkbox"
:id="checkboxId"
type="checkbox"
class="is-sr-only"
:checked="modelValue"
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
:disabled="disabled || undefined"
/>
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
>
<slot name="label" :checkboxId="checkboxId">
<label :for="checkboxId" class="base-checkbox__label">
<slot/>
<slot
name="label"
:checkbox-id="checkboxId"
>
<label
:for="checkboxId"
class="base-checkbox__label"
>
<slot />
</label>
</slot>
</div>

View File

@ -1,27 +1,30 @@
<template>
<transition
name="expandable-slide"
@before-enter="beforeEnter"
@enter="enter"
@after-enter="afterEnter"
@enter-cancelled="enterCancelled"
@before-leave="beforeLeave"
@leave="leave"
@after-leave="afterLeave"
@leave-cancelled="leaveCancelled"
>
<div
v-if="initialHeight"
class="expandable-initial-height"
:style="{ maxHeight: `${initialHeight}px` }"
:class="{ 'expandable-initial-height--expanded': open }"
>
<slot />
</div>
<div v-else-if="open" class="expandable">
<slot />
</div>
</transition>
<transition
name="expandable-slide"
@beforeEnter="beforeEnter"
@enter="enter"
@afterEnter="afterEnter"
@enterCancelled="enterCancelled"
@beforeLeave="beforeLeave"
@leave="leave"
@afterLeave="afterLeave"
@leaveCancelled="leaveCancelled"
>
<div
v-if="initialHeight"
class="expandable-initial-height"
:style="{ maxHeight: `${initialHeight}px` }"
:class="{ 'expandable-initial-height--expanded': open }"
>
<slot />
</div>
<div
v-else-if="open"
class="expandable"
>
<slot />
</div>
</transition>
</template>
<script setup lang="ts">

View File

@ -3,9 +3,9 @@ import datemathHelp from './datemathHelp.vue'
</script>
<template>
<Story>
<Variant title="Default">
<datemathHelp />
</Variant>
</Story>
<Story>
<Variant title="Default">
<datemathHelp />
</Variant>
</Story>
</template>

View File

@ -7,21 +7,29 @@
{{ $t('input.datemathHelp.intro') }}
</p>
<p>
<i18n-t keypath="input.datemathHelp.expression" scope="global">
<i18n-t
keypath="input.datemathHelp.expression"
scope="global"
>
<code>now</code>
<code>||</code>
</i18n-t>
</p>
<p>
<i18n-t keypath="input.datemathHelp.similar" scope="global">
<i18n-t
keypath="input.datemathHelp.similar"
scope="global"
>
<BaseButton
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
target="_blank">
target="_blank"
>
Grafana
</BaseButton>
<BaseButton
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
target="_blank">
target="_blank"
>
Elasticsearch
</BaseButton>
</i18n-t>
@ -35,76 +43,79 @@
<h3>{{ $t('input.datemathHelp.supportedUnits') }}</h3>
<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>
<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>
<h3>{{ $t('input.datemathHelp.someExamples') }}</h3>
<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" scope="global">
<strong>{{ exampleDate }}</strong>
</i18n-t>
</td>
</tr>
<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"
scope="global"
>
<strong>{{ exampleDate }}</strong>
</i18n-t>
</td>
</tr>
</tbody>
</table>
</card>

View File

@ -1,20 +1,31 @@
<template>
<div class="datepicker-with-range-container">
<popup>
<Popup>
<template #trigger="{toggle}">
<slot name="trigger" :toggle="toggle" :buttonText="buttonText"></slot>
<slot
name="trigger"
:toggle="toggle"
:button-text="buttonText"
/>
</template>
<template #content="{isOpen}">
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
<div
class="datepicker-with-range"
:class="{'is-open': isOpen}"
>
<div class="selections">
<BaseButton @click="setDateRange(null)" :class="{'is-active': customRangeActive}">
<BaseButton
:class="{'is-active': customRangeActive}"
@click="setDateRange(null)"
>
{{ $t('misc.custom') }}
</BaseButton>
<BaseButton
v-for="(value, text) in DATE_RANGES"
:key="text"
:class="{'is-active': from === value[0] && to === value[1]}"
@click="setDateRange(value)"
:class="{'is-active': from === value[0] && to === value[1]}">
>
{{ $t(`input.datepickerRange.ranges.${text}`) }}
</BaseButton>
</div>
@ -23,10 +34,18 @@
{{ $t('input.datepickerRange.from') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="from"/>
<input
v-model="from"
class="input"
type="text"
>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
<x-button
icon="calendar"
variant="secondary"
data-toggle
/>
</div>
</div>
</label>
@ -34,38 +53,49 @@
{{ $t('input.datepickerRange.to') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="to"/>
<input
v-model="to"
class="input"
type="text"
>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
<x-button
icon="calendar"
variant="secondary"
data-toggle
/>
</div>
</div>
</label>
<flat-pickr
:config="flatPickerConfig"
v-model="flatpickrRange"
:config="flatPickerConfig"
/>
<p>
{{ $t('input.datemathHelp.canuse') }}
<BaseButton class="has-text-primary" @click="showHowItWorks = true">
<BaseButton
class="has-text-primary"
@click="showHowItWorks = true"
>
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
</p>
<modal
:enabled="showHowItWorks"
@close="() => showHowItWorks = false"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => showHowItWorks = false"
>
<DatemathHelp/>
<DatemathHelp />
</modal>
</div>
</div>
</template>
</popup>
</Popup>
</div>
</template>
@ -81,20 +111,18 @@ 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'
import {useAuthStore} from '@/stores/auth'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
const authStore = useAuthStore()
const {t} = useI18n({useScope: 'global'})
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: {
required: false,
},
})
// FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed(() => authStore.settings.weekStart ?? 0)
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
@ -102,9 +130,7 @@ const flatPickerConfig = computed(() => ({
enableTime: false,
wrap: true,
mode: 'range',
locale: {
firstDayOf7Days: weekStart.value,
},
locale: getFlatpickrLanguage(),
}))
const showHowItWorks = ref(false)

View File

@ -4,12 +4,18 @@
class="add-to-home-screen"
:class="{'has-update-available': hasUpdateAvailable}"
>
<icon icon="arrow-up-from-bracket" class="add-icon"/>
<icon
icon="arrow-up-from-bracket"
class="add-icon"
/>
<p>
{{ $t('home.addToHomeScreen') }}
</p>
<BaseButton @click="() => hideMessage = true" class="hide-button">
<icon icon="x"/>
<BaseButton
class="hide-button"
@click="() => hideMessage = true"
>
<icon icon="x" />
</BaseButton>
</div>
</template>

View File

@ -17,8 +17,11 @@ const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
{{ $t('demo.title') }}
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
</p>
<BaseButton @click="() => hide = true" class="hide-button">
<icon icon="times"/>
<BaseButton
class="hide-button"
@click="() => hide = true"
>
<icon icon="times" />
</BaseButton>
</div>
</template>

View File

@ -15,8 +15,17 @@ const CustomLogo = computed(() => window.CUSTOM_LOGO_URL)
<template>
<div>
<Logo v-if="!CustomLogo" alt="Vikunja" class="logo" />
<img v-show="CustomLogo" :src="CustomLogo" alt="Vikunja" class="logo" />
<Logo
v-if="!CustomLogo"
alt="Vikunja"
class="logo"
/>
<img
v-show="CustomLogo"
:src="CustomLogo"
alt="Vikunja"
class="logo"
>
</div>
</template>

View File

@ -1,11 +1,11 @@
<template>
<BaseButton
class="menu-show-button"
@click="baseStore.toggleMenu()"
@shortkey="() => baseStore.toggleMenu()"
v-shortcut="'Mod+e'"
class="menu-show-button"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
@click="baseStore.toggleMenu()"
@shortkey="() => baseStore.toggleMenu()"
/>
</template>

View File

@ -1,7 +1,11 @@
<template>
<BaseButton class="menu-bottom-link" :href="poweredByUrl" target="_blank">
{{ $t('misc.poweredBy') }}
</BaseButton>
<BaseButton
class="menu-bottom-link"
:href="poweredByUrl"
target="_blank"
>
{{ $t('misc.poweredBy') }}
</BaseButton>
</template>
<script setup lang="ts">

View File

@ -2,10 +2,8 @@
<draggable
v-model="availableProjects"
animation="100"
ghostClass="ghost"
ghost-class="ghost"
group="projects"
@start="() => drag = true"
@end="saveProjectPosition"
handle=".handle"
tag="menu"
item-key="id"
@ -19,6 +17,8 @@
{ 'dragging-disabled': !canEditOrder }
],
}"
@start="() => drag = true"
@end="saveProjectPosition"
>
<template #item="{element: project}">
<ProjectsNavigationItem

View File

@ -6,10 +6,13 @@
<div>
<BaseButton
v-if="canCollapse && childProjects?.length > 0"
@click="childProjectsOpen = !childProjectsOpen"
class="collapse-project-button"
@click="childProjectsOpen = !childProjectsOpen"
>
<icon icon="chevron-down" :class="{ 'project-is-collapsed': !childProjectsOpen }"/>
<icon
icon="chevron-down"
:class="{ 'project-is-collapsed': !childProjectsOpen }"
/>
</BaseButton>
<BaseButton
:to="{ name: 'project.index', params: { projectId: project.id} }"
@ -19,21 +22,27 @@
<span
v-if="!canCollapse || childProjects?.length === 0"
class="collapse-project-button-placeholder"
></span>
<div class="color-bubble-handle-wrapper" :class="{'is-draggable': project.id > 0}">
/>
<div
class="color-bubble-handle-wrapper"
:class="{'is-draggable': project.id > 0}"
>
<ColorBubble
v-if="project.hexColor !== ''"
:color="project.hexColor"
/>
<span v-else-if="project.id < -1" class="saved-filter-icon icon menu-item-icon">
<icon icon="filter"/>
<span
v-else-if="project.id < -1"
class="saved-filter-icon icon menu-item-icon"
>
<icon icon="filter" />
</span>
<span
v-if="project.id > 0"
class="icon menu-item-icon handle lines-handle"
class="icon menu-item-icon handle"
:class="{'has-color-bubble': project.hexColor !== ''}"
>
<icon icon="grip-lines"/>
<icon icon="grip-lines" />
</span>
</div>
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
@ -44,7 +53,7 @@
:class="{'is-favorite': project.isFavorite}"
@click="projectStore.toggleProjectFavorite(project)"
>
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']"/>
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']" />
</BaseButton>
<ProjectSettingsDropdown
class="menu-list-dropdown"
@ -52,8 +61,14 @@
:level="level"
>
<template #trigger="{toggleOpen}">
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
<BaseButton
class="menu-list-dropdown-trigger"
@click="toggleOpen"
>
<icon
icon="ellipsis-h"
class="icon"
/>
</BaseButton>
</template>
</ProjectSettingsDropdown>

View File

@ -1,66 +1,110 @@
<template>
<header :class="{ 'has-background': background, 'menu-active': menuActive }" aria-label="main navigation"
class="navbar d-print-none">
<router-link :to="{ name: 'home' }" class="logo-link">
<Logo width="164" height="48" />
<header
:class="{ 'has-background': background, 'menu-active': menuActive }"
aria-label="main navigation"
class="navbar d-print-none"
>
<router-link
:to="{ name: 'home' }"
class="logo-link"
>
<Logo
width="164"
height="48"
/>
</router-link>
<MenuButton class="menu-button" />
<div v-if="currentProject?.id" class="project-title-wrapper">
<div
v-if="currentProject?.id"
class="project-title-wrapper"
>
<h1 class="project-title">
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
<BaseButton :to="{ name: 'project.info', params: { projectId: currentProject.id } }" class="project-title-button">
<BaseButton
:to="{ name: 'project.info', params: { projectId: currentProject.id } }"
class="project-title-button"
>
<icon icon="circle-info" />
</BaseButton>
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1"
class="project-title-dropdown" :project="currentProject">
<ProjectSettingsDropdown
v-if="canWriteCurrentProject && currentProject.id !== -1"
class="project-title-dropdown"
:project="currentProject"
>
<template #trigger="{ toggleOpen }">
<BaseButton class="project-title-button" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon" />
<BaseButton
class="project-title-button"
@click="toggleOpen"
>
<icon
icon="ellipsis-h"
class="icon"
/>
</BaseButton>
</template>
</project-settings-dropdown>
</ProjectSettingsDropdown>
</div>
<div class="navbar-end">
<OpenQuickActions/>
<OpenQuickActions />
<Notifications />
<dropdown>
<Dropdown>
<template #trigger="{ toggleOpen, open }">
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40" />
<BaseButton
class="username-dropdown-trigger"
variant="secondary"
:shadow="false"
@click="toggleOpen"
>
<img
:src="authStore.avatarUrl"
alt=""
class="avatar"
width="40"
height="40"
>
<span class="username">{{ authStore.userDisplayName }}</span>
<span class="icon is-small" :style="{
transform: open ? 'rotate(180deg)' : 'rotate(0)',
}">
<span
class="icon is-small"
:style="{
transform: open ? 'rotate(180deg)' : 'rotate(0)',
}"
>
<icon icon="chevron-down" />
</span>
</BaseButton>
</template>
<dropdown-item :to="{ name: 'user.settings' }">
<DropdownItem :to="{ name: 'user.settings' }">
{{ $t('user.settings.title') }}
</dropdown-item>
<dropdown-item v-if="imprintUrl" :href="imprintUrl">
</DropdownItem>
<DropdownItem
v-if="imprintUrl"
:href="imprintUrl"
>
{{ $t('navigation.imprint') }}
</dropdown-item>
<dropdown-item v-if="privacyPolicyUrl" :href="privacyPolicyUrl">
</DropdownItem>
<DropdownItem
v-if="privacyPolicyUrl"
:href="privacyPolicyUrl"
>
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item @click="baseStore.setKeyboardShortcutsActive(true)">
</DropdownItem>
<DropdownItem @click="baseStore.setKeyboardShortcutsActive(true)">
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
<dropdown-item :to="{ name: 'about' }">
</DropdownItem>
<DropdownItem :to="{ name: 'about' }">
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item @click="authStore.logout()">
</DropdownItem>
<DropdownItem @click="authStore.logout()">
{{ $t('user.auth.logout') }}
</dropdown-item>
</dropdown>
</DropdownItem>
</Dropdown>
</div>
</header>
</template>

View File

@ -1,11 +1,16 @@
<template>
<div class="update-notification" v-if="updateAvailable">
<p class="update-notification__message">{{ $t('update.available') }}</p>
<div
v-if="updateAvailable"
class="update-notification"
>
<p class="update-notification__message">
{{ $t('update.available') }}
</p>
<x-button
@click="refreshApp()"
:shadow="false"
:wrap="false"
>
@click="refreshApp()"
>
{{ $t('update.do') }}
</x-button>
</div>

View File

@ -2,10 +2,10 @@
<div class="content-auth">
<BaseButton
v-show="menuActive"
@click="baseStore.setMenuActive(false)"
class="menu-hide-button d-print-none"
@click="baseStore.setMenuActive(false)"
>
<icon icon="times"/>
<icon icon="times" />
</BaseButton>
<div
class="app-container"
@ -15,8 +15,9 @@
<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"/>
:style="{'background-image': background && `url(${background})`}"
/>
<Navigation class="d-print-none" />
<main
class="app-content"
:class="[
@ -26,33 +27,36 @@
>
<BaseButton
v-show="menuActive"
@click="baseStore.setMenuActive(false)"
class="mobile-overlay d-print-none"
@click="baseStore.setMenuActive(false)"
/>
<quick-actions/>
<QuickActions />
<router-view :route="routeWithModal" v-slot="{ Component }">
<router-view
v-slot="{ Component }"
:route="routeWithModal"
>
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
<component :is="Component"/>
<component :is="Component" />
</keep-alive>
</router-view>
<modal
:enabled="typeof currentModal !== 'undefined'"
@close="closeModal()"
variant="scrolling"
class="task-detail-view-modal"
@close="closeModal()"
>
<component :is="currentModal"/>
<component :is="currentModal" />
</modal>
<BaseButton
v-shortcut="'?'"
class="keyboard-shortcuts-button d-print-none"
@click="showKeyboardShortcuts()"
v-shortcut="'?'"
>
<icon icon="keyboard"/>
<icon icon="keyboard" />
</BaseButton>
</main>
</div>

View File

@ -6,16 +6,20 @@
>
<div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1">
<Logo class="logo" v-if="logoVisible"/>
<Logo
v-if="logoVisible"
class="logo"
/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
class="title">
class="title"
>
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
</h1>
<div class="box has-text-left view">
<router-view/>
<PoweredByLink/>
<router-view />
<PoweredByLink />
</div>
</div>
</div>

View File

@ -1,46 +1,70 @@
<template>
<aside :class="{'is-active': baseStore.menuActive}" class="menu-container">
<aside
:class="{'is-active': baseStore.menuActive}"
class="menu-container"
>
<nav class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48"/>
<router-link
:to="{name: 'home'}"
class="logo"
>
<Logo
width="164"
height="48"
/>
</router-link>
<menu class="menu-list other-menu-items">
<li>
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
<router-link
v-shortcut="'g o'"
:to="{ name: 'home'}"
>
<span class="menu-item-icon icon">
<icon icon="calendar"/>
<icon icon="calendar" />
</span>
{{ $t('navigation.overview') }}
</router-link>
</li>
<li>
<router-link :to="{ name: 'tasks.range'}" v-shortcut="'g u'">
<router-link
v-shortcut="'g u'"
:to="{ name: 'tasks.range'}"
>
<span class="menu-item-icon icon">
<icon :icon="['far', 'calendar-alt']"/>
<icon :icon="['far', 'calendar-alt']" />
</span>
{{ $t('navigation.upcoming') }}
</router-link>
</li>
<li>
<router-link :to="{ name: 'projects.index'}" v-shortcut="'g p'">
<router-link
v-shortcut="'g p'"
:to="{ name: 'projects.index'}"
>
<span class="menu-item-icon icon">
<icon icon="layer-group"/>
<icon icon="layer-group" />
</span>
{{ $t('project.projects') }}
</router-link>
</li>
<li>
<router-link :to="{ name: 'labels.index'}" v-shortcut="'g a'">
<router-link
v-shortcut="'g a'"
:to="{ name: 'labels.index'}"
>
<span class="menu-item-icon icon">
<icon icon="tags"/>
<icon icon="tags" />
</span>
{{ $t('label.title') }}
</router-link>
</li>
<li>
<router-link :to="{ name: 'teams.index'}" v-shortcut="'g m'">
<router-link
v-shortcut="'g m'"
:to="{ name: 'teams.index'}"
>
<span class="menu-item-icon icon">
<icon icon="users"/>
<icon icon="users" />
</span>
{{ $t('team.title') }}
</router-link>
@ -53,7 +77,10 @@
variant="small"
/>
<template v-else>
<nav class="menu" v-if="favoriteProjects">
<nav
v-if="favoriteProjects"
class="menu"
>
<ProjectsNavigation
:model-value="favoriteProjects"
:can-edit-order="false"
@ -61,7 +88,10 @@
/>
</nav>
<nav class="menu" v-if="savedFilterProjects">
<nav
v-if="savedFilterProjects"
class="menu"
>
<ProjectsNavigation
:model-value="savedFilterProjects"
:can-edit-order="false"
@ -79,7 +109,7 @@
</nav>
</template>
<PoweredByLink/>
<PoweredByLink />
</aside>
</template>

View File

@ -6,19 +6,28 @@ import XButton from './button.vue'
<template>
<Story :layout="{ type: 'grid', width: '200px' }">
<Variant title="primary">
<XButton @click="logEvent('Click', $event)" variant="primary">
<XButton
variant="primary"
@click="logEvent('Click', $event)"
>
Order pizza!
</XButton>
</Variant>
<Variant title="secondary">
<XButton @click="logEvent('Click', $event)" variant="secondary">
<XButton
variant="secondary"
@click="logEvent('Click', $event)"
>
Order spaghetti!
</XButton>
</Variant>
<Variant title="tertiary">
<XButton @click="logEvent('Click', $event)" variant="tertiary">
<XButton
variant="tertiary"
@click="logEvent('Click', $event)"
>
Order tortellini!
</XButton>
</Variant>

View File

@ -1,36 +1,67 @@
<template>
<div class="color-picker-container">
<datalist :id="colorListID">
<option v-for="defaultColor in defaultColors" :key="defaultColor" :value="defaultColor" />
<option
v-for="defaultColor in defaultColors"
:key="defaultColor"
:value="defaultColor"
/>
</datalist>
<div class="picker">
<input
v-model="color"
class="picker__input"
type="color"
v-model="color"
:list="colorListID"
:class="{'is-empty': isEmpty}"
/>
<svg class="picker__pattern" v-show="isEmpty" viewBox="0 0 22 22" fill="fff">
<pattern id="checker" width="11" height="11" patternUnits="userSpaceOnUse" fill="FFF">
<rect fill="#cccccc" x="0" width="5.5" height="5.5" y="0"></rect>
<rect fill="#cccccc" x="5.5" width="5.5" height="5.5" y="5.5"></rect>
>
<svg
v-show="isEmpty"
class="picker__pattern"
viewBox="0 0 22 22"
fill="fff"
>
<pattern
id="checker"
width="11"
height="11"
patternUnits="userSpaceOnUse"
fill="FFF"
>
<rect
fill="#cccccc"
x="0"
width="5.5"
height="5.5"
y="0"
/>
<rect
fill="#cccccc"
x="5.5"
width="5.5"
height="5.5"
y="5.5"
/>
</pattern>
<rect width="22" height="22" fill="url(#checker)"></rect>
<rect
width="22"
height="22"
fill="url(#checker)"
/>
</svg>
</div>
<x-button
<XButton
v-if="!isEmpty"
:disabled="isEmpty"
@click="reset"
class="is-small ml-2"
:shadow="false"
variant="secondary"
@click="reset"
>
{{ $t('input.resetColor') }}
</x-button>
</XButton>
</div>
</template>
@ -39,6 +70,14 @@ import {computed, ref, watch} from 'vue'
import {createRandomID} from '@/helpers/randomId'
import XButton from '@/components/input/button.vue'
const {
modelValue,
} = defineProps<{
modelValue: string,
}>()
const emit = defineEmits(['update:modelValue'])
const DEFAULT_COLORS = [
'#1973ff',
'#7F23FF',
@ -53,17 +92,18 @@ const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const defaultColors = ref(DEFAULT_COLORS)
const colorListID = ref(createRandomID())
const {
modelValue,
} = defineProps<{
modelValue: string,
}>()
const emit = defineEmits(['update:modelValue'])
watch(
() => modelValue,
(newValue) => {
if (newValue === '' || newValue.startsWith('var(')) {
color.value = ''
return
}
if (!newValue.startsWith('#') && (newValue.length === 6 || newValue.length === 3)) {
newValue = `#${newValue}`
}
color.value = newValue
},
{immediate: true},

View File

@ -1,5 +1,5 @@
<template>
<multiselect
<Multiselect
v-model="selectedProjects"
:search-results="foundProjects"
:loading="projectService.loading"

View File

@ -1,5 +1,5 @@
<template>
<multiselect
<Multiselect
v-model="selectedUsers"
:search-results="foundUsers"
:loading="userService.loading"

View File

@ -1,6 +1,6 @@
<template>
<BaseButton class="simple-button">
<slot/>
<slot />
</BaseButton>
</template>

View File

@ -18,7 +18,10 @@
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
/>
<span class="icon is-small" v-else>
<span
v-else
class="icon is-small"
>
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
@ -38,7 +41,7 @@ const BUTTON_TYPES_MAP = {
export type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
export default { name: 'x-button' }
export default { name: 'XButton' }
</script>
<script setup lang="ts">

View File

@ -1,22 +1,29 @@
<template>
<div class="datepicker">
<SimpleButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
<SimpleButton
class="show"
:disabled="disabled || undefined"
@click.stop="toggleDatePopup"
>
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
</SimpleButton>
<CustomTransition name="fade">
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
<div
v-if="show"
ref="datepickerPopup"
class="datepicker-popup"
>
<DatepickerInline
v-model="date"
@update:model-value="updateData"
@update:modelValue="updateData"
/>
<x-button
v-cy="'closeDatepicker'"
class="datepicker__close-button"
:shadow="false"
@click="close"
v-cy="'closeDatepicker'"
>
{{ $t('misc.confirm') }}
</x-button>
@ -56,7 +63,7 @@ const props = defineProps({
},
})
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
const emit = defineEmits(['update:modelValue', 'close', 'closeOnChange'])
const date = ref<Date | null>()
const show = ref(false)
@ -108,7 +115,7 @@ function close() {
emit('close', changed.value)
if (changed.value) {
changed.value = false
emit('close-on-change', changed.value)
emit('closeOnChange', changed.value)
}
}, 200)
}

View File

@ -4,7 +4,7 @@
class="datepicker__quick-select-date"
@click.stop="setDate('today')"
>
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
<span class="icon"><icon :icon="['far', 'calendar-alt']" /></span>
<span class="text">
<span>{{ $t('input.datepicker.today') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
@ -14,7 +14,7 @@
class="datepicker__quick-select-date"
@click.stop="setDate('tomorrow')"
>
<span class="icon"><icon :icon="['far', 'sun']"/></span>
<span class="icon"><icon :icon="['far', 'sun']" /></span>
<span class="text">
<span>{{ $t('input.datepicker.tomorrow') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
@ -24,7 +24,7 @@
class="datepicker__quick-select-date"
@click.stop="setDate('nextMonday')"
>
<span class="icon"><icon icon="coffee"/></span>
<span class="icon"><icon icon="coffee" /></span>
<span class="text">
<span>{{ $t('input.datepicker.nextMonday') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
@ -34,7 +34,7 @@
class="datepicker__quick-select-date"
@click.stop="setDate('thisWeekend')"
>
<span class="icon"><icon icon="cocktail"/></span>
<span class="icon"><icon icon="cocktail" /></span>
<span class="text">
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
@ -44,7 +44,7 @@
class="datepicker__quick-select-date"
@click.stop="setDate('laterThisWeek')"
>
<span class="icon"><icon icon="chess-knight"/></span>
<span class="icon"><icon icon="chess-knight" /></span>
<span class="text">
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
@ -54,7 +54,7 @@
class="datepicker__quick-select-date"
@click.stop="setDate('nextWeek')"
>
<span class="icon"><icon icon="forward"/></span>
<span class="icon"><icon icon="forward" /></span>
<span class="text">
<span>{{ $t('input.datepicker.nextWeek') }}</span>
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
@ -63,8 +63,8 @@
<div class="flatpickr-container">
<flat-pickr
:config="flatPickerConfig"
v-model="flatPickrDate"
:config="flatPickerConfig"
/>
</div>
</template>
@ -80,8 +80,8 @@ import {formatDate} from '@/helpers/time/formatDate'
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
const props = defineProps({
modelValue: {
@ -105,8 +105,6 @@ watch(
{immediate: true},
)
const authStore = useAuthStore()
const weekStart = computed(() => authStore.settings.weekStart)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
@ -114,9 +112,7 @@ const flatPickerConfig = computed(() => ({
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: weekStart.value,
},
locale: getFlatpickrLanguage(),
}))
// Since flatpickr dates are strings, we need to convert them to native date objects.
@ -128,6 +124,12 @@ const flatPickrDate = computed({
return
}
if (date.value !== null) {
const oldDate = formatDate(date.value, 'yyy-LL-dd H:mm')
if (oldDate === newValue) {
return
}
}
date.value = createDateFromString(newValue)
updateData()
},
@ -155,10 +157,6 @@ function updateData() {
}
function setDate(dateString: string) {
if (date.value === null) {
date.value = new Date()
}
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
@ -166,7 +164,6 @@ function setDate(dateString: string) {
newDate.setMinutes(0)
newDate.setSeconds(0)
date.value = newDate
flatPickrDate.value = newDate
updateData()
}

View File

@ -2,26 +2,30 @@
<div class="items">
<template v-if="items.length">
<button
class="item"
:class="{ 'is-selected': index === selectedIndex }"
v-for="(item, index) in items"
:key="index"
class="item"
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
>
<icon :icon="item.icon"/>
<icon :icon="item.icon" />
<div class="description">
<p>{{ item.title }}</p>
<p>{{ item.description }}</p>
</div>
</button>
</template>
<div class="item" v-else>
<div
v-else
class="item"
>
No result
</div>
</div>
</template>
<script>
<script lang="ts">
/* eslint-disable vue/component-api-style */
export default {
props: {
items: {

View File

@ -2,35 +2,35 @@
<div class="editor-toolbar">
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
v-tooltip="$t('input.editor.heading1')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-header']"/>
<icon :icon="['fa', 'fa-header']" />
<span class="icon__lower-text">1</span>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
v-tooltip="$t('input.editor.heading2')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-header']"/>
<icon :icon="['fa', 'fa-header']" />
<span class="icon__lower-text">2</span>
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
v-tooltip="$t('input.editor.heading3')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-header']"/>
<icon :icon="['fa', 'fa-header']" />
<span class="icon__lower-text">3</span>
</span>
</BaseButton>
@ -38,167 +38,167 @@
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
v-tooltip="$t('input.editor.bold')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-bold']"/>
<icon :icon="['fa', 'fa-bold']" />
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
v-tooltip="$t('input.editor.italic')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-italic']"/>
<icon :icon="['fa', 'fa-italic']" />
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleUnderline().run()"
:class="{ 'is-active': editor.isActive('underline') }"
v-tooltip="$t('input.editor.underline')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('underline') }"
@click="editor.chain().focus().toggleUnderline().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-underline']"/>
<icon :icon="['fa', 'fa-underline']" />
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
v-tooltip="$t('input.editor.strikethrough')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('strike') }"
@click="editor.chain().focus().toggleStrike().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-strikethrough']"/>
<icon :icon="['fa', 'fa-strikethrough']" />
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleCodeBlock().run()"
:class="{ 'is-active': editor.isActive('codeBlock') }"
v-tooltip="$t('input.editor.code')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('codeBlock') }"
@click="editor.chain().focus().toggleCodeBlock().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-code']"/>
<icon :icon="['fa', 'fa-code']" />
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleBlockquote().run()"
:class="{ 'is-active': editor.isActive('blockquote') }"
v-tooltip="$t('input.editor.quote')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('blockquote') }"
@click="editor.chain().focus().toggleBlockquote().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-quote-right']"/>
<icon :icon="['fa', 'fa-quote-right']" />
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleBulletList().run()"
:class="{ 'is-active': editor.isActive('bulletList') }"
v-tooltip="$t('input.editor.bulletList')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('bulletList') }"
@click="editor.chain().focus().toggleBulletList().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-list-ol']"/>
<icon :icon="['fa', 'fa-list-ul']" />
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleOrderedList().run()"
:class="{ 'is-active': editor.isActive('orderedList') }"
v-tooltip="$t('input.editor.orderedList')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('orderedList') }"
@click="editor.chain().focus().toggleOrderedList().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-list-ul']"/>
<icon :icon="['fa', 'fa-list-ol']" />
</span>
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleTaskList().run()"
:class="{ 'is-active': editor.isActive('taskList') }"
v-tooltip="$t('input.editor.taskList')"
>
<span class="icon">
<icon icon="fa-list-check"/>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="openImagePicker"
v-tooltip="$t('input.editor.image')"
>
<span class="icon">
<icon icon="fa-image"/>
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
class="editor-toolbar__button"
@click="setLink"
:class="{ 'is-active': editor.isActive('taskList') }"
@click="editor.chain().focus().toggleTaskList().run()"
>
<span class="icon">
<icon icon="fa-list-check" />
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
v-tooltip="$t('input.editor.image')"
class="editor-toolbar__button"
@click="openImagePicker"
>
<span class="icon">
<icon icon="fa-image" />
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
v-tooltip="$t('input.editor.link')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('link') }"
title="set link"
v-tooltip="$t('input.editor.link')"
@click="setLink"
>
<span class="icon">
<icon :icon="['fa', 'fa-link']"/>
<icon :icon="['fa', 'fa-link']" />
</span>
</BaseButton>
<BaseButton
v-tooltip="$t('input.editor.text')"
class="editor-toolbar__button"
@click="editor.chain().focus().setParagraph().run()"
:class="{ 'is-active': editor.isActive('paragraph') }"
title="paragraph"
v-tooltip="$t('input.editor.text')"
@click="editor.chain().focus().setParagraph().run()"
>
<span class="icon">
<icon :icon="['fa', 'fa-paragraph']"/>
<icon :icon="['fa', 'fa-paragraph']" />
</span>
</BaseButton>
<BaseButton
v-tooltip="$t('input.editor.horizontalRule')"
class="editor-toolbar__button"
@click="editor.chain().focus().setHorizontalRule().run()"
v-tooltip="$t('input.editor.horizontalRule')"
>
<span class="icon">
<icon :icon="['fa', 'fa-ruler-horizontal']"/>
<icon :icon="['fa', 'fa-ruler-horizontal']" />
</span>
</BaseButton>
</div>
<div class="editor-toolbar__segment">
<BaseButton
v-tooltip="$t('input.editor.undo')"
class="editor-toolbar__button"
@click="editor.chain().focus().undo().run()"
v-tooltip="$t('input.editor.undo')"
>
<span class="icon">
<icon :icon="['fa', 'fa-undo']"/>
<icon :icon="['fa', 'fa-undo']" />
</span>
</BaseButton>
<BaseButton
v-tooltip="$t('input.editor.redo')"
class="editor-toolbar__button"
@click="editor.chain().focus().redo().run()"
v-tooltip="$t('input.editor.redo')"
>
<span class="icon">
<icon :icon="['fa', 'fa-redo']"/>
<icon :icon="['fa', 'fa-redo']" />
</span>
</BaseButton>
</div>
@ -206,16 +206,19 @@
<div class="editor-toolbar__segment">
<!-- table -->
<BaseButton
class="editor-toolbar__button"
@click="toggleTableMode"
:class="{ 'is-active': editor.isActive('table') }"
v-tooltip="$t('input.editor.table.title')"
class="editor-toolbar__button"
:class="{ 'is-active': editor.isActive('table') }"
@click="toggleTableMode"
>
<span class="icon">
<icon :icon="['fa', 'fa-table']"/>
<icon :icon="['fa', 'fa-table']" />
</span>
</BaseButton>
<div v-if="tableMode" class="editor-toolbar__table-buttons">
<div
v-if="tableMode"
class="editor-toolbar__table-buttons"
>
<BaseButton
class="editor-toolbar__button"
@click="
@ -230,99 +233,99 @@
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().addColumnBefore().run()"
:disabled="!editor.can().addColumnBefore"
@click="editor.chain().focus().addColumnBefore().run()"
>
{{ $t('input.editor.table.addColumnBefore') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().addColumnAfter().run()"
:disabled="!editor.can().addColumnAfter"
@click="editor.chain().focus().addColumnAfter().run()"
>
{{ $t('input.editor.table.addColumnAfter') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().deleteColumn().run()"
:disabled="!editor.can().deleteColumn"
@click="editor.chain().focus().deleteColumn().run()"
>
{{ $t('input.editor.table.deleteColumn') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().addRowBefore().run()"
:disabled="!editor.can().addRowBefore"
@click="editor.chain().focus().addRowBefore().run()"
>
{{ $t('input.editor.table.addRowBefore') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().addRowAfter().run()"
:disabled="!editor.can().addRowAfter"
@click="editor.chain().focus().addRowAfter().run()"
>
{{ $t('input.editor.table.addRowAfter') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().deleteRow().run()"
:disabled="!editor.can().deleteRow"
@click="editor.chain().focus().deleteRow().run()"
>
{{ $t('input.editor.table.deleteRow') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().deleteTable().run()"
:disabled="!editor.can().deleteTable"
@click="editor.chain().focus().deleteTable().run()"
>
{{ $t('input.editor.table.deleteTable') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().mergeCells().run()"
:disabled="!editor.can().mergeCells"
@click="editor.chain().focus().mergeCells().run()"
>
{{ $t('input.editor.table.mergeCells') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().splitCell().run()"
:disabled="!editor.can().splitCell"
@click="editor.chain().focus().splitCell().run()"
>
{{ $t('input.editor.table.splitCell') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeaderColumn().run()"
:disabled="!editor.can().toggleHeaderColumn"
@click="editor.chain().focus().toggleHeaderColumn().run()"
>
{{ $t('input.editor.table.toggleHeaderColumn') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeaderRow().run()"
:disabled="!editor.can().toggleHeaderRow"
@click="editor.chain().focus().toggleHeaderRow().run()"
>
{{ $t('input.editor.table.toggleHeaderRow') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().toggleHeaderCell().run()"
:disabled="!editor.can().toggleHeaderCell"
@click="editor.chain().focus().toggleHeaderCell().run()"
>
{{ $t('input.editor.table.toggleHeaderCell') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().mergeOrSplit().run()"
:disabled="!editor.can().mergeOrSplit"
@click="editor.chain().focus().mergeOrSplit().run()"
>
{{ $t('input.editor.table.mergeOrSplit') }}
</BaseButton>
<BaseButton
class="editor-toolbar__button"
@click="editor.chain().focus().fixTables().run()"
:disabled="!editor.can().fixTables"
@click="editor.chain().focus().fixTables().run()"
>
{{ $t('input.editor.table.fixTables') }}
</BaseButton>
@ -336,6 +339,7 @@ import {ref} from 'vue'
import {Editor} from '@tiptap/vue-3'
import BaseButton from '@/components/base/BaseButton.vue'
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
const {
editor = null,
@ -353,29 +357,8 @@ function openImagePicker() {
document.getElementById('tiptap__image-upload').click()
}
function setLink() {
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
// cancelled
if (url === null) {
return
}
// empty
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
// update link
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({href: url, target: '_blank'})
.run()
function setLink(event) {
setLinkInEditor(event.target.getBoundingClientRect(), editor)
}
</script>

View File

@ -1,5 +1,8 @@
<template>
<div class="tiptap" ref="tiptapInstanceRef">
<div
ref="tiptapInstanceRef"
class="tiptap"
>
<EditorToolbar
v-if="editor && isEditing"
:editor="editor"
@ -11,108 +14,124 @@
class="editor-bubble__wrapper"
>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor.isActive('bold') }"
v-tooltip="$t('input.editor.bold')"
class="editor-bubble__button"
:class="{ 'is-active': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
>
<icon :icon="['fa', 'fa-bold']"/>
<icon :icon="['fa', 'fa-bold']" />
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleItalic().run()"
:class="{ 'is-active': editor.isActive('italic') }"
v-tooltip="$t('input.editor.italic')"
class="editor-bubble__button"
:class="{ 'is-active': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
>
<icon :icon="['fa', 'fa-italic']"/>
<icon :icon="['fa', 'fa-italic']" />
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleUnderline().run()"
:class="{ 'is-active': editor.isActive('underline') }"
v-tooltip="$t('input.editor.underline')"
class="editor-bubble__button"
:class="{ 'is-active': editor.isActive('underline') }"
@click="editor.chain().focus().toggleUnderline().run()"
>
<icon :icon="['fa', 'fa-underline']"/>
<icon :icon="['fa', 'fa-underline']" />
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleStrike().run()"
:class="{ 'is-active': editor.isActive('strike') }"
v-tooltip="$t('input.editor.strikethrough')"
class="editor-bubble__button"
:class="{ 'is-active': editor.isActive('strike') }"
@click="editor.chain().focus().toggleStrike().run()"
>
<icon :icon="['fa', 'fa-strikethrough']"/>
<icon :icon="['fa', 'fa-strikethrough']" />
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="editor.chain().focus().toggleCode().run()"
:class="{ 'is-active': editor.isActive('code') }"
v-tooltip="$t('input.editor.code')"
class="editor-bubble__button"
:class="{ 'is-active': editor.isActive('code') }"
@click="editor.chain().focus().toggleCode().run()"
>
<icon :icon="['fa', 'fa-code']"/>
<icon :icon="['fa', 'fa-code']" />
</BaseButton>
<BaseButton
class="editor-bubble__button"
@click="setLink"
:class="{ 'is-active': editor.isActive('link') }"
v-tooltip="$t('input.editor.link')"
class="editor-bubble__button"
:class="{ 'is-active': editor.isActive('link') }"
@click="setLink"
>
<icon :icon="['fa', 'fa-link']"/>
<icon :icon="['fa', 'fa-link']" />
</BaseButton>
</BubbleMenu>
<editor-content
<EditorContent
class="tiptap__editor"
:class="{'tiptap__editor-is-edit-enabled': isEditing}"
:editor="editor"
@click="focusIfEditing()"
/>
<input
v-if="isEditing"
type="file"
id="tiptap__image-upload"
class="is-hidden"
ref="uploadInputRef"
type="file"
class="is-hidden"
@change="addImage"
/>
>
<ul class="tiptap__editor-actions d-print-none" v-if="bottomActions.length === 0 && !isEditing">
<ul
v-if="bottomActions.length === 0 && !isEditing && isEditEnabled"
class="tiptap__editor-actions d-print-none"
>
<li>
<BaseButton
class="done-edit"
@click="setEdit"
class="done-edit">
>
{{ $t('input.editor.edit') }}
</BaseButton>
</li>
</ul>
<ul class="tiptap__editor-actions d-print-none" v-if="bottomActions.length > 0">
<ul
v-if="bottomActions.length > 0"
class="tiptap__editor-actions d-print-none"
>
<li v-if="isEditing && showSave">
<BaseButton
class="done-edit"
@click="bubbleSave"
class="done-edit">
>
{{ $t('misc.save') }}
</BaseButton>
</li>
<li v-if="!isEditing">
<BaseButton
class="done-edit"
@click="setEdit"
class="done-edit">
>
{{ $t('input.editor.edit') }}
</BaseButton>
</li>
<li v-for="(action, k) in bottomActions" :key="k">
<BaseButton @click="action.action">{{ action.title }}</BaseButton>
<li
v-for="(action, k) in bottomActions"
:key="k"
>
<BaseButton @click="action.action">
{{ action.title }}
</BaseButton>
</li>
</ul>
<x-button
<XButton
v-else-if="isEditing && showSave"
v-cy="'saveEditor'"
class="mt-4"
@click="bubbleSave"
variant="secondary"
:shadow="false"
v-cy="'saveEditor'"
:disabled="!contentHasChanged"
@click="bubbleSave"
>
{{ $t('misc.save') }}
</x-button>
</XButton>
</div>
</template>
@ -171,8 +190,29 @@ import XButton from '@/components/input/button.vue'
import {Placeholder} from '@tiptap/extension-placeholder'
import {eventToHotkeyString} from '@github/hotkey'
import {mergeAttributes} from '@tiptap/core'
import {createRandomID} from '@/helpers/randomId'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
import inputPrompt from '@/helpers/inputPrompt'
import {setLinkInEditor} from '@/components/input/editor/setLinkInEditor'
const {
modelValue,
uploadCallback,
isEditEnabled = true,
bottomActions = [],
showSave = false,
placeholder = '',
editShortcut = '',
} = defineProps<{
modelValue: string,
uploadCallback?: UploadCallback,
isEditEnabled?: boolean,
bottomActions?: BottomAction[],
showSave?: boolean,
placeholder?: string,
editShortcut?: string,
}>()
const emit = defineEmits(['update:modelValue', 'save'])
const tiptapInstanceRef = ref<HTMLInputElement | null>(null)
@ -227,19 +267,20 @@ const CustomImage = Image.extend({
renderHTML({HTMLAttributes}) {
if (HTMLAttributes.src?.startsWith(window.API_URL) || HTMLAttributes['data-src']?.startsWith(window.API_URL)) {
const imageUrl = HTMLAttributes['data-src'] ?? HTMLAttributes.src
const id = 'tiptap-image-' + createRandomID()
// The url is something like /tasks/<id>/attachments/<id>
const parts = imageUrl.slice(window.API_URL.length + 1).split('/')
const taskId = Number(parts[1])
const attachmentId = Number(parts[3])
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
const id = 'tiptap-image-' + cacheKey
nextTick(async () => {
const img = document.getElementById(id)
if (!img) return
// The url is something like /tasks/<id>/attachments/<id>
const parts = imageUrl.slice(window.API_URL.length + 1).split('/')
const taskId = Number(parts[1])
const attachmentId = Number(parts[3])
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
if (typeof loadedAttachments.value[cacheKey] === 'undefined') {
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
@ -266,31 +307,21 @@ const CustomImage = Image.extend({
type Mode = 'edit' | 'preview'
const {
modelValue,
uploadCallback,
isEditEnabled = true,
bottomActions = [],
showSave = false,
placeholder = '',
editShortcut = '',
} = defineProps<{
modelValue: string,
uploadCallback?: UploadCallback,
isEditEnabled?: boolean,
bottomActions?: BottomAction[],
showSave?: boolean,
placeholder?: string,
editShortcut?: string,
}>()
const emit = defineEmits(['update:modelValue', 'save'])
const internalMode = ref<Mode>('edit')
const internalMode = ref<Mode>('preview')
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
const contentHasChanged = ref<boolean>(false)
watch(
() => internalMode.value,
mode => {
if (mode === 'preview') {
contentHasChanged.value = false
}
},
)
const editor = useEditor({
content: modelValue,
// eslint-disable-next-line vue/no-ref-object-destructure
editable: isEditing.value,
extensions: [
// Starterkit:
@ -308,7 +339,9 @@ const editor = useEditor({
addKeyboardShortcuts() {
return {
'Mod-Enter': () => {
bubbleSave()
if (contentHasChanged.value) {
bubbleSave()
}
},
}
},
@ -420,6 +453,7 @@ function bubbleNow() {
return
}
contentHasChanged.value = true
emit('update:modelValue', editor.value?.getHTML())
}
@ -455,7 +489,7 @@ function uploadAndInsertFiles(files: File[] | FileList) {
})
}
function addImage() {
async function addImage(event) {
if (typeof uploadCallback !== 'undefined') {
const files = uploadInputRef.value?.files
@ -469,7 +503,7 @@ function addImage() {
return
}
const url = window.prompt('URL')
const url = await inputPrompt(event.target.getBoundingClientRect())
if (url) {
editor.value?.chain().focus().setImage({src: url}).run()
@ -477,34 +511,8 @@ function addImage() {
}
}
function setLink() {
const previousUrl = editor.value?.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
// cancelled
if (url === null) {
return
}
// empty
if (url === '') {
editor.value
?.chain()
.focus()
.extendMarkRange('link')
.unsetLink()
.run()
return
}
// update link
editor.value
?.chain()
.focus()
.extendMarkRange('link')
.setLink({href: url, target: '_blank'})
.run()
function setLink(event) {
setLinkInEditor(event.target.getBoundingClientRect(), editor.value)
}
onMounted(async () => {
@ -558,6 +566,7 @@ function setFocusToEditor(event) {
event.target.contentEditable === 'true') {
return
}
event.preventDefault()
if (!isEditing.value && isEditEnabled) {
@ -567,6 +576,12 @@ function setFocusToEditor(event) {
editor.value?.commands.focus()
}
function focusIfEditing() {
if (isEditing.value) {
editor.value?.commands.focus()
}
}
function clickTasklistCheckbox(event) {
event.stopImmediatePropagation()
@ -671,36 +686,17 @@ watch(
line-height: 1.1;
}
a {
color: #68cef8;
}
code {
background-color: rgba(#616161, 0.1);
color: #616161;
background-color: var(--grey-200);
color: var(--grey-700);
}
pre {
background: #0d0d0d;
color: #fff;
background: var(--grey-200);
color: var(--grey-700);
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
}
pre {
background: #0d0d0d;
color: #fff;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border-radius: $radius;
code {
color: inherit;
@ -711,7 +707,7 @@ watch(
.hljs-comment,
.hljs-quote {
color: #616161;
color: var(--grey-500);
}
.hljs-variable,
@ -724,7 +720,7 @@ watch(
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f98181;
color: var(--code-variable);
}
.hljs-number,
@ -734,23 +730,23 @@ watch(
.hljs-literal,
.hljs-type,
.hljs-params {
color: #fbbc88;
color: var(--code-literal);
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #b9f18d;
color: var(--code-symbol);
}
.hljs-title,
.hljs-section {
color: #faf594;
color: var(--code-section);
}
.hljs-keyword,
.hljs-selector-tag {
color: #70cff8;
color: var(--code-keyword);
}
.hljs-emphasis {
@ -767,7 +763,7 @@ watch(
height: auto;
&.ProseMirror-selectednode {
outline: 3px solid #68cef8;
outline: 3px solid var(--primary);
}
}

View File

@ -0,0 +1,26 @@
import inputPrompt from '@/helpers/inputPrompt'
export async function setLinkInEditor(pos, editor) {
const previousUrl = editor?.getAttributes('link').href || ''
const url = await inputPrompt(pos, previousUrl)
// empty
if (url === '') {
editor
?.chain()
.focus()
.extendMarkRange('link')
.unsetLink()
.run()
return
}
// update link
editor
?.chain()
.focus()
.extendMarkRange('link')
.setLink({href: url, target: '_blank'})
.run()
}

View File

@ -26,32 +26,42 @@ const withoutInitialState = ref<boolean | undefined>()
</FancyCheckbox>
Visualisation
<input type="checkbox" v-model="isChecked">
<input
v-model="isChecked"
type="checkbox"
>
{{ isChecked }}
</Variant>
<Variant title="Enabled Initially">
<FancyCheckbox
:disabled="isDisabled"
v-model="isCheckedInitiallyEnabled"
:disabled="isDisabled"
>
We want you to use this option
</FancyCheckbox>
Visualisation
<input type="checkbox" v-model="isCheckedInitiallyEnabled">
<input
v-model="isCheckedInitiallyEnabled"
type="checkbox"
>
{{ isCheckedInitiallyEnabled }}
</Variant>
<Variant title="Disabled">
<FancyCheckbox
disabled
:modelValue="isCheckedDisabled"
@update:model-value="logEvent('Setting disabled: This should never happen', $event)"
:model-value="isCheckedDisabled"
@update:modelValue="logEvent('Setting disabled: This should never happen', $event)"
>
You can't change this
</FancyCheckbox>
Visualisation
<input type="checkbox" v-model="isCheckedDisabled" disabled>
<input
v-model="isCheckedDisabled"
type="checkbox"
disabled
>
{{ isCheckedDisabled }}
</Variant>
@ -64,7 +74,11 @@ const withoutInitialState = ref<boolean | undefined>()
</FancyCheckbox>
Visualisation
<input type="checkbox" v-model="withoutInitialState" disabled>
<input
v-model="withoutInitialState"
type="checkbox"
disabled
>
{{ withoutInitialState }}
</Variant>
</Story>

View File

@ -7,11 +7,14 @@
}"
:disabled="disabled"
:model-value="modelValue"
@update:model-value="value => emit('update:modelValue', value)"
@update:modelValue="value => emit('update:modelValue', value)"
>
<CheckboxIcon class="fancycheckbox__icon" />
<span v-if="$slots.default" class="fancycheckbox__content">
<slot/>
<span
v-if="$slots.default"
class="fancycheckbox__content"
>
<slot />
</span>
</BaseCheckbox>
</template>

View File

@ -1,12 +1,15 @@
<template>
<div
ref="multiselectRoot"
class="multiselect"
:class="{'has-search-results': searchResultsVisible}"
ref="multiselectRoot"
tabindex="-1"
@focus="focus"
>
<div class="control" :class="{'is-loading': loading || localLoading}">
<div
class="control"
:class="{'is-loading': loading || localLoading}"
>
<div
class="input-wrapper input"
:class="{'has-multiple': hasMultiple, 'has-removal-button': removalAvailable}"
@ -18,51 +21,67 @@
:remove="remove"
>
<template v-for="(item, key) in internalValue">
<slot name="tag" :item="item">
<span :key="`item${key}`" class="tag ml-2 mt-2">
<slot
name="tag"
:item="item"
>
<span
:key="`item${key}`"
class="tag ml-2 mt-2"
>
{{ label !== '' ? item[label] : item }}
<BaseButton @click="() => remove(item)" class="delete is-small"></BaseButton>
<BaseButton
class="delete is-small"
@click="() => remove(item)"
/>
</span>
</slot>
</template>
</slot>
<input
ref="searchInput"
v-model="query"
type="text"
class="input"
v-model="query"
@keyup="search"
@keyup.enter.exact.prevent="() => createOrSelectOnEnter()"
:placeholder="placeholder"
@keydown.down.exact.prevent="() => preSelect(0)"
ref="searchInput"
@focus="handleFocus"
:autocomplete="autocompleteEnabled ? undefined : 'off'"
:spellcheck="autocompleteEnabled ? undefined : 'false'"
/>
@keyup="search"
@keyup.enter.exact.prevent="() => createOrSelectOnEnter()"
@keydown.down.exact.prevent="() => preSelect(0)"
@focus="handleFocus"
>
<BaseButton
v-if="removalAvailable"
class="removal-button"
@click="resetSelectedValue"
>
<icon icon="times"/>
<icon icon="times" />
</BaseButton>
</div>
</div>
<CustomTransition name="fade">
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<div
v-if="searchResultsVisible"
class="search-results"
:class="{'search-results-inline': inline}"
>
<BaseButton
class="search-result-button is-fullwidth"
v-for="(data, index) in filteredSearchResults"
:key="index"
:ref="(el) => setResult(el, index)"
class="search-result-button is-fullwidth"
@keydown.up.prevent="() => preSelect(index - 1)"
@keydown.down.prevent="() => preSelect(index + 1)"
@click.prevent.stop="() => select(data)"
>
<span>
<slot name="searchResult" :option="data">
<slot
name="searchResult"
:option="data"
>
<span class="search-result">{{ label !== '' ? data[label] : data }}</span>
</slot>
</span>
@ -73,15 +92,18 @@
<BaseButton
v-if="creatableAvailable"
class="search-result-button is-fullwidth"
:ref="(el) => setResult(el, filteredSearchResults.length)"
class="search-result-button is-fullwidth"
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
@keyup.enter.prevent="create"
@click.prevent.stop="create"
>
<span>
<slot name="searchResult" :option="query">
<slot
name="searchResult"
:option="query"
>
<span class="search-result">
{{ query }}
</span>
@ -107,16 +129,6 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function elementInResults(elem: string | any, label: string, query: string): boolean {
// Don't make create available if we have an exact match in our search results.
if (label !== '') {
return elem[label] === query
}
return elem === query
}
const props = defineProps({
/**
* When true, shows a loading spinner
@ -245,6 +257,16 @@ const emit = defineEmits<{
(e: 'remove', value: null): void
}>()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function elementInResults(elem: string | any, label: string, query: string): boolean {
// Don't make create available if we have an exact match in our search results.
if (label !== '') {
return elem[label] === query
}
return elem === query
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const query = ref<string | { [key: string]: any }>('')
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)

View File

@ -1,27 +1,31 @@
<template>
<div class="password-field">
<input
class="input"
id="password"
class="input"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
:type="passwordFieldType"
autocomplete="current-password"
@keyup.enter="e => $emit('submit', e)"
:tabindex="props.tabindex"
@keyup.enter="e => $emit('submit', e)"
@focusout="validate"
@input="handleInput"
/>
>
<BaseButton
@click="togglePasswordFieldType"
v-tooltip="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')"
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'"/>
@click="togglePasswordFieldType"
>
<icon :icon="passwordFieldType === 'password' ? 'eye' : 'eye-slash'" />
</BaseButton>
</div>
<p class="help is-danger" v-if="!isValid">
<p
v-if="!isValid"
class="help is-danger"
>
{{ $t('user.auth.passwordRequired') }}
</p>
</template>

View File

@ -1,5 +1,7 @@
<template>
<BaseButton class="button-link"><slot/></BaseButton>
<BaseButton class="button-link">
<slot />
</BaseButton>
</template>
<script setup lang="ts">

View File

@ -1,5 +1,5 @@
<template>
<div
<div
v-if="isDone"
class="is-done"
:class="{ 'is-done--small': variant === 'small' }"

View File

@ -31,10 +31,10 @@ function openQuickActions() {
<template>
<BaseButton
@click="openQuickActions"
class="trigger-button"
:title="$t('keyboardShortcuts.quickSearch')"
@click="openQuickActions"
>
<icon icon="search"/>
<icon icon="search" />
</BaseButton>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import {ref} from 'vue'
import ProgressBar from './ProgressBar.vue'
const value = ref(50)
</script>
<template>
<Story>
<Variant title="Default">
<ProgressBar :value="value" />
</Variant>
</Story>
</template>

View File

@ -0,0 +1,139 @@
<template>
<progress
class="progress-bar"
:class="{
'is-small': isSmall,
'is-primary': isPrimary,
}"
:value="value"
max="100"
>
{{ value }}%
</progress>
</template>
<script setup lang="ts">
import {defineProps} from 'vue'
defineProps({
value: {
type: Number,
required: true,
},
isSmall: {
type: Boolean,
default: false,
},
isPrimary: {
type: Boolean,
required: false,
},
})
</script>
<style lang="scss" scoped>
.progress-bar {
--progress-height: #{$size-normal};
--progress-bar-background-color: var(--border-light, #{$border-light});
--progress-value-background-color: var(--grey-500, #{$text});
--progress-border-radius: #{$radius};
--progress-indeterminate-duration: 1.5s;
appearance: none;
border: none;
border-radius: var(--progress-border-radius);
height: var(--progress-height);
overflow: hidden;
padding: 0;
min-width: 6vw;
width: 50px;
margin: 0 .5rem 0 0;
flex: 3 1 auto;
&::-moz-progress-bar,
&::-webkit-progress-value {
background: var(--progress-value-background-color);
}
@media screen and (max-width: $tablet) {
margin: 0.5rem 0 0 0;
order: 1;
width: 100%;
}
&::-webkit-progress-bar {
background-color: var(--progress-bar-background-color);
}
&::-webkit-progress-value {
background-color: var(--progress-value-background-color);
}
&::-moz-progress-bar {
background-color: var(--progress-value-background-color);
}
&::-ms-fill {
background-color: var(--progress-value-background-color);
border: none;
}
// Colors
@each $name, $pair in $colors {
$color: nth($pair, 1);
&.is-#{$name} {
--progress-value-background-color: var(--#{$name}, #{$color});
&:indeterminate {
background-image: linear-gradient(
to right,
var(--#{$name}, #{$color}) 30%,
var(--progress-bar-background-color) 30%
);
}
}
}
&:indeterminate {
animation-duration: var(--progress-indeterminate-duration);
animation-iteration-count: infinite;
animation-name: moveIndeterminate;
animation-timing-function: linear;
background-color: var(--progress-bar-background-color);
background-image: linear-gradient(
to right,
var(--text, #{$text}) 30%,
var(--progress-bar-background-color) 30%
);
background-position: top left;
background-repeat: no-repeat;
background-size: 150% 150%;
&::-webkit-progress-bar {
background-color: transparent;
}
&::-moz-progress-bar {
background-color: transparent;
}
&::-ms-fill {
animation-name: none;
}
}
&.is-small {
--progress-height: #{$size-small};
}
}
@keyframes moveIndeterminate {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
</style>

View File

@ -1,38 +1,62 @@
<template>
<div class="api-config">
<div v-if="configureApi">
<label class="label" for="api-url">{{ $t('apiConfig.url') }}</label>
<label
class="label"
for="api-url"
>{{ $t('apiConfig.url') }}</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input"
id="api-url"
v-model="apiUrl"
v-focus
class="input"
:placeholder="$t('apiConfig.urlPlaceholder')"
required
type="url"
v-focus
v-model="apiUrl"
@keyup.enter="setApiUrl"
/>
>
</div>
<div class="control">
<x-button @click="setApiUrl" :disabled="apiUrl === '' || undefined">
<x-button
:disabled="apiUrl === '' || undefined"
@click="setApiUrl"
>
{{ $t('apiConfig.change') }}
</x-button>
</div>
</div>
</div>
<div class="api-url-info" v-else>
<i18n-t keypath="apiConfig.use" scope="global">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
<div
v-else
class="api-url-info"
>
<i18n-t
keypath="apiConfig.use"
scope="global"
>
<span
v-tooltip="apiUrl"
class="url"
> {{ apiDomain }} </span>
</i18n-t>
<br/>
<ButtonLink class="api-config__change-button" @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</ButtonLink>
<br>
<ButtonLink
class="api-config__change-button"
@click="() => (configureApi = true)"
>
{{ $t('apiConfig.change') }}
</ButtonLink>
</div>
<message variant="danger" v-if="errorMsg !== ''" class="mt-2">
<Message
v-if="errorMsg !== ''"
variant="danger"
class="mt-2"
>
{{ errorMsg }}
</message>
</Message>
</div>
</template>
@ -57,7 +81,7 @@ const props = defineProps({
const emit = defineEmits(['foundApi'])
const apiUrl = ref(window.API_URL)
const configureApi = ref(apiUrl.value === '')
const configureApi = ref(window.API_URL === '')
// 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.

View File

@ -1,18 +1,24 @@
<template>
<div class="card" :class="{'has-no-shadow': !shadow}">
<header class="card-header" v-if="title !== ''">
<div
class="card"
:class="{'has-no-shadow': !shadow}"
>
<header
v-if="title !== ''"
class="card-header"
>
<p class="card-header-title">
{{ title }}
</p>
<BaseButton
v-if="hasClose"
v-tooltip="$t('misc.close')"
class="card-header-icon"
:aria-label="$t('misc.close')"
@click="$emit('close')"
v-tooltip="$t('misc.close')"
>
<span class="icon">
<icon :icon="closeIcon"/>
<icon :icon="closeIcon" />
</span>
</BaseButton>
</header>
@ -24,12 +30,15 @@
}"
>
<div :class="{'content': hasContent}">
<slot/>
<slot />
</div>
</div>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer"/>
<footer
v-if="$slots.footer"
class="card-footer"
>
<slot name="footer" />
</footer>
</div>
</template>

View File

@ -2,7 +2,7 @@
<span
:style="{backgroundColor: color }"
class="color-bubble"
></span>
/>
</template>
<script lang="ts" setup>

View File

@ -1,16 +1,20 @@
<template>
<modal @close="$router.back()" :overflow="true" :wide="wide">
<modal
:overflow="true"
:wide="wide"
@close="$router.back()"
>
<card
:title="title"
:shadow="false"
:padding="false"
class="has-text-left"
:has-close="true"
@close="$router.back()"
:loading="loading"
@close="$router.back()"
>
<div class="p-4">
<slot/>
<slot />
</div>
<template #footer>
@ -32,10 +36,10 @@
<x-button
v-if="hasPrimaryAction"
variant="primary"
@click.prevent.stop="primary()"
:icon="primaryIcon"
:disabled="primaryDisabled || loading"
class="ml-2"
@click.prevent.stop="primary()"
>
{{ primaryLabel || $t('misc.create') }}
</x-button>

View File

@ -5,10 +5,10 @@
class="icon is-small"
:class="iconClass"
>
<Icon :icon="icon"/>
<Icon :icon="icon" />
</span>
<span>
<slot/>
<slot />
</span>
</BaseButton>
</template>

View File

@ -1,15 +1,32 @@
<template>
<div class="dropdown" ref="dropdown">
<slot name="trigger" :close="close" :toggleOpen="toggleOpen" :open="open">
<BaseButton class="dropdown-trigger is-flex" @click="toggleOpen">
<icon :icon="triggerIcon" class="icon"/>
<div
ref="dropdown"
class="dropdown"
>
<slot
name="trigger"
:close="close"
:toggle-open="toggleOpen"
:open="open"
>
<BaseButton
class="dropdown-trigger is-flex"
@click="toggleOpen"
>
<icon
:icon="triggerIcon"
class="icon"
/>
</BaseButton>
</slot>
<CustomTransition name="fade">
<div class="dropdown-menu" v-if="open">
<div
v-if="open"
class="dropdown-menu"
>
<div class="dropdown-content">
<slot :close="close"></slot>
<slot :close="close" />
</div>
</div>
</CustomTransition>

View File

@ -1,10 +1,17 @@
<template>
<message variant="danger">
<i18n-t keypath="loadingError.failed" scope="global">
<ButtonLink @click="reload">{{ $t('loadingError.tryAgain') }}</ButtonLink>
<ButtonLink href="https://vikunja.io/contact/">{{ $t('loadingError.contact') }}</ButtonLink>
<Message variant="danger">
<i18n-t
keypath="loadingError.failed"
scope="global"
>
<ButtonLink @click="reload">
{{ $t('loadingError.tryAgain') }}
</ButtonLink>
<ButtonLink href="https://vikunja.io/contact/">
{{ $t('loadingError.contact') }}
</ButtonLink>
</i18n-t>
</message>
</Message>
</template>
<script lang="ts">

View File

@ -1,11 +1,11 @@
<template>
<input
v-bind="attrs"
ref="root"
type="text"
data-input
:disabled="disabled"
v-bind="attrs"
ref="root"
/>
>
</template>
<script lang="ts">

View File

@ -1,10 +1,20 @@
<template>
<modal @close="close()">
<card class="has-background-white keyboard-shortcuts" :shadow="false" :title="$t('keyboardShortcuts.title')">
<template v-for="(s, i) in shortcuts" :key="i">
<card
class="has-background-white keyboard-shortcuts"
:shadow="false"
:title="$t('keyboardShortcuts.title')"
>
<template
v-for="(s, i) in shortcuts"
:key="i"
>
<h3>{{ $t(s.title) }}</h3>
<message class="mb-4" v-if="s.available">
<Message
v-if="s.available"
class="mb-4"
>
{{
typeof s.available === 'undefined' ?
$t('keyboardShortcuts.allPages') :
@ -14,14 +24,19 @@
: $t('keyboardShortcuts.somePagesOnly')
)
}}
</message>
</Message>
<dl class="shortcut-list">
<template v-for="(sc, si) in s.shortcuts" :key="si">
<dt class="shortcut-title">{{ $t(sc.title) }}</dt>
<shortcut
class="shortcut-keys"
<template
v-for="(sc, si) in s.shortcuts"
:key="si"
>
<dt class="shortcut-title">
{{ $t(sc.title) }}
</dt>
<Shortcut
is="dd"
class="shortcut-keys"
:keys="sc.keys"
:combination="sc.combination && $t(`keyboardShortcuts.${sc.combination}`)"
/>

View File

@ -1,8 +1,18 @@
<template>
<div class="legal-links">
<BaseButton :href="imprintUrl" v-if="imprintUrl">{{ $t('navigation.imprint') }}</BaseButton>
<BaseButton
v-if="imprintUrl"
:href="imprintUrl"
>
{{ $t('navigation.imprint') }}
</BaseButton>
<span v-if="imprintUrl && privacyPolicyUrl"> | </span>
<BaseButton :href="privacyPolicyUrl" v-if="privacyPolicyUrl">{{ $t('navigation.privacy') }}</BaseButton>
<BaseButton
v-if="privacyPolicyUrl"
:href="privacyPolicyUrl"
>
{{ $t('navigation.privacy') }}
</BaseButton>
</div>
</template>

View File

@ -1,5 +1,8 @@
<template>
<div class="loader-container is-loading" :class="{'is-small': variant === 'small'}"></div>
<div
class="loader-container is-loading"
:class="{'is-small': variant === 'small'}"
/>
</template>
<script lang="ts">

View File

@ -1,7 +1,10 @@
<template>
<div class="message-wrapper">
<div class="message" :class="[variant, textAlignClass]">
<slot/>
<div
class="message"
:class="[variant, textAlignClass]"
>
<slot />
</div>
</div>
</template>
@ -9,14 +12,6 @@
<script lang="ts" setup>
import {computed, type 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,
@ -28,6 +23,14 @@ const props = defineProps({
},
})
const TEXT_ALIGN_MAP = Object.freeze({
left: '',
center: 'has-text-centered',
right: 'has-text-right',
})
type textAlignVariants = keyof typeof TEXT_ALIGN_MAP
const textAlignClass = computed(() => TEXT_ALIGN_MAP[props.textAlign])
</script>

View File

@ -1,56 +1,59 @@
<template>
<Teleport to="body">
<!-- FIXME: transition should not be included in the modal -->
<CustomTransition :name="transitionName" appear>
<CustomTransition
:name="transitionName"
appear
>
<section
v-if="enabled"
ref="modal"
class="modal-mask"
:class="[
{ 'has-overflow': overflow },
variant,
]"
ref="modal"
v-bind="attrs"
>
<div
v-shortcut="'Escape'"
class="modal-container"
@mousedown.self.prevent.stop="$emit('close')"
v-shortcut="'Escape'"
>
<div
class="modal-content"
:class="{
'has-overflow': overflow,
'is-wide': wide
}"
'has-overflow': overflow,
'is-wide': wide
}"
>
<BaseButton
@click="$emit('close')"
class="close"
@click="$emit('close')"
>
<icon icon="times"/>
<icon icon="times" />
</BaseButton>
<slot>
<div class="header">
<slot name="header"></slot>
<slot name="header" />
</div>
<div class="content">
<slot name="text"></slot>
<slot name="text" />
</div>
<div class="actions">
<x-button
@click="$emit('close')"
variant="tertiary"
class="has-text-danger"
@click="$emit('close')"
>
{{ $t('misc.cancel') }}
</x-button>
<x-button
@click="$emit('submit')"
variant="primary"
v-cy="'modalPrimary'"
variant="primary"
:shadow="false"
@click="$emit('submit')"
>
{{ $t('misc.doit') }}
</x-button>

View File

@ -1,8 +1,15 @@
<template>
<div class="no-auth-wrapper">
<Logo class="logo" width="200" height="58"/>
<Logo
class="logo"
width="200"
height="58"
/>
<div class="noauth-container">
<section class="image" :class="{'has-message': motd !== ''}">
<section
class="image"
:class="{'has-message': motd !== ''}"
>
<Message v-if="motd !== ''">
{{ motd }}
</Message>
@ -12,14 +19,22 @@
</section>
<section class="content">
<div>
<h2 class="title" v-if="title">{{ title }}</h2>
<api-config v-if="showApiConfig"/>
<Message v-if="motd !== ''" class="is-hidden-tablet mb-4">
<h2
v-if="title"
class="title"
>
{{ title }}
</h2>
<ApiConfig v-if="showApiConfig" />
<Message
v-if="motd !== ''"
class="is-hidden-tablet mb-4"
>
{{ motd }}
</Message>
<slot/>
<slot />
</div>
<legal/>
<Legal />
</section>
</div>
</div>
@ -38,6 +53,11 @@ import ApiConfig from '@/components/misc/api-config.vue'
import {useTitle} from '@/composables/useTitle'
import {useConfigStore} from '@/stores/config'
const {
showApiConfig = true,
} = defineProps<{
showApiConfig?: boolean
}>()
const configStore = useConfigStore()
const motd = computed(() => configStore.motd)
@ -46,11 +66,6 @@ const {t} = useI18n({useScope: 'global'})
const title = computed(() => t(route.meta?.title as string || ''))
useTitle(() => title.value)
const {
showApiConfig = true,
} = defineProps<{
showApiConfig?: boolean
}>()
</script>
<style lang="scss" scoped>

View File

@ -1,5 +1,5 @@
<template>
<p class="has-text-centered has-text-grey is-italic p-4 mb-4">
<slot></slot>
<slot />
</p>
</template>

View File

@ -1,30 +1,43 @@
<template>
<notifications position="bottom left" :max="2" class="global-notification">
<notifications
position="bottom left"
:max="2"
class="global-notification"
>
<template #body="{ item, close }">
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
<div
class="vue-notification-template vue-notification"
:class="[
'vue-notification-template',
'vue-notification',
item.type,
]"
@click="close()"
>
<div v-if="item.title" class="notification-title">{{ item.title }}</div>
<div
v-if="item.title"
class="notification-title"
>
{{ item.title }}
</div>
<div class="notification-content">
<template v-for="(t, k) in item.text" :key="k">{{ t }}<br /></template>
<template
v-for="(t, k) in item.text"
:key="k"
>
{{ t }}<br>
</template>
</div>
<div
class="buttons is-right"
v-if="item.data?.actions?.length > 0"
class="buttons is-right"
>
<x-button
v-for="(action, i) in item.data.actions"
:key="'action_' + i"
@click="action.callback"
:shadow="false"
class="is-small"
variant="secondary"
v-for="(action, i) in item.data.actions"
@click="action.callback"
>
{{ action.title }}
</x-button>

View File

@ -1,25 +1,33 @@
<template>
<nav
v-if="totalPages > 1"
aria-label="pagination"
class="pagination is-centered p-4"
role="navigation"
v-if="totalPages > 1"
>
<router-link
:disabled="currentPage === 1 || undefined"
:to="getRouteForPagination(currentPage - 1)"
class="pagination-previous">
class="pagination-previous"
>
{{ $t('misc.previous') }}
</router-link>
<router-link
:disabled="currentPage === totalPages || undefined"
:to="getRouteForPagination(currentPage + 1)"
class="pagination-next">
class="pagination-next"
>
{{ $t('misc.next') }}
</router-link>
<ul class="pagination-list">
<li :key="`page-${i}`" v-for="(p, i) in pages">
<span class="pagination-ellipsis" v-if="p.isEllipsis">&hellip;</span>
<li
v-for="(p, i) in pages"
:key="`page-${i}`"
>
<span
v-if="p.isEllipsis"
class="pagination-ellipsis"
>&hellip;</span>
<router-link
v-else
class="pagination-link"
@ -37,6 +45,17 @@
<script lang="ts" setup>
import {computed} from 'vue'
const props = defineProps({
totalPages: {
type: Number,
required: true,
},
currentPage: {
type: Number,
default: 0,
},
})
function createPagination(totalPages: number, currentPage: number) {
const pages = []
for (let i = 0; i < totalPages; i++) {
@ -81,17 +100,6 @@ function getRouteForPagination(page = 1, type = null) {
}
}
const props = defineProps({
totalPages: {
type: Number,
required: true,
},
currentPage: {
type: Number,
default: 0,
},
})
const pages = computed(() => createPagination(props.totalPages, props.currentPage))
</script>

View File

@ -1,14 +1,24 @@
<template>
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
<slot
name="trigger"
:is-open="open"
:toggle="toggle"
:close="close"
/>
<div
ref="popup"
class="popup"
:class="{
'is-open': open,
'has-overflow': props.hasOverflow && open
}"
ref="popup"
>
<slot name="content" :isOpen="open" :toggle="toggle"/>
<slot
name="content"
:is-open="open"
:toggle="toggle"
:close="close"
/>
</div>
</template>
@ -53,6 +63,7 @@ onClickOutside(popup, () => {
overflow: hidden;
position: absolute;
top: 1rem;
z-index: 100;
&.is-open {
opacity: 1;

View File

@ -1,37 +1,55 @@
<template>
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
<div class="offline" style="height: 0;width: 0;"></div>
<div class="app offline" v-if="!online">
<div
class="offline"
style="height: 0;width: 0;"
/>
<div
v-if="!online"
class="app offline"
>
<div class="offline-message">
<h1 class="title">{{ $t('offline.title') }}</h1>
<h1 class="title">
{{ $t('offline.title') }}
</h1>
<p>{{ $t('offline.text') }}</p>
</div>
</div>
<template v-else-if="ready">
<slot/>
<slot />
</template>
<section v-else-if="error !== ''">
<no-auth-wrapper :show-api-config="false">
<NoAuthWrapper :show-api-config="false">
<p v-if="error === ERROR_NO_API_URL">
{{ $t('ready.noApiUrlConfigured') }}
</p>
<message variant="danger" v-else class="mb-4">
<Message
v-else
variant="danger"
class="mb-4"
>
<p>
{{ $t('ready.errorOccured') }}<br/>
{{ $t('ready.errorOccured') }}<br>
{{ error }}
</p>
<p>
{{ $t('ready.checkApiUrl') }}
</p>
</message>
<api-config :configure-open="true" @found-api="load"/>
</no-auth-wrapper>
</Message>
<ApiConfig
:configure-open="true"
@foundApi="load"
/>
</NoAuthWrapper>
</section>
<CustomTransition name="fade">
<section class="vikunja-loading" v-if="showLoading">
<Logo class="logo"/>
<section
v-if="showLoading"
class="vikunja-loading"
>
<Logo class="logo" />
<p>
<span class="loader-container is-loading-small is-loading"></span>
<span class="loader-container is-loading-small is-loading" />
{{ $t('ready.loading') }}
</p>
</section>

View File

@ -1,6 +1,12 @@
<template>
<component :is="is" class="shortcuts">
<template v-for="(k, i) in keys" :key="i">
<component
:is="is"
class="shortcuts"
>
<template
v-for="(k, i) in keys"
:key="i"
>
<kbd>{{ k }}</kbd>
<span v-if="i < keys.length - 1">{{ combination }}</span>
</template>

View File

@ -1,32 +1,32 @@
<template>
<x-button
v-if="type === 'button'"
v-tooltip="tooltipText"
variant="secondary"
:icon="iconName"
v-tooltip="tooltipText"
@click="changeSubscription"
:disabled="disabled"
@click="changeSubscription"
>
{{ buttonText }}
</x-button>
<DropdownItem
v-else-if="type === 'dropdown'"
v-tooltip="tooltipText"
@click="changeSubscription"
:disabled="disabled"
:icon="iconName"
@click="changeSubscription"
>
{{ buttonText }}
</DropdownItem>
<BaseButton
v-else
v-tooltip="tooltipText"
@click="changeSubscription"
:class="{'is-disabled': disabled}"
:disabled="disabled"
@click="changeSubscription"
>
<span class="icon">
<icon :icon="iconName"/>
<icon :icon="iconName" />
</span>
{{ buttonText }}
</BaseButton>
@ -63,10 +63,10 @@ const props = defineProps({
},
})
const subscriptionEntity = computed<string | null>(() => props.modelValue?.entity ?? null)
const emit = defineEmits(['update:modelValue'])
const subscriptionEntity = computed<string | null>(() => props.modelValue?.entity ?? null)
const subscriptionService = shallowRef(new SubscriptionService())
const {t} = useI18n({useScope: 'global'})

View File

@ -4,14 +4,17 @@
:class="{'is-inline': isInline}"
>
<img
v-tooltip="displayName"
:height="avatarSize"
:src="getAvatarUrl(user, avatarSize)"
:width="avatarSize"
:alt="'Avatar of ' + displayName"
class="avatar"
v-tooltip="displayName"
/>
<span class="username" v-if="showUsername">{{ displayName }}</span>
>
<span
v-if="showUsername"
class="username"
>{{ displayName }}</span>
</div>
</template>

View File

@ -1,51 +1,80 @@
<template>
<div class="notifications">
<slot name="trigger" toggleOpen="() => showNotifications = !showNotifications" :has-unread-notifications="unreadNotifications > 0">
<BaseButton class="trigger-button" @click.stop="showNotifications = !showNotifications">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
<slot
name="trigger"
toggle-open="() => showNotifications = !showNotifications"
:has-unread-notifications="unreadNotifications > 0"
>
<BaseButton
class="trigger-button"
@click.stop="showNotifications = !showNotifications"
>
<span
v-if="unreadNotifications > 0"
class="unread-indicator"
/>
<icon icon="bell" />
</BaseButton>
</slot>
<CustomTransition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<div
v-if="showNotifications"
ref="popup"
class="notifications-list"
>
<span class="head">{{ $t('notification.title') }}</span>
<div
v-for="(n, index) in notifications"
:key="n.id"
class="single-notification"
>
<div class="read-indicator" :class="{'read': n.readAt !== null}"></div>
<user
<div
class="read-indicator"
:class="{'read': n.readAt !== null}"
/>
<User
v-if="n.notification.doer"
:user="n.notification.doer"
:show-username="false"
:avatar-size="16"
v-if="n.notification.doer"
/>
<div class="detail">
<div>
<span class="has-text-weight-bold mr-1" v-if="n.notification.doer">
<span
v-if="n.notification.doer"
class="has-text-weight-bold mr-1"
>
{{ getDisplayName(n.notification.doer) }}
</span>
<BaseButton @click="() => to(n, index)()">
<BaseButton
class="has-text-left"
@click="() => to(n, index)()"
>
{{ n.toText(userInfo) }}
</BaseButton>
</div>
<span class="created" v-tooltip="formatDateLong(n.created)">
<span
v-tooltip="formatDateLong(n.created)"
class="created"
>
{{ formatDateSince(n.created) }}
</span>
</div>
</div>
<x-button
<XButton
v-if="notifications.length > 0 && unreadNotifications > 0"
variant="tertiary"
class="mt-2 is-fullwidth"
@click="markAllRead"
variant="tertiary"
class="mt-2 is-fullwidth"
>
{{ $t('notification.markAllRead') }}
</x-button>
<p class="nothing" v-if="notifications.length === 0">
{{ $t('notification.none') }}<br/>
</XButton>
<p
v-if="notifications.length === 0"
class="nothing"
>
{{ $t('notification.none') }}<br>
<span class="explainer">
{{ $t('notification.explainer') }}
</span>
@ -124,6 +153,7 @@ function to(n, index) {
switch (n.name) {
case names.TASK_COMMENT:
case names.TASK_ASSIGNED:
case names.TASK_REMINDER:
to.name = 'task.detail'
to.params.id = n.notification.task.id
break
@ -155,6 +185,8 @@ async function markAllRead() {
const notificationService = new NotificationService()
await notificationService.markAllRead()
success({message: t('notification.markAllReadSuccess')})
notifications.value.forEach(n => n.readAt = new Date())
}
</script>
@ -221,7 +253,8 @@ async function markAllRead() {
height: .35rem;
background: var(--primary);
border-radius: 100%;
margin-left: .5rem;
margin: 0 .5rem;
flex-shrink: 0;
&.read {
background: transparent;

View File

@ -14,7 +14,7 @@
:title="$t('keyboardShortcuts.project.switchToListView')"
class="switch-view-button"
:class="{'is-active': viewName === 'project'}"
:to="{ name: 'project.list', params: { projectId } }"
:to="{ name: 'project.list', params: { projectId } }"
>
{{ $t('project.list.title') }}
</BaseButton>
@ -23,7 +23,7 @@
:title="$t('keyboardShortcuts.project.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'project.gantt', params: { projectId } }"
:to="{ name: 'project.gantt', params: { projectId } }"
>
{{ $t('project.gantt.title') }}
</BaseButton>
@ -32,7 +32,7 @@
:title="$t('keyboardShortcuts.project.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'project.table', params: { projectId } }"
:to="{ name: 'project.table', params: { projectId } }"
>
{{ $t('project.table.title') }}
</BaseButton>
@ -49,12 +49,16 @@
<slot name="header" />
</div>
<CustomTransition name="fade">
<Message variant="warning" v-if="currentProject?.isArchived" class="mb-4">
<Message
v-if="currentProject?.isArchived"
variant="warning"
class="mb-4"
>
{{ $t('project.archivedMessage') }}
</Message>
</CustomTransition>
<slot v-if="loadedProjectId"/>
<slot v-if="loadedProjectId" />
</div>
</template>

View File

@ -15,11 +15,20 @@
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<span v-if="project.isArchived" class="is-archived" >{{ $t('project.archived') }}</span>
<span
v-if="project.isArchived"
class="is-archived"
>{{ $t('project.archived') }}</span>
<div class="project-title" aria-hidden="true">
<span v-if="project.id < -1" class="saved-filter-icon icon">
<icon icon="filter"/>
<div
class="project-title"
aria-hidden="true"
>
<span
v-if="project.id < -1"
class="saved-filter-icon icon"
>
<icon icon="filter" />
</span>
{{ project.title }}
</div>

View File

@ -1,13 +1,13 @@
<template>
<ul class="project-grid">
<li
v-for="(item, index) in filteredProjects"
:key="`project_${item.id}_${index}`"
class="project-grid-item"
>
<ProjectCard :project="item" />
</li>
</ul>
<ul class="project-grid">
<li
v-for="(item, index) in filteredProjects"
:key="`project_${item.id}_${index}`"
class="project-grid-item"
>
<ProjectCard :project="item" />
</li>
</ul>
</template>
<script lang="ts" setup>

View File

@ -7,9 +7,9 @@
{{ $t('filters.clear') }}
</x-button>
<x-button
@click="() => modalOpen = true"
variant="secondary"
icon="filter"
@click="() => modalOpen = true"
>
{{ $t('filters.title') }}
</x-button>
@ -20,10 +20,10 @@
variant="hint-modal"
@close="() => modalOpen = false"
>
<filters
:has-title="true"
v-model="value"
<Filters
ref="filters"
v-model="value"
:has-title="true"
class="filter-popup"
/>
</modal>
@ -36,7 +36,7 @@ import Filters from '@/components/project/partials/filters.vue'
import {getDefaultParams} from '@/composables/useTaskList'
const props = defineProps({
const props = defineProps({
modelValue: {
required: true,
},
@ -48,6 +48,9 @@ const value = computed({
return props.modelValue
},
set(value) {
if(props.modelValue === value) {
return
}
emit('update:modelValue', value)
},
})
@ -59,7 +62,7 @@ watch(
},
{immediate: true},
)
const hasFilters = computed(() => {
// this.value also contains the page parameter which we don't want to include in filters
// eslint-disable-next-line no-unused-vars

View File

@ -1,134 +1,157 @@
<template>
<card class="filters has-overflow" :title="hasTitle ? $t('filters.title') : ''">
<card
class="filters has-overflow"
:title="hasTitle ? $t('filters.title') : ''"
>
<div class="field is-flex is-flex-direction-column">
<fancycheckbox
<Fancycheckbox
v-model="params.filter_include_nulls"
@update:model-value="change()"
@update:modelValue="change()"
>
{{ $t('filters.attributes.includeNulls') }}
</fancycheckbox>
<fancycheckbox
</Fancycheckbox>
<Fancycheckbox
v-model="filters.requireAllFilters"
@update:model-value="setFilterConcat()"
@update:modelValue="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<fancycheckbox
</Fancycheckbox>
<Fancycheckbox
v-model="filters.done"
@update:model-value="setDoneFilter"
@update:modelValue="setDoneFilter"
>
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
</Fancycheckbox>
<Fancycheckbox
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
v-model="sortAlphabetically"
@update:model-value="change()"
@update:modelValue="change()"
>
{{ $t('filters.attributes.sortAlphabetically') }}
</fancycheckbox>
</Fancycheckbox>
</div>
<div class="field">
<label class="label">{{ $t('misc.search') }}</label>
<div class="control">
<input
v-model="params.s"
class="input"
:placeholder="$t('misc.search')"
v-model="params.s"
@blur="change()"
@keyup.enter="change()"
/>
>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.priority') }}</label>
<div class="control single-value-control">
<priority-select
<PrioritySelect
v-model.number="filters.priority"
@update:model-value="setPriority"
:disabled="!filters.usePriority || undefined"
@update:modelValue="setPriority"
/>
<fancycheckbox
<Fancycheckbox
v-model="filters.usePriority"
@update:model-value="setPriority"
@update:modelValue="setPriority"
>
{{ $t('filters.attributes.enablePriority') }}
</fancycheckbox>
</Fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.percentDone') }}</label>
<div class="control single-value-control">
<percent-done-select
<PercentDoneSelect
v-model.number="filters.percentDone"
@update:model-value="setPercentDoneFilter"
:disabled="!filters.usePercentDone || undefined"
@update:modelValue="setPercentDoneFilter"
/>
<fancycheckbox
<Fancycheckbox
v-model="filters.usePercentDone"
@update:model-value="setPercentDoneFilter"
@update:modelValue="setPercentDoneFilter"
>
{{ $t('filters.attributes.enablePercentDone') }}
</fancycheckbox>
</Fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<datepicker-with-range
<DatepickerWithRange
v-model="filters.dueDate"
@update:model-value="values => setDateFilter('due_date', values)"
@update:modelValue="values => setDateFilter('due_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<datepicker-with-range
<DatepickerWithRange
v-model="filters.startDate"
@update:model-value="values => setDateFilter('start_date', values)"
@update:modelValue="values => setDateFilter('start_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<datepicker-with-range
<DatepickerWithRange
v-model="filters.endDate"
@update:model-value="values => setDateFilter('end_date', values)"
@update:modelValue="values => setDateFilter('end_date', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</DatepickerWithRange>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<datepicker-with-range
<DatepickerWithRange
v-model="filters.reminders"
@update:model-value="values => setDateFilter('reminders', values)"
@update:modelValue="values => setDateFilter('reminders', values)"
>
<template #trigger="{toggle, buttonText}">
<x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
<x-button
variant="secondary"
:shadow="false"
class="mb-2"
@click.prevent.stop="toggle()"
>
{{ buttonText }}
</x-button>
</template>
</datepicker-with-range>
</DatepickerWithRange>
</div>
</div>
@ -146,10 +169,10 @@
<div class="field">
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<edit-labels
:creatable="false"
<EditLabels
v-model="entities.labels"
@update:model-value="changeLabelFilter"
:creatable="false"
@update:modelValue="changeLabelFilter"
/>
</div>
</div>
@ -162,9 +185,9 @@
<div class="control">
<SelectProject
v-model="entities.projects"
:project-filter="p => p.id > 0"
@select="changeMultiselectFilter('projects', 'project_id')"
@remove="changeMultiselectFilter('projects', 'project_id')"
:project-filter="p => p.id > 0"
/>
</div>
</div>
@ -205,6 +228,18 @@ import ProjectService from '@/services/project'
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList'
const props = defineProps({
modelValue: {
required: true,
},
hasTitle: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
const DEFAULT_PARAMS = {
sort_by: [],
@ -233,18 +268,6 @@ const DEFAULT_FILTERS = {
project_id: '',
} as const
const props = defineProps({
modelValue: {
required: true,
},
hasTitle: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue'])
const {modelValue} = toRefs(props)
const labelStore = useLabelStore()

View File

@ -1,99 +1,108 @@
<template>
<dropdown>
<Dropdown>
<template #trigger="triggerProps">
<slot name="trigger" v-bind="triggerProps">
<BaseButton class="dropdown-trigger" @click="triggerProps.toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
<slot
name="trigger"
v-bind="triggerProps"
>
<BaseButton
class="dropdown-trigger"
@click="triggerProps.toggleOpen"
>
<icon
icon="ellipsis-h"
class="icon"
/>
</BaseButton>
</slot>
</template>
<template v-if="isSavedFilter(project)">
<dropdown-item
<DropdownItem
:to="{ name: 'filter.settings.edit', params: { projectId: project.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
</DropdownItem>
<DropdownItem
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
icon="trash-alt"
>
{{ $t('misc.delete') }}
</dropdown-item>
</DropdownItem>
</template>
<template v-else-if="project.isArchived">
<dropdown-item
<DropdownItem
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
</dropdown-item>
</DropdownItem>
</template>
<template v-else>
<dropdown-item
<DropdownItem
:to="{ name: 'project.settings.edit', params: { projectId: project.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
</DropdownItem>
<DropdownItem
v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
icon="image"
>
{{ $t('menu.setBackground') }}
</dropdown-item>
<dropdown-item
</DropdownItem>
<DropdownItem
:to="{ name: 'project.settings.share', params: { projectId: project.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
</DropdownItem>
<DropdownItem
:to="{ name: 'project.settings.duplicate', params: { projectId: project.id } }"
icon="paste"
>
{{ $t('menu.duplicate') }}
</dropdown-item>
<dropdown-item
</DropdownItem>
<DropdownItem
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
</dropdown-item>
</DropdownItem>
<Subscription
class="has-no-shadow"
:is-button="false"
entity="project"
:entity-id="project.id"
:model-value="project.subscription"
@update:model-value="setSubscriptionInStore"
type="dropdown"
@update:modelValue="setSubscriptionInStore"
/>
<dropdown-item
<DropdownItem
:to="{ name: 'project.settings.webhooks', params: { projectId: project.id } }"
icon="bolt"
>
{{ $t('project.webhooks.title') }}
</dropdown-item>
<dropdown-item
</DropdownItem>
<DropdownItem
v-if="level < 2"
:to="{ name: 'project.createFromParent', params: { parentProjectId: project.id } }"
icon="layer-group"
>
{{ $t('menu.createProject') }}
</dropdown-item>
<dropdown-item
</DropdownItem>
<DropdownItem
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
icon="trash-alt"
class="has-text-danger"
>
{{ $t('menu.delete') }}
</dropdown-item>
</DropdownItem>
</template>
</dropdown>
</Dropdown>
</template>
<script setup lang="ts">

View File

@ -1,33 +1,53 @@
<template>
<modal :enabled="active" @close="closeQuickActions" :overflow="isNewTaskCommand">
<modal
:enabled="active"
:overflow="isNewTaskCommand"
@close="closeQuickActions"
>
<div class="card quick-actions">
<div class="action-input" :class="{'has-active-cmd': selectedCmd !== null}">
<div class="active-cmd tag" v-if="selectedCmd !== null">
<div
class="action-input"
:class="{'has-active-cmd': selectedCmd !== null}"
>
<div
v-if="selectedCmd !== null"
class="active-cmd tag"
>
{{ selectedCmd.title }}
</div>
<input
ref="searchInput"
v-model="query"
v-focus
class="input"
:class="{'is-loading': loading}"
v-model="query"
:placeholder="placeholder"
@keyup="search"
ref="searchInput"
@keydown.down.prevent="select(0, 0)"
@keyup.prevent.delete="unselectCmd"
@keyup.prevent.enter="doCmd"
@keyup.prevent.esc="closeQuickActions"
/>
>
</div>
<div class="help has-text-grey-light p-2" v-if="hintText !== '' && !isNewTaskCommand">
<div
v-if="hintText !== '' && !isNewTaskCommand"
class="help has-text-grey-light p-2"
>
{{ hintText }}
</div>
<quick-add-magic v-if="isNewTaskCommand"/>
<QuickAddMagic v-if="isNewTaskCommand" />
<div class="results" v-if="selectedCmd === null">
<div v-for="(r, k) in results" :key="k" class="result">
<div
v-if="selectedCmd === null"
class="results"
>
<div
v-for="(r, k) in results"
:key="k"
class="result"
>
<span class="result-title">
{{ r.title }}
</span>
@ -35,9 +55,9 @@
<BaseButton
v-for="(i, key) in r.items"
:key="key"
:ref="(el: Element | ComponentPublicInstance | null) => setResultRefs(el, k, key)"
class="result-item-button"
:class="{'is-strikethrough': (i as DoAction<ITask>)?.done}"
:ref="(el: Element | ComponentPublicInstance | null) => setResultRefs(el, k, key)"
@keydown.up.prevent="select(k, key - 1)"
@keydown.down.prevent="select(k, key + 1)"
@click.prevent.stop="doAction(r.type, i)"
@ -45,10 +65,10 @@
@keyup.prevent.esc="searchInput?.focus()"
>
<template v-if="r.type === ACTION_TYPE.LABELS">
<x-label :label="i"/>
<XLabel :label="i" />
</template>
<template v-else-if="r.type === ACTION_TYPE.TASK">
<single-task-inline-readonly
<SingleTaskInlineReadonly
:task="i"
:show-project="true"
/>

View File

@ -3,8 +3,9 @@
<p class="has-text-weight-bold">
{{ $t('project.share.links.title') }}
<span
v-tooltip="$t('project.share.links.explanation')"
class="is-size-7 has-text-grey is-italic ml-3"
v-tooltip="$t('project.share.links.explanation')">
>
{{ $t('project.share.links.what') }}
</span>
</p>
@ -12,20 +13,30 @@
<div class="sharables-project">
<x-button
v-if="!(linkShares.length === 0 || showNewForm)"
@click="showNewForm = true"
icon="plus"
class="mb-4">
class="mb-4"
@click="showNewForm = true"
>
{{ $t('project.share.links.create') }}
</x-button>
<div class="p-4" v-if="linkShares.length === 0 || showNewForm">
<div
v-if="linkShares.length === 0 || showNewForm"
class="p-4"
>
<div class="field">
<label class="label" for="linkShareRight">
<label
class="label"
for="linkShareRight"
>
{{ $t('project.share.right.title') }}
</label>
<div class="control">
<div class="select">
<select v-model="selectedRight" id="linkShareRight">
<select
id="linkShareRight"
v-model="selectedRight"
>
<option :value="RIGHTS.READ">
{{ $t('project.share.right.read') }}
</option>
@ -40,131 +51,150 @@
</div>
</div>
<div class="field">
<label class="label" for="linkShareName">
<label
class="label"
for="linkShareName"
>
{{ $t('project.share.links.name') }}
</label>
<div class="control">
<input
id="linkShareName"
v-model="name"
v-tooltip="$t('project.share.links.nameExplanation')"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
v-tooltip="$t('project.share.links.nameExplanation')"
v-model="name"
/>
>
</div>
</div>
<div class="field">
<label class="label" for="linkSharePassword">
<label
class="label"
for="linkSharePassword"
>
{{ $t('project.share.links.password') }}
</label>
<div class="control">
<input
id="linkSharePassword"
v-model="password"
v-tooltip="$t('project.share.links.passwordExplanation')"
type="password"
class="input"
:placeholder="$t('user.auth.passwordPlaceholder')"
v-tooltip="$t('project.share.links.passwordExplanation')"
v-model="password"
/>
>
</div>
</div>
<x-button @click="add(projectId)" icon="plus">
<x-button
icon="plus"
@click="add(projectId)"
>
{{ $t('project.share.share') }}
</x-button>
</div>
<table
class="table has-actions is-striped is-hoverable is-fullwidth"
v-if="linkShares.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
>
<thead>
<tr>
<th></th>
<th>{{ $t('project.share.links.view') }}</th>
<th>{{ $t('project.share.attributes.delete') }}</th>
</tr>
<tr>
<th />
<th>{{ $t('project.share.links.view') }}</th>
<th>{{ $t('project.share.attributes.delete') }}</th>
</tr>
</thead>
<tbody>
<tr :key="s.id" v-for="s in linkShares">
<td>
<p class="mb-2 is-italic" v-if="s.name !== ''">
{{ s.name }}
</p>
<tr
v-for="s in linkShares"
:key="s.id"
>
<td>
<p
v-if="s.name !== ''"
class="mb-2 is-italic"
>
{{ s.name }}
</p>
<p class="mb-2">
<i18n-t keypath="project.share.links.sharedBy" scope="global">
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
<p class="mb-2">
<i18n-t
keypath="project.share.links.sharedBy"
scope="global"
>
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
<p class="mb-2">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('project.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>&nbsp;
{{ $t('project.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>&nbsp;
{{ $t('project.share.right.read') }}
</template>
</p>
<p class="mb-2">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock" />
</span>&nbsp;
{{ $t('project.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen" />
</span>&nbsp;
{{ $t('project.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users" />
</span>&nbsp;
{{ $t('project.share.right.read') }}
</template>
</p>
<div class="field has-addons no-input-mobile">
<div class="control">
<input
<div class="field has-addons no-input-mobile">
<div class="control">
<input
:value="getShareLink(s.hash, selectedView[s.id])"
class="input"
readonly
type="text"
/>
</div>
<div class="control">
<x-button
@click="copy(getShareLink(s.hash, selectedView[s.id]))"
:shadow="false"
>
</div>
<div class="control">
<x-button
v-tooltip="$t('misc.copy')"
>
<span class="icon">
<icon icon="paste"/>
</span>
</x-button>
:shadow="false"
@click="copy(getShareLink(s.hash, selectedView[s.id]))"
>
<span class="icon">
<icon icon="paste" />
</span>
</x-button>
</div>
</div>
</div>
</td>
<td>
<div class="select">
<select v-model="selectedView[s.id]">
<option
v-for="(title, key) in availableViews"
:value="key"
:key="key">
{{ title }}
</option>
</select>
</div>
</td>
<td class="actions">
<x-button
@click="
</td>
<td>
<div class="select">
<select v-model="selectedView[s.id]">
<option
v-for="(title, key) in availableViews"
:key="key"
:value="key"
>
{{ title }}
</option>
</select>
</div>
</td>
<td class="actions">
<x-button
class="is-danger"
icon="trash-alt"
@click="
() => {
linkIdToDelete = s.id
showDeleteModal = true
}
"
class="is-danger"
icon="trash-alt"
/>
</td>
</tr>
/>
</td>
</tr>
</tbody>
</table>
</div>
@ -207,7 +237,7 @@ import {useConfigStore} from '@/stores/config'
const props = defineProps({
projectId: {
default: 0,
required: true,
required: false,
},
})

View File

@ -10,108 +10,119 @@
:class="{ 'is-loading': searchService.loading }"
>
<Multiselect
v-model="sharable"
:loading="searchService.loading"
:placeholder="$t('misc.searchPlaceholder')"
@search="find"
:search-results="found"
:label="searchLabel"
v-model="sharable"
@search="find"
/>
</p>
<p class="control">
<x-button @click="add()">{{ $t('project.share.share') }}</x-button>
<x-button @click="add()">
{{ $t('project.share.share') }}
</x-button>
</p>
</div>
</div>
<table class="table has-actions is-striped is-hoverable is-fullwidth mb-4" v-if="sharables.length > 0">
<table
v-if="sharables.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth mb-4"
>
<tbody>
<tr :key="s.id" v-for="s in sharables">
<template v-if="shareType === 'user'">
<td>{{ getDisplayName(s) }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b>
</template>
</td>
</template>
<template v-if="shareType === 'team'">
<td>
<router-link
:to="{
<tr
v-for="s in sharables"
:key="s.id"
>
<template v-if="shareType === 'user'">
<td>{{ getDisplayName(s) }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b>
</template>
</td>
</template>
<template v-if="shareType === 'team'">
<td>
<router-link
:to="{
name: 'teams.edit',
params: { id: s.id },
}"
>
{{ s.name }}
</router-link>
>
{{ s.name }}
</router-link>
</td>
</template>
<td class="type">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock" />
</span>
{{ $t('project.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen" />
</span>
{{ $t('project.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users" />
</span>
{{ $t('project.share.right.read') }}
</template>
</td>
</template>
<td class="type">
<template v-if="s.right === RIGHTS.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>
{{ $t('project.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
{{ $t('project.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
{{ $t('project.share.right.read') }}
</template>
</td>
<td class="actions" v-if="userIsAdmin">
<div class="select">
<select
@change="toggleType(s)"
class="mr-2"
v-model="selectedRight[s.id]"
>
<option
:selected="s.right === RIGHTS.READ"
:value="RIGHTS.READ"
<td
v-if="userIsAdmin"
class="actions"
>
<div class="select">
<select
v-model="selectedRight[s.id]"
class="mr-2"
@change="toggleType(s)"
>
{{ $t('project.share.right.read') }}
</option>
<option
:selected="s.right === RIGHTS.READ_WRITE"
:value="RIGHTS.READ_WRITE"
>
{{ $t('project.share.right.readWrite') }}
</option>
<option
:selected="s.right === RIGHTS.ADMIN"
:value="RIGHTS.ADMIN"
>
{{ $t('project.share.right.admin') }}
</option>
</select>
</div>
<x-button
@click="
<option
:selected="s.right === RIGHTS.READ"
:value="RIGHTS.READ"
>
{{ $t('project.share.right.read') }}
</option>
<option
:selected="s.right === RIGHTS.READ_WRITE"
:value="RIGHTS.READ_WRITE"
>
{{ $t('project.share.right.readWrite') }}
</option>
<option
:selected="s.right === RIGHTS.ADMIN"
:value="RIGHTS.ADMIN"
>
{{ $t('project.share.right.admin') }}
</option>
</select>
</div>
<x-button
class="is-danger"
icon="trash-alt"
@click="
() => {
sharable = s
showDeleteModal = true
}
"
class="is-danger"
icon="trash-alt"
/>
</td>
</tr>
/>
</td>
</tr>
</tbody>
</table>
<nothing v-else>
<Nothing v-else>
{{ $t('project.share.userTeam.notShared', {type: shareTypeNames}) }}
</nothing>
</Nothing>
<modal
:enabled="showDeleteModal"
@ -120,8 +131,8 @@
>
<template #header>
<span>{{
$t('project.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
}}</span>
$t('project.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
}}</span>
</template>
<template #text>
<p>{{ $t('project.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
@ -131,7 +142,7 @@
</template>
<script lang="ts">
export default {name: 'userTeamShare'}
export default {name: 'UserTeamShare'}
</script>
<script setup lang="ts">

View File

@ -3,7 +3,11 @@
v-if="props.isLoading && !ganttBars.length || dayjsLanguageLoading"
class="gantt-container"
/>
<div ref="ganttContainer" class="gantt-container" v-else>
<div
v-else
ref="ganttContainer"
class="gantt-container"
>
<GGanttChart
:date-format="DAYJS_ISO_DATE_FORMAT"
:chart-start="isoToKebabDate(filters.dateFrom)"
@ -12,9 +16,9 @@
bar-start="startDate"
bar-end="endDate"
:grid="true"
@dragend-bar="updateGanttTask"
@dblclick-bar="openTask"
:width="ganttChartWidth"
@dragendBar="updateGanttTask"
@dblclickBar="openTask"
>
<template #timeunit="{value, date}">
<div
@ -61,6 +65,8 @@ import {
import Loading from '@/components/misc/loading.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import {useWeekDayFromDate} from '@/helpers/time/formatDate'
import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
export interface GanttChartProps {
isLoading: boolean,
@ -70,19 +76,19 @@ export interface GanttChartProps {
defaultTaskEndDate: DateISO
}
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
const props = defineProps<GanttChartProps>()
const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void
}>()
const DAYJS_ISO_DATE_FORMAT = 'YYYY-MM-DD'
const {tasks, filters} = toRefs(props)
// setup dayjs for vue-ganttastic
const dayjsLanguageLoading = ref(false)
// const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
// const dayjsLanguageLoading = ref(false)
const dayjsLanguageLoading = useDayjsLanguageSync(dayjs)
extendDayjs()
const ganttContainer = ref(null)
@ -121,6 +127,23 @@ watch(
function transformTaskToGanttBar(t: ITask) {
const black = 'var(--grey-800)'
const taskColor = getHexColor(t.hexColor)
let textColor = black
let backgroundColor = 'var(--grey-100)'
if(t.startDate) {
backgroundColor = taskColor ?? ''
if(typeof taskColor === 'undefined') {
textColor = 'white'
backgroundColor = 'var(--primary)'
} else if(colorIsDark(taskColor)) {
textColor = black
} else {
textColor = 'white'
}
}
return [{
startDate: isoToKebabDate(t.startDate ? t.startDate.toISOString() : props.defaultTaskStartDate),
endDate: isoToKebabDate(t.endDate ? t.endDate.toISOString() : props.defaultTaskEndDate),
@ -129,8 +152,8 @@ function transformTaskToGanttBar(t: ITask) {
label: t.title,
hasHandles: true,
style: {
color: t.startDate ? (colorIsDark(getHexColor(t.hexColor)) ? black : 'white') : black,
backgroundColor: t.startDate ? getHexColor(t.hexColor) : 'var(--grey-100)',
color: textColor,
backgroundColor,
border: t.startDate ? '' : '2px dashed var(--grey-300)',
'text-decoration': t.done ? 'line-through' : null,
},

View File

@ -1,20 +1,24 @@
<template>
<form
@submit.prevent="createTask"
class="add-new-task"
@submit.prevent="createTask"
>
<CustomTransition name="width">
<input
v-if="newTaskFieldActive"
ref="newTaskTitleField"
v-model="newTaskTitle"
class="input"
type="text"
@blur="hideCreateNewTask"
@keyup.esc="newTaskFieldActive = false"
class="input"
ref="newTaskTitleField"
type="text"
/>
>
</CustomTransition>
<x-button @click="showCreateTaskOrCreate" :shadow="false" icon="plus">
<x-button
:shadow="false"
icon="plus"
@click="showCreateTaskOrCreate"
>
{{ $t('task.new') }}
</x-button>
</form>
@ -27,7 +31,7 @@ import type {ITask} from '@/modelTypes/ITask'
import CustomTransition from '@/components/misc/CustomTransition.vue'
const emit = defineEmits<{
(e: 'create-task', title: string): Promise<ITask>
(e: 'createTask', title: string): Promise<ITask>
}>()
const newTaskFieldActive = ref(false)
@ -56,7 +60,7 @@ async function createTask() {
if (!newTaskFieldActive.value) {
return
}
await emit('create-task', newTaskTitle.value)
await emit('createTask', newTaskTitle.value)
newTaskTitle.value = ''
hideCreateNewTask()
}

View File

@ -1,31 +1,34 @@
<template>
<div class="task-add" ref="taskAdd">
<div
ref="taskAdd"
class="task-add"
>
<div class="add-task__field field is-grouped">
<p class="control has-icons-left has-icons-right is-expanded">
<textarea
ref="newTaskInput"
v-model="newTaskTitle"
v-focus
class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('project.list.addPlaceholder')"
rows="1"
v-focus
v-model="newTaskTitle"
ref="newTaskInput"
@keyup="resetEmptyTitleError"
@keydown.enter="handleEnter"
/>
<span class="icon is-small is-left">
<icon icon="tasks"/>
<icon icon="tasks" />
</span>
<quick-add-magic :highlight-hint-icon="taskAddHovered"/>
<QuickAddMagic :highlight-hint-icon="taskAddHovered" />
</p>
<p class="control">
<x-button
class="add-task-button"
:disabled="newTaskTitle === '' || loading || undefined"
@click="addTask()"
icon="plus"
:loading="loading"
:aria-label="$t('project.list.add')"
@click="addTask()"
>
<span class="button-text">
{{ $t('project.list.add') }}
@ -34,7 +37,10 @@
</p>
</div>
<Expandable :open="errorMessage !== ''">
<p class="pt-3 mt-0 help is-danger" v-if="errorMessage !== ''">
<p
v-if="errorMessage !== ''"
class="pt-3 mt-0 help is-danger"
>
{{ errorMessage }}
</p>
</Expandable>

View File

@ -22,26 +22,29 @@ const hasDelete = computed(() => typeof remove !== 'undefined' && !disabled)
</script>
<template>
<div class="assignees-list" :class="{'is-inline': inline}">
<div
class="assignees-list"
:class="{'is-inline': inline}"
>
<span
v-for="user in assignees"
class="assignee"
:key="user.id"
class="assignee"
>
<User
:key="'user'+user.id"
:avatar-size="avatarSize"
:show-username="false"
:user="user"
:class="{'m-2': hasDelete, 'mr-3': !hasDelete}"
:class="{'m-2': hasDelete}"
/>
<BaseButton
:key="'delete'+user.id"
v-if="hasDelete"
@click="remove(user)"
:key="'delete'+user.id"
class="remove-assignee"
@click="remove(user)"
>
<icon icon="times"/>
<icon icon="times" />
</BaseButton>
</span>
</div>

View File

@ -2,37 +2,38 @@
<div class="attachments">
<h3>
<span class="icon is-grey">
<icon icon="paperclip"/>
<icon icon="paperclip" />
</span>
{{ $t('task.attachment.title') }}
</h3>
<input
v-if="editEnabled"
:disabled="loading || undefined"
@change="uploadNewAttachment()"
id="files"
multiple
ref="filesRef"
:disabled="loading || undefined"
multiple
type="file"
/>
<progress
v-if="attachmentService.uploadProgress > 0"
:value="attachmentService.uploadProgress"
class="progress is-primary"
max="100"
@change="uploadNewAttachment()"
>
{{ attachmentService.uploadProgress }}%
</progress>
<div class="files" v-if="attachments.length > 0">
<ProgressBar
v-if="attachmentService.uploadProgress > 0"
:value="attachmentService.uploadProgress * 100"
is-primary
/>
<div
v-if="attachments.length > 0"
class="files"
>
<!-- FIXME: don't use a for element that wraps other links / buttons
Instead: overlay element with button that is inside.
-->
<a
class="attachment"
v-for="a in attachments"
:key="a.id"
class="attachment"
@click="viewOrDownload(a)"
>
<div class="filename">
@ -46,7 +47,10 @@
</div>
<div class="info">
<p class="attachment-info-meta">
<i18n-t keypath="task.attachment.createdBy" scope="global">
<i18n-t
keypath="task.attachment.createdBy"
scope="global"
>
<span v-tooltip="formatDateLong(a.created)">
{{ formatDateSince(a.created) }}
</span>
@ -65,24 +69,24 @@
</p>
<p>
<BaseButton
v-tooltip="$t('task.attachment.downloadTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="downloadAttachment(a)"
v-tooltip="$t('task.attachment.downloadTooltip')"
>
{{ $t('misc.download') }}
</BaseButton>
<BaseButton
v-tooltip="$t('task.attachment.copyUrlTooltip')"
class="attachment-info-meta-button"
@click.stop="copyUrl(a)"
v-tooltip="$t('task.attachment.copyUrlTooltip')"
>
{{ $t('task.attachment.copyUrl') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
v-tooltip="$t('task.attachment.deleteTooltip')"
class="attachment-info-meta-button"
@click.prevent.stop="setAttachmentToDelete(a)"
v-tooltip="$t('task.attachment.deleteTooltip')"
>
{{ $t('misc.delete') }}
</BaseButton>
@ -105,11 +109,11 @@
<x-button
v-if="editEnabled"
:disabled="loading"
@click="filesRef?.click()"
class="mb-4"
icon="cloud-upload-alt"
variant="secondary"
:shadow="false"
@click="filesRef?.click()"
>
{{ $t('task.attachment.upload') }}
</x-button>
@ -117,15 +121,17 @@
<!-- Dropzone -->
<Teleport to="body">
<div
v-if="editEnabled"
:class="{ hidden: !isOverDropZone }"
class="dropzone"
v-if="editEnabled"
>
<div class="drop-hint">
<div class="icon">
<icon icon="cloud-upload-alt"/>
<icon icon="cloud-upload-alt" />
</div>
<div class="hint">
{{ $t('task.attachment.drop') }}
</div>
<div class="hint">{{ $t('task.attachment.drop') }}</div>
</div>
</div>
</Teleport>
@ -142,7 +148,7 @@
<template #text>
<p>
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/>
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>
@ -153,7 +159,10 @@
:enabled="attachmentImageBlobUrl !== null"
@close="attachmentImageBlobUrl = null"
>
<img :src="attachmentImageBlobUrl" alt=""/>
<img
:src="attachmentImageBlobUrl"
alt=""
>
</modal>
</div>
</template>
@ -163,6 +172,7 @@ import {ref, shallowReactive, computed} from 'vue'
import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/user.vue'
import ProgressBar from '@/components/misc/ProgressBar.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AttachmentService from '@/services/attachment'
@ -179,9 +189,6 @@ import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useI18n} from 'vue-i18n'
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const {
task,
editEnabled = true,
@ -189,9 +196,10 @@ const {
task: ITask,
editEnabled: boolean,
}>()
// FIXME: this should go through the store
const emit = defineEmits(['task-changed'])
const emit = defineEmits(['taskChanged'])
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const attachmentService = shallowReactive(new AttachmentService())
@ -267,7 +275,7 @@ function copyUrl(attachment: IAttachment) {
async function setCoverImage(attachment: IAttachment | null) {
const updatedTask = await taskStore.setCoverImage(task, attachment)
emit('task-changed', updatedTask)
emit('taskChanged', updatedTask)
success({message: t('task.attachment.successfullyChangedCoverImage')})
}
</script>

View File

@ -1,9 +1,29 @@
<template>
<span v-if="checklist.total > 0" class="checklist-summary">
<svg width="12" height="12">
<circle stroke-width="2" fill="transparent" cx="50%" cy="50%" r="5"></circle>
<circle stroke-width="2" stroke-dasharray="31" :stroke-dashoffset="checklistCircleDone"
stroke-linecap="round" fill="transparent" cx="50%" cy="50%" r="5"></circle>
<span
v-if="checklist.total > 0"
class="checklist-summary"
>
<svg
width="12"
height="12"
>
<circle
stroke-width="2"
fill="transparent"
cx="50%"
cy="50%"
r="5"
/>
<circle
stroke-width="2"
stroke-dasharray="31"
:stroke-dashoffset="checklistCircleDone"
stroke-linecap="round"
fill="transparent"
cx="50%"
cy="50%"
r="5"
/>
</svg>
<span>{{ label }}</span>
</span>

View File

@ -1,20 +1,30 @@
<template>
<div class="content details" v-if="enabled">
<h3 v-if="canWrite || comments.length > 0" :class="{'d-print-none': comments.length === 0}">
<div
v-if="enabled"
class="content details"
>
<h3
v-if="canWrite || comments.length > 0"
:class="{'d-print-none': comments.length === 0}"
>
<span class="icon is-grey">
<icon :icon="['far', 'comments']"/>
<icon :icon="['far', 'comments']" />
</span>
{{ $t('task.comment.title') }}
</h3>
<div class="comments">
<span
class="is-inline-flex is-align-items-center"
v-if="taskCommentService.loading && saving === null && !creating"
class="is-inline-flex is-align-items-center"
>
<span class="loader is-inline-block mr-2"></span>
<span class="loader is-inline-block mr-2" />
{{ $t('task.comment.loading') }}
</span>
<div :key="c.id" class="media comment" v-for="c in comments">
<div
v-for="c in comments"
:key="c.id"
class="media comment"
>
<figure class="media-left is-hidden-mobile">
<img
:src="getAvatarUrl(c.author, 48)"
@ -22,7 +32,7 @@
class="image is-avatar"
height="48"
width="48"
/>
>
</figure>
<div class="media-content">
<div class="comment-info">
@ -32,9 +42,12 @@
class="image is-avatar d-print-none"
height="20"
width="20"
/>
>
<strong>{{ getDisplayName(c.author) }}</strong>&nbsp;
<span v-tooltip="formatDateLong(c.created)" class="has-text-grey">
<span
v-tooltip="formatDateLong(c.created)"
class="has-text-grey"
>
{{ formatDateSince(c.created) }}
</span>
<span
@ -45,32 +58,35 @@
</span>
<CustomTransition name="fade">
<span
class="is-inline-flex"
v-if="
taskCommentService.loading &&
saving === c.id
saving === c.id
"
class="is-inline-flex"
>
<span class="loader is-inline-block mr-2"></span>
<span class="loader is-inline-block mr-2" />
{{ $t('misc.saving') }}
</span>
<span
class="has-text-success"
v-else-if="
!taskCommentService.loading &&
saved === c.id
saved === c.id
"
class="has-text-success"
>
{{ $t('misc.saved') }}
</span>
</CustomTransition>
</div>
<editor
<Editor
v-model="c.comment"
:is-edit-enabled="canWrite && c.author.id === currentUserId"
:upload-callback="attachmentUpload"
:upload-enabled="true"
v-model="c.comment"
@update:model-value="
:bottom-actions="actions[c.id]"
:show-save="true"
initial-mode="preview"
@update:modelValue="
() => {
toggleEdit(c)
editCommentWithDelay()
@ -80,13 +96,13 @@
toggleEdit(c)
editComment()
}"
:bottom-actions="actions[c.id]"
:show-save="true"
initial-mode="preview"
/>
</div>
</div>
<div class="media comment d-print-none" v-if="canWrite">
<div
v-if="canWrite"
class="media comment d-print-none"
>
<figure class="media-left is-hidden-mobile">
<img
:src="userAvatar"
@ -94,7 +110,7 @@
class="image is-avatar"
height="48"
width="48"
/>
>
</figure>
<div class="media-content">
<div class="form">
@ -103,12 +119,14 @@
v-if="taskCommentService.loading && creating"
class="is-inline-flex"
>
<span class="loader is-inline-block mr-2"></span>
<span class="loader is-inline-block mr-2" />
{{ $t('task.comment.creating') }}
</span>
</CustomTransition>
<div class="field">
<editor
<Editor
v-if="editorActive"
v-model="newComment.comment"
:class="{
'is-loading':
taskCommentService.loading &&
@ -117,8 +135,6 @@
:upload-callback="attachmentUpload"
:upload-enabled="true"
:placeholder="$t('task.comment.placeholder')"
v-if="editorActive"
v-model="newComment.comment"
@save="addComment()"
/>
</div>
@ -141,11 +157,13 @@
@close="showDeleteModal = false"
@submit="() => deleteComment(commentToDelete)"
>
<template #header><span>{{ $t('task.comment.delete') }}</span></template>
<template #header>
<span>{{ $t('task.comment.delete') }}</span>
</template>
<template #text>
<p>
{{ $t('task.comment.deleteText1') }}<br/>
{{ $t('task.comment.deleteText1') }}<br>
<strong class="has-text-white">{{ $t('misc.cannotBeUndone') }}</strong>
</p>
</template>

View File

@ -1,24 +1,42 @@
<template>
<p class="created">
<time :datetime="formatISO(task.created)" v-tooltip="formatDateLong(task.created)">
<i18n-t keypath="task.detail.created" scope="global">
<time
v-tooltip="formatDateLong(task.created)"
:datetime="formatISO(task.created)"
>
<i18n-t
keypath="task.detail.created"
scope="global"
>
<span>{{ formatDateSince(task.created) }}</span>
{{ getDisplayName(task.createdBy) }}
</i18n-t>
</time>
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
<br/>
<br>
<!-- Computed properties to show the actual date every time it gets updated -->
<time :datetime="formatISO(task.updated)" v-tooltip="updatedFormatted">
<i18n-t keypath="task.detail.updated" scope="global">
<time
v-tooltip="updatedFormatted"
:datetime="formatISO(task.updated)"
>
<i18n-t
keypath="task.detail.updated"
scope="global"
>
<span>{{ updatedSince }}</span>
</i18n-t>
</time>
</template>
<template v-if="task.done">
<br/>
<time :datetime="formatISO(task.doneAt)" v-tooltip="doneFormatted">
<i18n-t keypath="task.detail.doneAt" scope="global">
<br>
<time
v-tooltip="doneFormatted"
:datetime="formatISO(task.doneAt)"
>
<i18n-t
keypath="task.detail.doneAt"
scope="global"
>
<span>{{ doneSince }}</span>
</i18n-t>
</time>

View File

@ -6,33 +6,33 @@
<label class="label">{{ $t('task.deferDueDate.title') }}</label>
<div class="defer-days">
<x-button
@click.prevent.stop="() => deferDays(1)"
:shadow="false"
variant="secondary"
@click.prevent.stop="() => deferDays(1)"
>
{{ $t('task.deferDueDate.1day') }}
</x-button>
<x-button
@click.prevent.stop="() => deferDays(3)"
:shadow="false"
variant="secondary"
@click.prevent.stop="() => deferDays(3)"
>
{{ $t('task.deferDueDate.3days') }}
</x-button>
<x-button
@click.prevent.stop="() => deferDays(7)"
:shadow="false"
variant="secondary"
@click.prevent.stop="() => deferDays(7)"
>
{{ $t('task.deferDueDate.1week') }}
</x-button>
</div>
<flat-pickr
v-model="dueDate"
:class="{ disabled: taskService.loading }"
:config="flatPickerConfig"
:disabled="taskService.loading || undefined"
class="input"
v-model="dueDate"
/>
</div>
</template>
@ -44,7 +44,7 @@ import flatPickr from 'vue-flatpickr-component'
import TaskService from '@/services/task'
import type {ITask} from '@/modelTypes/ITask'
import {useAuthStore} from '@/stores/auth'
import { getFlatpickrLanguage } from '@/helpers/flatpickrLanguage'
const {
modelValue,
@ -55,7 +55,6 @@ const {
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const taskService = shallowReactive(new TaskService())
const task = ref<ITask>()
@ -102,9 +101,7 @@ const flatPickerConfig = computed(() => ({
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: authStore.settings.weekStart,
},
locale: getFlatpickrLanguage(),
}))
function deferDays(days: number) {

View File

@ -2,29 +2,35 @@
<div>
<h3>
<span class="icon is-grey">
<icon icon="align-left"/>
<icon icon="align-left" />
</span>
{{ $t('task.attributes.description') }}
<CustomTransition name="fade">
<span class="is-small is-inline-flex" v-if="loading && saving">
<span class="loader is-inline-block mr-2"></span>
<span
v-if="loading && saving"
class="is-small is-inline-flex"
>
<span class="loader is-inline-block mr-2" />
{{ $t('misc.saving') }}
</span>
<span class="is-small has-text-success" v-else-if="!loading && saved">
<icon icon="check"/>
<span
v-else-if="!loading && saved"
class="is-small has-text-success"
>
<icon icon="check" />
{{ $t('misc.saved') }}
</span>
</CustomTransition>
</h3>
<editor
<Editor
v-model="description"
class="tiptap__task-description"
:is-edit-enabled="canWrite"
:upload-callback="uploadCallback"
:placeholder="$t('task.description.placeholder')"
:show-save="true"
edit-shortcut="e"
v-model="description"
@update:model-value="saveWithDelay"
@update:modelValue="saveWithDelay"
@save="save"
/>
</div>

View File

@ -1,21 +1,31 @@
<template>
<Multiselect
v-model="assignees"
class="edit-assignees"
:class="{'has-assignees': assignees.length > 0}"
:loading="projectUserService.loading"
:placeholder="$t('task.assignee.placeholder')"
:multiple="true"
@search="findUser"
:search-results="foundUsers"
@select="addAssignee"
label="name"
:select-placeholder="$t('task.assignee.selectPlaceholder')"
v-model="assignees"
:autocomplete-enabled="false"
@search="findUser"
@select="addAssignee"
>
<template #items="{items}">
<assignee-list :assignees="items" :remove="removeAssignee"/>
<AssigneeList
:assignees="items"
:remove="removeAssignee"
:disabled="disabled"
/>
</template>
<template #searchResult="{option: user}">
<user :avatar-size="24" :show-username="true" :user="user"/>
<User
:avatar-size="24"
:show-username="true"
:user="user"
/>
</template>
</Multiselect>
</template>
@ -115,3 +125,9 @@ async function findUser(query: string) {
})
}
</script>
<style lang="scss">
.edit-assignees.has-assignees.multiselect .input {
padding-left: 0;
}
</style>

View File

@ -1,37 +1,44 @@
<template>
<Multiselect
v-model="labels"
:loading="loading"
:placeholder="$t('task.label.placeholder')"
:multiple="true"
@search="findLabel"
:search-results="foundLabels"
@select="addLabel"
label="title"
:creatable="creatable"
@create="createAndAddLabel"
:create-placeholder="$t('task.label.createPlaceholder')"
v-model="labels"
:search-delay="10"
:close-after-select="false"
@search="findLabel"
@select="addLabel"
@create="createAndAddLabel"
>
<template #tag="{item: label}">
<span
:style="{'background': label.hexColor, 'color': label.textColor}"
class="tag">
class="tag"
>
<span>{{ label.title }}</span>
<BaseButton v-cy="'taskDetail.removeLabel'" @click="removeLabel(label)" class="delete is-small" />
<BaseButton
v-cy="'taskDetail.removeLabel'"
class="delete is-small"
@click="removeLabel(label)"
/>
</span>
</template>
<template #searchResult="{option}">
<span
v-if="typeof option === 'string'"
class="tag search-result">
class="tag search-result"
>
<span>{{ option }}</span>
</span>
<span
v-else
:style="{'background': option.hexColor, 'color': option.textColor}"
class="tag search-result">
class="tag search-result"
>
<span>{{ option.title }}</span>
</span>
</template>
@ -51,6 +58,7 @@ import Multiselect from '@/components/input/multiselect.vue'
import type {ILabel} from '@/modelTypes/ILabel'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
import {getRandomColorHex} from '@/helpers/color/randomColor'
const props = defineProps({
modelValue: {
@ -132,7 +140,10 @@ async function createAndAddLabel(title: string) {
return
}
const newLabel = await labelStore.createLabel(new LabelModel({title}))
const newLabel = await labelStore.createLabel(new LabelModel({
title,
hexColor: getRandomColorHex(),
}))
addLabel(newLabel, false)
labels.value.push(newLabel)
success({message: t('task.label.addCreateSuccess')})

View File

@ -1,8 +1,15 @@
<template>
<div class="heading">
<div class="flex is-align-items-center">
<BaseButton @click="copyUrl"><h1 class="title task-id">{{ textIdentifier }}</h1></BaseButton>
<Done class="heading__done" :is-done="task.done"/>
<BaseButton @click="copyUrl">
<h1 class="title task-id">
{{ textIdentifier }}
</h1>
</BaseButton>
<Done
class="heading__done"
:is-done="task.done"
/>
<ColorBubble
v-if="task.hexColor !== ''"
:color="getHexColor(task.hexColor)"
@ -12,10 +19,10 @@
<h1
class="title input"
:class="{'disabled': !canWrite}"
@blur="save(($event.target as HTMLInputElement).textContent as string)"
@keydown.enter.prevent.stop="($event.target as HTMLInputElement).blur()"
:contenteditable="canWrite ? true : undefined"
:spellcheck="false"
@blur="save(($event.target as HTMLInputElement).textContent as string)"
@keydown.enter.prevent.stop="($event.target as HTMLInputElement).blur()"
>
{{ task.title.trim() }}
</h1>
@ -24,14 +31,17 @@
v-if="loading && saving"
class="is-inline-flex is-align-items-center"
>
<span class="loader is-inline-block mr-2"></span>
<span class="loader is-inline-block mr-2" />
{{ $t('misc.saving') }}
</span>
<span
v-else-if="!loading && showSavedMessage"
class="has-text-success is-inline-flex is-align-content-center"
>
<icon icon="check" class="mr-2"/>
<icon
icon="check"
class="mr-2"
/>
{{ $t('misc.saved') }}
</span>
</CustomTransition>

View File

@ -4,10 +4,10 @@
:class="{
'is-loading': loadingInternal || loading,
'draggable': !(loadingInternal || loading),
'has-light-text': color !== TASK_DEFAULT_COLOR && !colorIsDark(color),
'has-custom-background-color': color !== TASK_DEFAULT_COLOR ? color : undefined,
'has-light-text': !colorIsDark(color),
'has-custom-background-color': color ?? undefined,
}"
:style="{'background-color': color !== TASK_DEFAULT_COLOR ? color : undefined}"
:style="{'background-color': color ?? undefined}"
@click.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)"
@click.meta="() => toggleTaskDone(task)"
@ -17,10 +17,14 @@
:src="coverImageBlobUrl"
alt=""
class="cover-image"
/>
>
<div class="p-2">
<span class="task-id">
<Done class="kanban-card__done" :is-done="task.done" variant="small"/>
<Done
class="kanban-card__done"
:is-done="task.done"
variant="small"
/>
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
@ -29,46 +33,59 @@
</template>
</span>
<span
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)"
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)">
>
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
<icon :icon="['far', 'calendar-alt']" />
</span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</time>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
<ProgressBar
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</progress>
class="task-progress"
:value="task.percentDone * 100"
/>
<div class="footer">
<labels :labels="task.labels"/>
<priority-label
<Labels :labels="task.labels" />
<PriorityLabel
:priority="task.priority"
:done="task.done"
class="is-inline-flex is-align-items-center"/>
<assignee-list
class="is-inline-flex is-align-items-center"
/>
<AssigneeList
v-if="task.assignees.length > 0"
:assignees="task.assignees"
:avatar-size="24"
class="ml-1"
:inline="true"
class="mr-1"
/>
<checklist-summary :task="task"/>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
<ChecklistSummary
:task="task"
class="checklist"
/>
<span
v-if="task.attachments.length > 0"
class="icon"
>
<icon icon="paperclip" />
</span>
<span v-if="!isEditorContentEmpty(task.description)" class="icon">
<icon icon="align-left"/>
<span
v-if="!isEditorContentEmpty(task.description)"
class="icon"
>
<icon icon="align-left" />
</span>
<span class="icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
<span
v-if="task.repeatAfter.amount > 0"
class="icon"
>
<icon icon="history" />
</span>
</div>
</div>
@ -80,11 +97,12 @@ import {ref, computed, watch} from 'vue'
import {useRouter} from 'vue-router'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import ProgressBar from '@/components/misc/ProgressBar.vue'
import Done from '@/components/misc/Done.vue'
import Labels from '@/components/tasks/partials/labels.vue'
import ChecklistSummary from './checklist-summary.vue'
import {TASK_DEFAULT_COLOR, getHexColor} from '@/models/task'
import {getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import AttachmentService from '@/services/attachment'
@ -97,10 +115,6 @@ import {useAuthStore} from '@/stores/auth'
import {playPopSound} from '@/helpers/playPop'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
const router = useRouter()
const loadingInternal = ref(false)
const {
task,
loading = false,
@ -109,6 +123,10 @@ const {
loading: boolean,
}>()
const router = useRouter()
const loadingInternal = ref(false)
const color = computed(() => getHexColor(task.hexColor))
async function toggleTaskDone(task: ITask) {
@ -188,11 +206,6 @@ $task-background: var(--white);
word-break: break-word;
}
.progress {
margin: 8px 0 0 0;
width: 100%;
height: 0.5rem;
}
.due-date {
float: right;
@ -218,15 +231,20 @@ $task-background: var(--white);
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: .25rem;
:deep(.tag),
:deep(.checklist-summary),
.assignees,
.icon,
.priority-label {
margin-top: .25rem;
margin-right: .25rem;
}
:deep(.checklist-summary) {
padding-left: 0;
}
.assignees {
display: flex;
@ -292,25 +310,34 @@ $task-background: var(--white);
.priority-label {
background: hsl(220, 13%, 91%);
}
.footer :deep(.checklist-summary) {
color: hsl(216.9, 19.1%, 26.7%); // grey-700
}
}
&.has-light-text {
--white: hsla(var(--white-h), var(--white-s), var(--white-l), var(--white-a)) !important;
color: var(--white);
.task-id {
color: var(--grey-200);
color: hsl(220, 13%, 91%); // grey-200;
}
.footer .icon,
.due-date,
.priority-label {
background: var(--grey-800);
background: hsl(215, 27.9%, 16.9%); // grey-800
}
.footer {
.icon svg {
fill: var(--white);
}
:deep(.checklist-summary) {
color: hsl(220, 13%, 91%); // grey-200
}
}
}
}
@ -318,4 +345,10 @@ $task-background: var(--white);
.kanban-card__done {
margin-right: .25rem;
}
.task-progress {
margin: 8px 0 0 0;
width: 100%;
height: 0.5rem;
}
</style>

View File

@ -2,8 +2,8 @@
<div class="label-wrapper">
<XLabel
v-for="label in labels"
:label="label"
:key="label.id"
:label="label"
/>
</div>
</template>
@ -25,5 +25,9 @@ defineProps({
<style lang="scss" scoped>
.label-wrapper {
display: inline;
:deep(.tag) {
margin-bottom: .25rem;
}
}
</style>

View File

@ -4,17 +4,39 @@
v-model.number="percentDone"
:disabled="disabled || undefined"
>
<option value="0">0%</option>
<option value="0.1">10%</option>
<option value="0.2">20%</option>
<option value="0.3">30%</option>
<option value="0.4">40%</option>
<option value="0.5">50%</option>
<option value="0.6">60%</option>
<option value="0.7">70%</option>
<option value="0.8">80%</option>
<option value="0.9">90%</option>
<option value="1">100%</option>
<option value="0">
0%
</option>
<option value="0.1">
10%
</option>
<option value="0.2">
20%
</option>
<option value="0.3">
30%
</option>
<option value="0.4">
40%
</option>
<option value="0.5">
50%
</option>
<option value="0.6">
60%
</option>
<option value="0.7">
70%
</option>
<option value="0.8">
80%
</option>
<option value="0.9">
90%
</option>
<option value="1">
100%
</option>
</select>
</div>
</template>

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