├── .drone.yml ├── .editorconfig ├── .env.local.example ├── .envrc ├── .eslintrc.cjs ├── .gitea └── issue_template.md ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── config.yml └── workflows │ └── lockdown.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode.example ├── extensions.json └── settings.json ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── cliff.toml ├── cypress.config.js ├── cypress ├── README.md ├── docker-compose.yml ├── e2e │ ├── misc │ │ └── menu.spec.ts │ ├── project │ │ ├── prepareProjects.ts │ │ ├── project-history.spec.ts │ │ ├── project-view-gantt.spec.ts │ │ ├── project-view-kanban.spec.ts │ │ ├── project-view-list.spec.ts │ │ ├── project-view-table.spec.ts │ │ └── project.spec.ts │ ├── sharing │ │ ├── linkShare.spec.ts │ │ └── team.spec.ts │ ├── task │ │ ├── overview.spec.ts │ │ └── task.spec.ts │ ├── tsconfig.json │ └── user │ │ ├── login.spec.ts │ │ ├── logout.spec.ts │ │ ├── registration.spec.ts │ │ └── settings.spec.ts ├── factories │ ├── bucket.ts │ ├── label_task.ts │ ├── labels.ts │ ├── link_sharing.ts │ ├── project.ts │ ├── task.ts │ ├── task_assignee.ts │ ├── task_attachments.ts │ ├── task_comment.ts │ ├── task_reminders.ts │ ├── team.ts │ ├── team_member.ts │ ├── user.ts │ └── users_project.ts ├── fixtures │ └── image.jpg └── support │ ├── authenticateUser.ts │ ├── commands.ts │ ├── component.index.html │ ├── component.ts │ ├── e2e.ts │ ├── factory.ts │ ├── seed.ts │ └── updateUserSettings.ts ├── docker ├── injector.sh ├── ipv6-disable.sh ├── nginx.conf └── templates │ └── default.conf.template ├── docs └── models-services.md ├── env.config.d.ts ├── env.d.ts ├── flake.lock ├── flake.nix ├── histoire.config.ts ├── index.html ├── netlify.toml ├── originalMedia ├── audio │ ├── pop.mp3 │ └── pop.wav ├── fonts │ ├── OpenSans-Italic[wdth,wght].ttf │ ├── OpenSans[wdth,wght].ttf │ └── Quicksand[wght].ttf ├── icons │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon-120x120.png │ ├── apple-touch-icon-152x152.png │ ├── apple-touch-icon-180x180.png │ ├── apple-touch-icon-60x60.png │ ├── apple-touch-icon-76x76.png │ ├── apple-touch-icon.png │ ├── badge-monochrome.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── icon-maskable.png │ ├── msapplication-icon-144x144.png │ ├── mstile-150x150.png │ └── safari-pinned-tab.svg └── images │ ├── cool.svg │ ├── llama-nightscape.png │ ├── llama-nightscape.svg │ ├── llama.svg │ ├── logo-full-pride.svg │ ├── logo-full-white.svg │ ├── logo-full.svg │ ├── logo.svg │ └── migration │ ├── microsoft-todo.svg │ ├── todoist.svg │ ├── trello.svg │ └── wunderlist.png ├── package.json ├── patches └── flexsearch@0.7.31.patch ├── pnpm-lock.yaml ├── public ├── favicon.ico ├── images │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── apple-touch-icon-60x60.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon.png │ │ ├── badge-monochrome.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── icon-maskable.png │ │ ├── msapplication-icon-144x144.png │ │ ├── mstile-150x150.png │ │ └── safari-pinned-tab.svg └── robots.txt ├── renovate.json ├── scripts ├── deploy-preview-netlify.mjs ├── deploy-preview-netlify.mjs.sha384 ├── fonts-download.sh └── fonts-subset.sh ├── src ├── App.vue ├── assets │ ├── audio │ │ └── pop.mp3 │ ├── checkbox.svg │ ├── fonts │ │ ├── OpenSans-BoldItalic_3ff98862.woff2 │ │ ├── OpenSans-Bold_eb52363b.woff2 │ │ ├── OpenSans-Italic[wght]_c9a8fe68.woff2 │ │ ├── OpenSans-RegularItalic_48244a7a.woff2 │ │ ├── OpenSans-Regular_d0acb717.woff2 │ │ ├── OpenSans[wght]_54a65da5.woff2 │ │ ├── Quicksand-Bold_20b26f76.woff2 │ │ ├── Quicksand-Regular_3e913e7e.woff2 │ │ ├── Quicksand-SemiBold_be48a442.woff2 │ │ └── Quicksand[wght]_87bdcc7f.woff2 │ ├── llama-cool.svg │ ├── llama-nightscape.jpg │ ├── llama.svg │ ├── logo-full-pride.svg │ ├── logo-full.svg │ ├── logo.svg │ └── no-auth-image.jpg ├── components │ ├── base │ │ ├── BaseButton.story.vue │ │ ├── BaseButton.vue │ │ ├── BaseCheckbox.vue │ │ └── Expandable.vue │ ├── date │ │ ├── dateRanges.ts │ │ ├── datemathHelp.story.vue │ │ ├── datemathHelp.vue │ │ └── datepickerWithRange.vue │ ├── home │ │ ├── AddToHomeScreen.vue │ │ ├── DemoMode.vue │ │ ├── Logo.vue │ │ ├── MenuButton.vue │ │ ├── PoweredByLink.vue │ │ ├── ProjectsNavigation.vue │ │ ├── ProjectsNavigationItem.vue │ │ ├── TheNavigation.vue │ │ ├── UpdateNotification.vue │ │ ├── contentAuth.vue │ │ ├── contentLinkShare.vue │ │ └── navigation.vue │ ├── input │ │ ├── AsyncEditor.ts │ │ ├── Button.story.vue │ │ ├── ColorPicker.story.vue │ │ ├── ColorPicker.vue │ │ ├── SelectProject.vue │ │ ├── SelectUser.vue │ │ ├── SimpleButton.vue │ │ ├── button.vue │ │ ├── datepicker.vue │ │ ├── datepickerInline.vue │ │ ├── editor │ │ │ ├── CommandsList.vue │ │ │ ├── EditorToolbar.vue │ │ │ ├── TipTap.vue │ │ │ ├── commands.ts │ │ │ ├── setLinkInEditor.ts │ │ │ ├── suggestion.ts │ │ │ └── types.ts │ │ ├── fancycheckbox.story.vue │ │ ├── fancycheckbox.vue │ │ ├── multiselect.vue │ │ └── password.vue │ ├── misc │ │ ├── ButtonLink.vue │ │ ├── Card.story.vue │ │ ├── CustomTransition.vue │ │ ├── Done.vue │ │ ├── Icon.ts │ │ ├── OpenQuickActions.vue │ │ ├── ProgressBar.story.vue │ │ ├── ProgressBar.vue │ │ ├── api-config.vue │ │ ├── card.vue │ │ ├── colorBubble.vue │ │ ├── create-edit.vue │ │ ├── dropdown-item.vue │ │ ├── dropdown.vue │ │ ├── error.vue │ │ ├── flatpickr │ │ │ └── Flatpickr.vue │ │ ├── keyboard-shortcuts │ │ │ ├── index.vue │ │ │ └── shortcuts.ts │ │ ├── legal.vue │ │ ├── loading.vue │ │ ├── message.vue │ │ ├── modal.vue │ │ ├── no-auth-wrapper.vue │ │ ├── nothing.vue │ │ ├── notification.vue │ │ ├── pagination.vue │ │ ├── popup.vue │ │ ├── ready.vue │ │ ├── shortcut.vue │ │ ├── subscription.vue │ │ └── user.vue │ ├── notifications │ │ └── notifications.vue │ ├── project │ │ ├── ProjectWrapper.vue │ │ ├── partials │ │ │ ├── ProjectCard.vue │ │ │ ├── ProjectCardGrid.vue │ │ │ ├── filter-popup.vue │ │ │ ├── filters.vue │ │ │ └── useProjectBackground.ts │ │ └── project-settings-dropdown.vue │ ├── quick-actions │ │ └── quick-actions.vue │ ├── sharing │ │ ├── linkSharing.vue │ │ └── userTeam.vue │ └── tasks │ │ ├── GanttChart.vue │ │ ├── TaskForm.vue │ │ ├── add-task.vue │ │ └── partials │ │ ├── assigneeList.vue │ │ ├── attachments.vue │ │ ├── checklist-summary.vue │ │ ├── comments.vue │ │ ├── createdUpdated.vue │ │ ├── date-table-cell.vue │ │ ├── defer-task.vue │ │ ├── description.vue │ │ ├── editAssignees.vue │ │ ├── editLabels.vue │ │ ├── heading.vue │ │ ├── kanban-card.vue │ │ ├── label.vue │ │ ├── labels.vue │ │ ├── percentDoneSelect.vue │ │ ├── priorityLabel.vue │ │ ├── prioritySelect.vue │ │ ├── projectSearch.vue │ │ ├── quick-add-magic.vue │ │ ├── relatedTasks.vue │ │ ├── reminder-detail.vue │ │ ├── reminder-period.vue │ │ ├── reminders.story.vue │ │ ├── reminders.vue │ │ ├── repeatAfter.vue │ │ ├── singleTaskInProject.vue │ │ ├── singleTaskInlineReadonly.vue │ │ └── sort.vue ├── composables │ ├── useAutoHeightTextarea.ts │ ├── useBodyClass.ts │ ├── useColorScheme.ts │ ├── useCopyToClipboard.ts │ ├── useDaytimeSalutation.ts │ ├── useMenuActive.ts │ ├── useOnline.ts │ ├── useRedirectToLastVisited.ts │ ├── useRenewTokenOnFocus.ts │ ├── useRouteFilters.ts │ ├── useRouteWithModal.ts │ ├── useTaskList.ts │ └── useTitle.ts ├── constants │ ├── date.ts │ ├── linkShareHash.ts │ ├── priorities.ts │ └── rights.ts ├── directives │ ├── cypress.ts │ ├── focus.ts │ └── shortcut.ts ├── helpers │ ├── attachments.ts │ ├── auth.ts │ ├── calculateItemPosition.ts │ ├── calculateTaskPosition.test.ts │ ├── canNestProjectDeeper.ts │ ├── case.ts │ ├── checkAndSetApiUrl.ts │ ├── checklistFromText.test.ts │ ├── checklistFromText.ts │ ├── closeWhenClickedOutside.ts │ ├── color │ │ ├── colorFromHex.test.ts │ │ ├── colorFromHex.ts │ │ ├── colorIsDark.test.ts │ │ ├── colorIsDark.ts │ │ └── randomColor.ts │ ├── createAsyncComponent.ts │ ├── downloadBlob.ts │ ├── editorContentEmpty.ts │ ├── fetcher.ts │ ├── flatpickrLanguage.ts │ ├── getBlobFromBlurHash.ts │ ├── getFullBaseUrl.ts │ ├── getHumanSize.ts │ ├── getInheritedBackgroundColor.ts │ ├── getProjectTitle.ts │ ├── hourToDaytime.test.ts │ ├── hourToDaytime.ts │ ├── inputPrompt.ts │ ├── isAppleDevice.ts │ ├── isEmail.ts │ ├── isValidHttpUrl.ts │ ├── parseDateOrNull.ts │ ├── parseSubtasksViaIndention.test.ts │ ├── parseSubtasksViaIndention.ts │ ├── playPop.ts │ ├── projectView.ts │ ├── randomId.ts │ ├── redirectToProvider.ts │ ├── replaceAll.ts │ ├── saveCollapsedBucketState.ts │ ├── saveLastVisited.ts │ ├── scrollIntoView.ts │ ├── setTitle.ts │ ├── time │ │ ├── calculateDayInterval.test.ts │ │ ├── calculateDayInterval.ts │ │ ├── calculateNearestHours.ts │ │ ├── calculateNearestTime.test.ts │ │ ├── createDateFromString.test.ts │ │ ├── createDateFromString.ts │ │ ├── formatDate.ts │ │ ├── getNextWeekDate.ts │ │ ├── isoToKebabDate.ts │ │ ├── parseBooleanProp.ts │ │ ├── parseDate.ts │ │ ├── parseDateOrString.ts │ │ ├── parseDateProp.ts │ │ ├── parseKebabDate.ts │ │ └── period.ts │ └── utils.ts ├── histoire.setup.ts ├── i18n │ ├── index.ts │ ├── lang │ │ ├── ar-SA.json │ │ ├── ca-ES.json │ │ ├── cs-CZ.json │ │ ├── da-DK.json │ │ ├── de-DE.json │ │ ├── de-swiss.json │ │ ├── en.json │ │ ├── eo-UY.json │ │ ├── es-ES.json │ │ ├── fr-FR.json │ │ ├── hu-HU.json │ │ ├── it-IT.json │ │ ├── ja-JP.json │ │ ├── ko-KR.json │ │ ├── nl-NL.json │ │ ├── no-NO.json │ │ ├── pl-PL.json │ │ ├── pt-BR.json │ │ ├── pt-PT.json │ │ ├── ro-RO.json │ │ ├── ru-RU.json │ │ ├── sk-SK.json │ │ ├── sl-SI.json │ │ ├── sr-CS.json │ │ ├── sv-SE.json │ │ ├── tr-TR.json │ │ ├── vi-VN.json │ │ ├── zh-CN.json │ │ └── zh-TW.json │ └── useDayjsLanguageSync.ts ├── indexes │ └── index.ts ├── main.ts ├── message │ └── index.ts ├── modelSchema │ └── common │ │ └── repeats.ts ├── modelTypes │ ├── IAbstract.ts │ ├── IApiToken.ts │ ├── IAttachment.ts │ ├── IAvatar.ts │ ├── IBackgroundImage.ts │ ├── IBucket.ts │ ├── ICaldavToken.ts │ ├── IEmailUpdate.ts │ ├── IFile.ts │ ├── ILabel.ts │ ├── ILabelTask.ts │ ├── ILinkShare.ts │ ├── INotification.ts │ ├── IPasswordReset.ts │ ├── IPasswordUpdate.ts │ ├── IProject.ts │ ├── IProjectDuplicate.ts │ ├── ISavedFilter.ts │ ├── ISubscription.ts │ ├── ITask.ts │ ├── ITaskAssignee.ts │ ├── ITaskComment.ts │ ├── ITaskRelation.ts │ ├── ITaskReminder.ts │ ├── ITeam.ts │ ├── ITeamMember.ts │ ├── ITeamProject.ts │ ├── ITeamShareBase.ts │ ├── ITotp.ts │ ├── IUser.ts │ ├── IUserProject.ts │ ├── IUserSettings.ts │ ├── IUserShareBase.ts │ └── IWebhook.ts ├── models │ ├── abstractModel.ts │ ├── apiTokenModel.ts │ ├── attachment.ts │ ├── avatar.ts │ ├── backgroundImage.ts │ ├── bucket.ts │ ├── caldavToken.ts │ ├── emailUpdate.ts │ ├── file.ts │ ├── label.ts │ ├── labelTask.ts │ ├── linkShare.ts │ ├── notification.ts │ ├── passwordReset.ts │ ├── passwordUpdate.ts │ ├── project.ts │ ├── projectDuplicateModel.ts │ ├── savedFilter.ts │ ├── subscription.ts │ ├── task.ts │ ├── taskAssignee.ts │ ├── taskComment.ts │ ├── taskRelation.ts │ ├── taskReminder.ts │ ├── team.ts │ ├── teamMember.ts │ ├── teamProject.ts │ ├── teamShareBase.ts │ ├── totp.ts │ ├── user.ts │ ├── userProject.ts │ ├── userSettings.ts │ ├── userShareBase.ts │ └── webhook.ts ├── modules │ ├── parseTaskText.test.ts │ ├── parseTaskText.ts │ ├── projectHistory.test.ts │ └── projectHistory.ts ├── pinia.ts ├── polyfills.ts ├── registerServiceWorker.ts ├── router │ └── index.ts ├── sentry.ts ├── services │ ├── abstractService.ts │ ├── accountDelete.ts │ ├── apiToken.ts │ ├── attachment.ts │ ├── avatar.ts │ ├── backgroundUnsplash.ts │ ├── backgroundUpload.ts │ ├── bucket.ts │ ├── caldavToken.ts │ ├── dataExport.ts │ ├── emailUpdate.ts │ ├── label.ts │ ├── labelTask.ts │ ├── linkShare.ts │ ├── migrator │ │ ├── abstractMigration.ts │ │ └── abstractMigrationFile.ts │ ├── notification.ts │ ├── passwordReset.ts │ ├── passwordUpdateService.ts │ ├── project.ts │ ├── projectDuplicateService.ts │ ├── projectUsers.ts │ ├── savedFilter.ts │ ├── subscription.ts │ ├── task.ts │ ├── taskAssignee.ts │ ├── taskCollection.ts │ ├── taskComment.ts │ ├── taskRelation.ts │ ├── team.ts │ ├── teamMember.ts │ ├── teamProject.ts │ ├── totp.ts │ ├── user.ts │ ├── userProject.ts │ ├── userSettings.ts │ └── webhook.ts ├── stores │ ├── attachments.ts │ ├── auth.ts │ ├── base.ts │ ├── config.ts │ ├── helper.ts │ ├── kanban.ts │ ├── labels.test.ts │ ├── labels.ts │ ├── projects.ts │ └── tasks.ts ├── styles │ ├── common-imports.scss │ ├── components │ │ ├── _index.scss │ │ ├── labels.scss │ │ ├── project.scss │ │ ├── task.scss │ │ ├── tasks.scss │ │ └── tooltip.scss │ ├── custom-properties │ │ ├── _index.scss │ │ ├── colors.scss │ │ └── shadows.scss │ ├── fonts.scss │ ├── global.scss │ ├── theme │ │ ├── _index.scss │ │ ├── background.scss │ │ ├── content.scss │ │ ├── flatpickr.scss │ │ ├── form.scss │ │ ├── helpers.scss │ │ ├── link-share.scss │ │ ├── loading.scss │ │ ├── navigation.scss │ │ ├── scrollbars.scss │ │ └── theme.scss │ └── transitions.scss ├── sw.ts ├── types │ ├── DateISO.ts │ ├── DateKebab.ts │ ├── IFilter.ts │ ├── IProvider.ts │ ├── IRelationKind.ts │ ├── IReminderPeriodRelativeTo.ts │ ├── IRepeatAfter.ts │ ├── IRepeatMode.ts │ ├── PartialWithId.ts │ ├── ProjectView.ts │ ├── cypress.d.ts │ ├── global-components.d.ts │ ├── shims-tsx.d.ts │ └── vue-fontawesome.ts ├── urls.ts ├── version.json └── views │ ├── 404.vue │ ├── About.vue │ ├── Home.vue │ ├── filters │ ├── FilterDelete.vue │ ├── FilterEdit.vue │ └── FilterNew.vue │ ├── labels │ ├── ListLabels.vue │ └── NewLabel.vue │ ├── migrate │ ├── Migration.vue │ ├── MigrationHandler.vue │ ├── icons │ │ ├── microsoft-todo.svg │ │ ├── ticktick.svg │ │ ├── todoist.svg │ │ ├── trello.svg │ │ ├── vikunja-file.png │ │ └── wunderlist.jpg │ └── migrators.ts │ ├── project │ ├── ListProjects.vue │ ├── NewProject.vue │ ├── ProjectGantt.vue │ ├── ProjectInfo.vue │ ├── ProjectKanban.vue │ ├── ProjectList.vue │ ├── ProjectTable.vue │ ├── helpers │ │ ├── useGanttFilters.ts │ │ └── useGanttTaskList.ts │ └── settings │ │ ├── archive.vue │ │ ├── background.vue │ │ ├── delete.vue │ │ ├── duplicate.vue │ │ ├── edit.vue │ │ ├── share.vue │ │ └── webhooks.vue │ ├── sharing │ └── LinkSharingAuth.vue │ ├── tasks │ ├── ShowTasks.vue │ └── TaskDetailView.vue │ ├── teams │ ├── EditTeam.vue │ ├── ListTeams.vue │ └── NewTeam.vue │ └── user │ ├── DataExportDownload.vue │ ├── Login.vue │ ├── OpenIdAuth.vue │ ├── PasswordReset.vue │ ├── Register.vue │ ├── RequestPasswordReset.vue │ ├── Settings.vue │ └── settings │ ├── ApiTokens.vue │ ├── Avatar.vue │ ├── Caldav.vue │ ├── DataExport.vue │ ├── Deletion.vue │ ├── EmailUpdate.vue │ ├── General.vue │ ├── PasswordUpdate.vue │ └── TOTP.vue ├── tsconfig.app.json ├── tsconfig.config.json ├── tsconfig.json ├── tsconfig.vitest.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = tab 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = false 11 | insert_final_newline = false 12 | 13 | [*.vue] 14 | indent_style = tab 15 | 16 | [*.{yaml,yml}] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.json] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.{scss,css}] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | [.nvmrc] 29 | insert_final_newline = false -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # (1) Duplicate this file and remove the '.example' suffix. 2 | # Naming this file '.env.local' is a Vite convention to prevent accidentally 3 | # submitting to git. 4 | # For more info see: https://vitejs.dev/guide/env-and-mode.html#env-files 5 | 6 | # (2) Comment in and adjust the values as needed. 7 | 8 | # VITE_IS_ONLINE=true 9 | # SENTRY_AUTH_TOKEN=YOUR_TOKEN 10 | # SENTRY_ORG=vikunja 11 | # SENTRY_PROJECT=frontend-oss 12 | # VIKUNJA_FRONTEND_BASE=/custom-subpath -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitea/issue_template.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | **Version information:** 9 | 10 | Frontend Version: 11 | API Version: 12 | Browser and OS Version: 13 | 14 | **Steps to reproduce:** 15 | 16 | 19 | 20 | 1. 21 | 2. 22 | ... 23 | 24 | **Expected behavior:** 25 | 26 | 29 | 30 | 31 | 32 | **Actual behavior:** 33 | 34 | 37 | 38 | 39 | 40 | **Checklist:** 41 | 42 | * [ ] I have provided all required information 43 | * [ ] I am using the latest release or the latest unstable build 44 | * [ ] I was able to reproduce the bug on [try](https://try.vikunja.io) 45 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kolaente 2 | open_collective: vikunja 3 | custom: ["https://vikunja.cloud", "https://www.buymeacoffee.com/kolaente"] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: API issues 4 | url: https://code.vikunja.io/api/issues 5 | about: This is the frontend repo. Please open api-related bug reports and discussions in the api 0repo. Not sure if your issue is frontend or api? Ask in Matrix or the forum first. 6 | - name: Forum 7 | url: https://community.vikunja.io/ 8 | about: Feature Requests, Questions, configuration or deployment problems should be discussed in the forum. 9 | - name: Security-related issues 10 | url: https://vikunja.io/contact/#security 11 | about: For security concerns, please send a mail to security@vikunja.io instead of opening a public issue. 12 | - name: Chat on Matrix 13 | url: https://matrix.to/#/#vikunja:matrix.org 14 | about: Please ask any quick questions here. 15 | - name: Translations 16 | url: https://crowdin.com/project/vikunja 17 | about: Any problems or requests for new languages about translations should be handled in crowdin. 18 | -------------------------------------------------------------------------------- /.github/workflows/lockdown.yml: -------------------------------------------------------------------------------- 1 | name: 'Repo Lockdown' 2 | 3 | on: 4 | pull_request_target: 5 | types: opened 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | action: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/repo-lockdown@v4 16 | with: 17 | pr-comment: 'Hi! Thank you for your contribution. 18 | 19 | This repo is only a mirror and unfortunately we can''t accept PRs made here. Please re-submit your changes to [our Gitea instance](https://kolaente.dev/vikunja/frontend/pulls). 20 | 21 | Also check out the [contribution guidelines](https://vikunja.io/docs/development/#pull-requests). 22 | 23 | Thank you for your understanding.' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | stats.html 10 | 11 | node_modules 12 | .DS_Store 13 | /dist* 14 | coverage 15 | *.zip 16 | .direnv/ 17 | 18 | # Test files 19 | cypress/screenshots 20 | cypress/videos 21 | 22 | # local env files 23 | .env.local 24 | .env.*.local 25 | 26 | # Editor directories and files 27 | .vscode 28 | .idea 29 | *.suo 30 | *.ntvs* 31 | *.njsproj 32 | *.sln 33 | *.sw* 34 | !rollup.sw.js 35 | 36 | 37 | # Local Netlify folder 38 | .netlify 39 | 40 | # histoire 41 | .histoire -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | fetch-timeout=100000 2 | 3 | # pnpm settings 4 | # The following settings prepare for the new default value of pnpm 8 5 | # they can be removed directly after having moved to pnpm 8 6 | auto-install-peers=true 7 | dedupe-peer-dependents=true 8 | resolve-peers-from-workspace-root=true 9 | save-workspace-protocol=rolling 10 | resolution-mode=lowest-direct 11 | publishConfig.linkDirectory=true 12 | 13 | # remove some time after having moved to pnpm 8 14 | use-lockfile-v6=true 15 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.11.0 -------------------------------------------------------------------------------- /.vscode.example/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "codezombiech.gitignore", 4 | "dbaeumer.vscode-eslint", 5 | "editorconfig.editorconfig", 6 | "vue.volar", 7 | "vue.vscode-typescript-vue-plugin", 8 | "lokalise.i18n-ally", 9 | "mgmcdermott.vscode-language-babel", 10 | "mikestead.dotenv", 11 | "Syler.sass-indented", 12 | "zixuanchen.vitest-explorer" 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode.example/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.packageManager": "pnpm", 3 | "editor.formatOnSave": false, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": true 6 | }, 7 | "eslint.format.enable": true, 8 | "[javascript]": { 9 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 10 | }, 11 | "[typescript]": { 12 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 13 | }, 14 | 15 | // https://eslint.vuejs.org/user-guide/#editor-integrations 16 | "eslint.validate": [ 17 | "javascript", 18 | "javascriptreact", 19 | "vue" 20 | ], 21 | 22 | "volar.completion.preferredTagNameCase": "pascal", 23 | 24 | // disable vetur in case it is installed 25 | "vetur.validation.template": false, 26 | 27 | // i18n ally 28 | "i18n-ally.localesPaths": [ 29 | "src/i18n/lang" 30 | ], 31 | "i18n-ally.sortKeys": true, 32 | "i18n-ally.keepFulfilled": true, 33 | "i18n-ally.keystyle": "nested" 34 | } -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'cypress' 2 | 3 | export default defineConfig({ 4 | env: { 5 | API_URL: 'http://localhost:3456/api/v1', 6 | TEST_SECRET: 'averyLongSecretToSe33dtheDB', 7 | }, 8 | video: false, 9 | retries: { 10 | runMode: 2, 11 | }, 12 | projectId: '181c7x', 13 | e2e: { 14 | specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}', 15 | baseUrl: 'http://127.0.0.1:4173', 16 | experimentalRunAllSpecs: true, 17 | // testIsolation: false, 18 | }, 19 | component: { 20 | devServer: { 21 | framework: 'vue', 22 | bundler: 'vite', 23 | }, 24 | }, 25 | viewportWidth: 1600, 26 | viewportHeight: 900, 27 | experimentalMemoryManagement: true, 28 | }) 29 | -------------------------------------------------------------------------------- /cypress/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | api: 5 | image: vikunja/api:unstable 6 | environment: 7 | VIKUNJA_LOG_LEVEL: DEBUG 8 | VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB 9 | ports: 10 | - 3456:3456 11 | cypress: 12 | image: cypress/browsers:node18.12.0-chrome107 13 | volumes: 14 | - ..:/project 15 | - $HOME/.cache:/home/node/.cache/ 16 | user: node 17 | working_dir: /project 18 | environment: 19 | CYPRESS_API_URL: http://api:3456/api/v1 20 | CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB 21 | -------------------------------------------------------------------------------- /cypress/e2e/misc/menu.spec.ts: -------------------------------------------------------------------------------- 1 | import {createFakeUserAndLogin} from '../../support/authenticateUser' 2 | 3 | describe('The Menu', () => { 4 | createFakeUserAndLogin() 5 | 6 | beforeEach(() => { 7 | cy.visit('/') 8 | }) 9 | 10 | it('Is visible by default on desktop', () => { 11 | cy.get('.menu-container') 12 | .should('have.class', 'is-active') 13 | }) 14 | 15 | it('Can be hidden on desktop', () => { 16 | cy.get('button.menu-show-button:visible') 17 | .click() 18 | cy.get('.menu-container') 19 | .should('not.have.class', 'is-active') 20 | }) 21 | 22 | it('Is hidden by default on mobile', () => { 23 | cy.viewport('iphone-8') 24 | cy.get('.menu-container') 25 | .should('not.have.class', 'is-active') 26 | }) 27 | 28 | it('Is can be shown on mobile', () => { 29 | cy.viewport('iphone-8') 30 | cy.get('button.menu-show-button:visible') 31 | .click() 32 | cy.get('.menu-container') 33 | .should('have.class', 'is-active') 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /cypress/e2e/project/prepareProjects.ts: -------------------------------------------------------------------------------- 1 | import {ProjectFactory} from '../../factories/project' 2 | import {TaskFactory} from '../../factories/task' 3 | 4 | export function createProjects() { 5 | const projects = ProjectFactory.create(1, { 6 | title: 'First Project' 7 | }) 8 | TaskFactory.truncate() 9 | return projects 10 | } 11 | 12 | export function prepareProjects(setProjects = (...args: any[]) => {}) { 13 | beforeEach(() => { 14 | const projects = createProjects() 15 | setProjects(projects) 16 | }) 17 | } -------------------------------------------------------------------------------- /cypress/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["./**/*", "../support/**/*", "../factories/**/*"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "isolatedModules": false, 7 | "target": "ES2015", 8 | "lib": ["ESNext", "dom"], 9 | "types": ["cypress"], 10 | "ignoreDeprecations": "5.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /cypress/e2e/user/logout.spec.ts: -------------------------------------------------------------------------------- 1 | import {createFakeUserAndLogin} from '../../support/authenticateUser' 2 | import {createProjects} from '../project/prepareProjects' 3 | 4 | function logout() { 5 | cy.get('.navbar .username-dropdown-trigger') 6 | .click() 7 | cy.get('.navbar .dropdown-item') 8 | .contains('Logout') 9 | .click() 10 | } 11 | 12 | describe('Log out', () => { 13 | createFakeUserAndLogin() 14 | 15 | it('Logs the user out', () => { 16 | cy.visit('/') 17 | 18 | expect(localStorage.getItem('token')).to.not.eq(null) 19 | 20 | logout() 21 | 22 | cy.url() 23 | .should('contain', '/login') 24 | .then(() => { 25 | expect(localStorage.getItem('token')).to.eq(null) 26 | }) 27 | }) 28 | 29 | it.skip('Should clear the project history after logging the user out', () => { 30 | const projects = createProjects() 31 | cy.visit(`/projects/${projects[0].id}`) 32 | .then(() => { 33 | expect(localStorage.getItem('projectHistory')).to.not.eq(null) 34 | }) 35 | 36 | logout() 37 | 38 | cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible 39 | 40 | cy.url() 41 | .should('contain', '/login') 42 | .then(() => { 43 | expect(localStorage.getItem('projectHistory')).to.eq(null) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /cypress/factories/bucket.ts: -------------------------------------------------------------------------------- 1 | import {faker} from '@faker-js/faker' 2 | import {Factory} from '../support/factory' 3 | 4 | export class BucketFactory extends Factory { 5 | static table = 'buckets' 6 | 7 | static factory() { 8 | const now = new Date() 9 | 10 | return { 11 | id: '{increment}', 12 | title: faker.lorem.words(3), 13 | project_id: 1, 14 | created_by_id: 1, 15 | created: now.toISOString(), 16 | updated: now.toISOString(), 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cypress/factories/label_task.ts: -------------------------------------------------------------------------------- 1 | import {Factory} from '../support/factory' 2 | 3 | export class LabelTaskFactory extends Factory { 4 | static table = 'label_tasks' 5 | 6 | static factory() { 7 | const now = new Date() 8 | 9 | return { 10 | id: '{increment}', 11 | task_id: 1, 12 | label_id: 1, 13 | created: now.toISOString(), 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /cypress/factories/labels.ts: -------------------------------------------------------------------------------- 1 | import {faker} from '@faker-js/faker' 2 | 3 | import {Factory} from '../support/factory' 4 | 5 | export class LabelFactory extends Factory { 6 | static table = 'labels' 7 | 8 | static factory() { 9 | const now = new Date() 10 | 11 | return { 12 | id: '{increment}', 13 | title: faker.lorem.words(2), 14 | description: faker.lorem.text(10), 15 | hex_color: (Math.random()*0xFFFFFF<<0).toString(16), // random 6-digit hex number 16 | created_by_id: 1, 17 | created: now.toISOString(), 18 | updated: now.toISOString(), 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /cypress/factories/link_sharing.ts: -------------------------------------------------------------------------------- 1 | import {Factory} from '../support/factory' 2 | import {faker} from '@faker-js/faker' 3 | 4 | export class LinkShareFactory extends Factory { 5 | static table = 'link_shares' 6 | 7 | static factory() { 8 | const now = new Date() 9 | 10 | return { 11 | id: '{increment}', 12 | hash: faker.random.word(32), 13 | project_id: 1, 14 | right: 0, 15 | sharing_type: 0, 16 | shared_by_id: 1, 17 | created: now.toISOString(), 18 | updated: now.toISOString(), 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cypress/factories/project.ts: -------------------------------------------------------------------------------- 1 | import {Factory} from '../support/factory' 2 | import {faker} from '@faker-js/faker' 3 | 4 | export class ProjectFactory extends Factory { 5 | static table = 'projects' 6 | 7 | static factory() { 8 | const now = new Date() 9 | 10 | return { 11 | id: '{increment}', 12 | title: faker.lorem.words(3), 13 | owner_id: 1, 14 | created: now.toISOString(), 15 | updated: now.toISOString(), 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /cypress/factories/task.ts: -------------------------------------------------------------------------------- 1 | import {faker} from '@faker-js/faker' 2 | import {Factory} from '../support/factory' 3 | 4 | export class TaskFactory extends Factory { 5 | static table = 'tasks' 6 | 7 | static factory() { 8 | const now = new Date() 9 | 10 | return { 11 | id: '{increment}', 12 | title: faker.lorem.words(3), 13 | done: false, 14 | project_id: 1, 15 | created_by_id: 1, 16 | index: '{increment}', 17 | position: '{increment}', 18 | created: now.toISOString(), 19 | updated: now.toISOString() 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cypress/factories/task_assignee.ts: -------------------------------------------------------------------------------- 1 | import {Factory} from '../support/factory' 2 | 3 | export class TaskAssigneeFactory extends Factory { 4 | static table = 'task_assignees' 5 | 6 | static factory() { 7 | const now = new Date() 8 | 9 | return { 10 | id: '{increment}', 11 | task_id: 1, 12 | user_id: 1, 13 | created: now.toISOString(), 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /cypress/factories/task_attachments.ts: -------------------------------------------------------------------------------- 1 | import {Factory} from '../support/factory' 2 | 3 | export class TaskAttachmentFactory extends Factory { 4 | static table = 'task_attachments' 5 | 6 | static factory() { 7 | const now = new Date() 8 | 9 | return { 10 | id: '{increment}', 11 | task_id: 1, 12 | file_id: 1, 13 | created: now.toISOString(), 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /cypress/factories/task_comment.ts: -------------------------------------------------------------------------------- 1 | import {faker} from '@faker-js/faker' 2 | 3 | import {Factory} from '../support/factory' 4 | 5 | export class TaskCommentFactory extends Factory { 6 | static table = 'task_comments' 7 | 8 | static factory() { 9 | const now = new Date() 10 | 11 | return { 12 | id: '{increment}', 13 | comment: faker.lorem.text(3), 14 | author_id: 1, 15 | task_id: 1, 16 | created: now.toISOString(), 17 | updated: now.toISOString() 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cypress/factories/task_reminders.ts: -------------------------------------------------------------------------------- 1 | import {Factory} from '../support/factory' 2 | 3 | export class TaskReminderFactory extends Factory { 4 | static table = 'task_reminders' 5 | 6 | static factory() { 7 | const now = new Date() 8 | 9 | return { 10 | id: '{increment}', 11 | task_id: 1, 12 | reminder: now.toISOString(), 13 | created: now.toISOString(), 14 | relative_to: '', 15 | relative_period: 0, 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /cypress/factories/team.ts: -------------------------------------------------------------------------------- 1 | import {faker} from '@faker-js/faker' 2 | import {Factory} from '../support/factory' 3 | 4 | export class TeamFactory extends Factory { 5 | static table = 'teams' 6 | 7 | static factory() { 8 | const now = new Date() 9 | 10 | return { 11 | name: faker.lorem.words(3), 12 | created_by_id: 1, 13 | created: now.toISOString(), 14 | updated: now.toISOString(), 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cypress/factories/team_member.ts: -------------------------------------------------------------------------------- 1 | import {Factory} from '../support/factory' 2 | 3 | export class TeamMemberFactory extends Factory { 4 | static table = 'team_members' 5 | 6 | static factory() { 7 | return { 8 | team_id: 1, 9 | user_id: 1, 10 | admin: false, 11 | created: new Date().toISOString(), 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /cypress/factories/user.ts: -------------------------------------------------------------------------------- 1 | import {faker} from '@faker-js/faker' 2 | 3 | import {Factory} from '../support/factory' 4 | 5 | export class UserFactory extends Factory { 6 | static table = 'users' 7 | 8 | static factory() { 9 | const now = new Date() 10 | 11 | return { 12 | id: '{increment}', 13 | username: faker.lorem.word(10) + faker.datatype.uuid(), 14 | password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234 15 | status: 0, 16 | issuer: 'local', 17 | created: now.toISOString(), 18 | updated: now.toISOString(), 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /cypress/factories/users_project.ts: -------------------------------------------------------------------------------- 1 | import {Factory} from '../support/factory' 2 | 3 | export class UserProjectFactory extends Factory { 4 | static table = 'users_projects' 5 | 6 | static factory() { 7 | const now = new Date() 8 | 9 | return { 10 | id: '{increment}', 11 | project_id: 1, 12 | user_id: 1, 13 | right: 0, 14 | created: now.toISOString(), 15 | updated: now.toISOString(), 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /cypress/fixtures/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/cypress/fixtures/image.jpg -------------------------------------------------------------------------------- /cypress/support/authenticateUser.ts: -------------------------------------------------------------------------------- 1 | 2 | // This authenticates a user and puts the token in local storage which allows us to perform authenticated requests. 3 | // Built after https://github.com/cypress-io/cypress-example-recipes/tree/bd2d6ffb33214884cab343d38e7f9e6ebffb323f/examples/logging-in__jwt 4 | 5 | import {UserFactory} from '../factories/user' 6 | 7 | export function login(user, cacheAcrossSpecs = false) { 8 | if (!user) { 9 | throw new Error('Needs user') 10 | } 11 | // Caching session when logging in via page visit 12 | cy.session(`user__${user.username}`, () => { 13 | cy.request('POST', `${Cypress.env('API_URL')}/login`, { 14 | username: user.username, 15 | password: '1234', 16 | }).then(({ body }) => { 17 | window.localStorage.setItem('token', body.token) 18 | }) 19 | }, { 20 | cacheAcrossSpecs, 21 | }) 22 | } 23 | 24 | export function createFakeUserAndLogin() { 25 | let user 26 | before(() => { 27 | user = UserFactory.create(1)[0] 28 | }) 29 | 30 | beforeEach(() => { 31 | login(user, true) 32 | }) 33 | 34 | return user 35 | } -------------------------------------------------------------------------------- /cypress/support/component.index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/vue' 23 | // Ensure global styles are loaded 24 | import '../../src/styles/global.scss'; 25 | 26 | Cypress.Commands.add('mount', mount) 27 | 28 | // Example use: 29 | // cy.mount(MyComponent) -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | 2 | import './commands' 3 | import '@4tw/cypress-drag-drop' 4 | 5 | // see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275 6 | Cypress.on('window:before:load', (win) => { 7 | // disable service workers 8 | // @ts-ignore 9 | delete win.navigator.__proto__.ServiceWorker 10 | }) -------------------------------------------------------------------------------- /cypress/support/factory.ts: -------------------------------------------------------------------------------- 1 | import {seed} from './seed' 2 | 3 | /** 4 | * A factory makes it easy to seed the database with data. 5 | */ 6 | export class Factory { 7 | static table: string | null = null 8 | 9 | static factory() { 10 | return {} 11 | } 12 | 13 | /** 14 | * Seeds a bunch of fake data into the database. 15 | * 16 | * Takes an override object as its single argument which will override the data from the factory. 17 | * If the value of one of the override fields is `{increment}` that value will be replaced with an incrementing 18 | * number through all created entities. 19 | * 20 | * @param override 21 | * @returns {[]} 22 | */ 23 | static create(count = 1, override = {}, truncate = true) { 24 | const data = [] 25 | 26 | for (let i = 1; i <= count; i++) { 27 | const entry = { 28 | ...this.factory(), 29 | ...override, 30 | } 31 | for (const e in entry) { 32 | if(typeof entry[e] === 'function') { 33 | entry[e] = entry[e](i) 34 | continue 35 | } 36 | if (entry[e] === '{increment}') { 37 | entry[e] = i 38 | } 39 | } 40 | data.push(entry) 41 | } 42 | 43 | seed(this.table, data, truncate) 44 | 45 | return data 46 | } 47 | 48 | static truncate() { 49 | seed(this.table, null) 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /cypress/support/seed.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Seeds a db table with data. If a data object is provided as the second argument, it will load the fixtures 3 | * file for the table and merge the data from it with the passed data. This allows you to override specific 4 | * fields of the fixtures without having to redeclare the whole fixture. 5 | * 6 | * Passing null as the second argument empties the table. 7 | * 8 | * @param table 9 | * @param data 10 | */ 11 | export function seed(table, data = {}, truncate = true) { 12 | if (data === null) { 13 | data = [] 14 | } 15 | 16 | cy.request({ 17 | method: 'PATCH', 18 | url: `${Cypress.env('API_URL')}/test/${table}?truncate=${truncate ? 'true' : 'false'}`, 19 | headers: { 20 | 'Authorization': Cypress.env('TEST_SECRET'), 21 | }, 22 | body: data, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /cypress/support/updateUserSettings.ts: -------------------------------------------------------------------------------- 1 | 2 | export function updateUserSettings(settings) { 3 | const token = `Bearer ${window.localStorage.getItem('token')}` 4 | 5 | return cy.request({ 6 | method: 'GET', 7 | url: `${Cypress.env('API_URL')}/user`, 8 | headers: { 9 | 'Authorization': token, 10 | }, 11 | }) 12 | .its('body') 13 | .then(oldSettings => { 14 | return cy.request({ 15 | method: 'POST', 16 | url: `${Cypress.env('API_URL')}/user/settings/general`, 17 | headers: { 18 | 'Authorization': token, 19 | }, 20 | body: { 21 | ...oldSettings, 22 | ...settings, 23 | }, 24 | }) 25 | }) 26 | } -------------------------------------------------------------------------------- /docker/injector.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | echo "info: API URL is $VIKUNJA_API_URL" 5 | echo "info: Sentry enabled: $VIKUNJA_SENTRY_ENABLED" 6 | 7 | # Escape the variable to prevent sed from complaining 8 | VIKUNJA_API_URL="$(echo "$VIKUNJA_API_URL" | sed -r 's/([:;])/\\\1/g')" 9 | VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')" 10 | 11 | sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html 12 | sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html 13 | sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html 14 | sed -ri "s:^(\s*window.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html 15 | sed -ri "s:^(\s*window.ALLOW_ICON_CHANGES\s*=)\s*.+:\1 ${VIKUNJA_ALLOW_ICON_CHANGES}:g" /usr/share/nginx/html/index.html 16 | sed -ri "s:^(\s*window.CUSTOM_LOGO_URL\s*=)\s*.+:\1 ${VIKUNJA_CUSTOM_LOGO_URL}:g" /usr/share/nginx/html/index.html 17 | 18 | date -uIseconds | xargs echo 'info: started at' 19 | -------------------------------------------------------------------------------- /docker/ipv6-disable.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -e 3 | 4 | if [ ! -f "/proc/net/if_inet6" ]; then 5 | echo "info: IPv6 is not available! Removing IPv6 listen configuration" 6 | find /etc/nginx/conf.d -name '*.conf' -type f | \ 7 | while IFS= read -r CONFIG; do 8 | sed -r '/^\s*listen\s*\[::\]:.+$/d' "$CONFIG" > "$CONFIG.temp" 9 | if ! diff -U 5 "$CONFIG" "$CONFIG.temp" > "$CONFIG.diff"; then 10 | echo "info: Removing IPv6 lines from $CONFIG" | \ 11 | cat - "$CONFIG.diff" 12 | echo "# IPv6 is disabled because /proc/net/if_inet6 was not found" | \ 13 | cat - "$CONFIG.temp" > "$CONFIG" 14 | else 15 | echo "info: Skipping $CONFIG because it does not have IPv6 listen" 16 | fi 17 | rm -f "$CONFIG.temp" "$CONFIG.diff" 18 | done 19 | fi 20 | -------------------------------------------------------------------------------- /env.config.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'postcss-easings' { 2 | import postcssEasings from 'postcss-easings' 3 | export default postcssEasings 4 | } 5 | 6 | declare module 'postcss-easing-gradients' { 7 | import postcssEasingGradients from 'postcss-easing-gradients' 8 | export default postcssEasingGradients 9 | } -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | 6 | declare module 'postcss-focus-within/browser' { 7 | import focusWithinInit from 'postcss-focus-within/browser' 8 | export default focusWithinInit 9 | } 10 | 11 | declare module 'css-has-pseudo/browser' { 12 | import cssHasPseudo from 'css-has-pseudo/browser' 13 | export default cssHasPseudo 14 | } 15 | 16 | interface ImportMetaEnv { 17 | readonly VIKUNJA_API_URL?: string 18 | readonly VIKUNJA_HTTP_PORT?: number 19 | readonly VIKUNJA_HTTPS_PORT?: number 20 | 21 | readonly VIKUNJA_SENTRY_ENABLED?: boolean 22 | readonly VIKUNJA_SENTRY_DSN?: string 23 | 24 | readonly SENTRY_AUTH_TOKEN?: string 25 | readonly SENTRY_ORG?: string 26 | readonly SENTRY_PROJECT?: string 27 | 28 | readonly VITE_IS_ONLINE: boolean 29 | } 30 | 31 | interface ImportMeta { 32 | readonly env: ImportMetaEnv 33 | } -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1701336116, 6 | "narHash": "sha256-kEmpezCR/FpITc6yMbAh4WrOCiT2zg5pSjnKrq51h5Y=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "f5c27c6136db4d76c30e533c20517df6864c46ee", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "id": "nixpkgs", 14 | "type": "indirect" 15 | } 16 | }, 17 | "root": { 18 | "inputs": { 19 | "nixpkgs": "nixpkgs" 20 | } 21 | } 22 | }, 23 | "root": "root", 24 | "version": 7 25 | } 26 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Vikunja frontend dev environment"; 3 | 4 | outputs = { self, nixpkgs }: 5 | let pkgs = nixpkgs.legacyPackages.x86_64-linux; 6 | in { 7 | defaultPackage.x86_64-linux = 8 | pkgs.mkShell { buildInputs = [ pkgs.nodePackages.pnpm pkgs.cypress pkgs.git-cliff ]; }; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /histoire.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, defaultColors} from 'histoire' 2 | import {HstVue} from '@histoire/plugin-vue' 3 | import {HstScreenshot} from '@histoire/plugin-screenshot' 4 | 5 | export default defineConfig({ 6 | setupFile: './src/histoire.setup.ts', 7 | storyIgnored: [ 8 | '**/node_modules/**', 9 | '**/dist/**', 10 | // see https://kolaente.dev/vikunja/frontend/pulls/2724#issuecomment-42012 11 | '**/.direnv/**', 12 | ], 13 | plugins: [ 14 | HstVue(), 15 | HstScreenshot({ 16 | // Options here 17 | }), 18 | ], 19 | theme: { 20 | title: 'Vikunja', 21 | colors: { 22 | // https://histoire.dev/guide/config.html#builtin-colors 23 | gray: defaultColors.zinc, 24 | primary: defaultColors.cyan, 25 | }, 26 | // logo: { 27 | // square: './img/square.png', 28 | // light: './img/light.png', 29 | // dark: './img/dark.png', 30 | // }, 31 | logoHref: 'https://vikunja.io', 32 | // favicon: './favicon.ico', 33 | }, 34 | }) -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "pnpm run build" 3 | publish = "dist-preview" 4 | 5 | [[redirects]] 6 | from = "/*" 7 | to = "/index.html" 8 | status = 200 9 | 10 | [[headers]] 11 | for = "/*" 12 | [headers.values] 13 | X-Frame-Options = "DENY" 14 | X-XSS-Protection = "1; mode=block" 15 | X-Robots-Tag = "noindex" 16 | -------------------------------------------------------------------------------- /originalMedia/audio/pop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/audio/pop.mp3 -------------------------------------------------------------------------------- /originalMedia/audio/pop.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/audio/pop.wav -------------------------------------------------------------------------------- /originalMedia/fonts/OpenSans-Italic[wdth,wght].ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/fonts/OpenSans-Italic[wdth,wght].ttf -------------------------------------------------------------------------------- /originalMedia/fonts/OpenSans[wdth,wght].ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/fonts/OpenSans[wdth,wght].ttf -------------------------------------------------------------------------------- /originalMedia/fonts/Quicksand[wght].ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/fonts/Quicksand[wght].ttf -------------------------------------------------------------------------------- /originalMedia/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /originalMedia/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /originalMedia/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /originalMedia/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /originalMedia/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /originalMedia/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /originalMedia/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /originalMedia/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /originalMedia/icons/badge-monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/badge-monochrome.png -------------------------------------------------------------------------------- /originalMedia/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/favicon-16x16.png -------------------------------------------------------------------------------- /originalMedia/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/favicon-32x32.png -------------------------------------------------------------------------------- /originalMedia/icons/icon-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/icon-maskable.png -------------------------------------------------------------------------------- /originalMedia/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /originalMedia/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/icons/mstile-150x150.png -------------------------------------------------------------------------------- /originalMedia/images/llama-nightscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/images/llama-nightscape.png -------------------------------------------------------------------------------- /originalMedia/images/migration/trello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /originalMedia/images/migration/wunderlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/originalMedia/images/migration/wunderlist.png -------------------------------------------------------------------------------- /patches/flexsearch@0.7.31.patch: -------------------------------------------------------------------------------- 1 | diff --git a/index.d.ts b/index.d.ts 2 | deleted file mode 100644 3 | index 9f39f41073864b83968bdaa242ac4e3c3149685a..0000000000000000000000000000000000000000 4 | diff --git a/package.json b/package.json 5 | index 8968f5bf8010ff194240591c8b83299f7328e79d..6d84b6f590a841b129ed8b3860cb786df5a185c0 100644 6 | --- a/package.json 7 | +++ b/package.json 8 | @@ -22,8 +22,6 @@ 9 | }, 10 | "main": "dist/flexsearch.bundle.js", 11 | "browser": "dist/flexsearch.bundle.js", 12 | - "module": "dist/module/index.js", 13 | - "types": "./index.d.ts", 14 | "preferGlobal": false, 15 | "repository": { 16 | "type": "git", -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/favicon.ico -------------------------------------------------------------------------------- /public/images/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/images/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/images/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/images/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/images/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/images/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/images/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/images/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/icons/badge-monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/badge-monochrome.png -------------------------------------------------------------------------------- /public/images/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/icons/icon-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/icon-maskable.png -------------------------------------------------------------------------------- /public/images/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /public/images/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/public/images/icons/mstile-150x150.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "labels": ["dependencies"], 4 | "extends": [ 5 | "config:js-app" 6 | ], 7 | "hostRules": [ 8 | { 9 | "timeout": 600000 10 | } 11 | ], 12 | "packageRules": [ 13 | { 14 | "matchPackageNames": ["happy-dom"], 15 | "extends": ["schedule:weekly"] 16 | }, 17 | { 18 | "groupName": "caniuse-and-related", 19 | "matchPackageNames": ["caniuse-lite", "browserslist"], 20 | "extends": ["schedule:weekly"] 21 | }, 22 | { 23 | "groupName": "vueuse", 24 | "matchPackagePrefixes": [ 25 | "@vueuse/" 26 | ] 27 | }, 28 | { 29 | "groupName": "histoire", 30 | "matchPackagePrefixes": [ 31 | "@histoire/", 32 | "histoire" 33 | ] 34 | }, 35 | { 36 | "groupName": "tiptap", 37 | "matchPackagePrefixes": [ 38 | "@tiptap/", 39 | "tiptap" 40 | ] 41 | }, 42 | { 43 | "matchDepTypes": ["devDependencies"], 44 | "groupName": "dev-dependencies", 45 | "extends": ["schedule:daily"] 46 | } 47 | ] 48 | } -------------------------------------------------------------------------------- /scripts/deploy-preview-netlify.mjs.sha384: -------------------------------------------------------------------------------- 1 | 4a7c1293c7b12e9ab476cdf35251a407c6a1cd005d22c06df994222cccfb25cde5f47d15866a098c9d739778fee4dc19 ./scripts/deploy-preview-netlify.mjs 2 | -------------------------------------------------------------------------------- /src/assets/audio/pop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/audio/pop.mp3 -------------------------------------------------------------------------------- /src/assets/checkbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-BoldItalic_3ff98862.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/OpenSans-BoldItalic_3ff98862.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Bold_eb52363b.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/OpenSans-Bold_eb52363b.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Italic[wght]_c9a8fe68.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/OpenSans-Italic[wght]_c9a8fe68.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-RegularItalic_48244a7a.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/OpenSans-RegularItalic_48244a7a.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans-Regular_d0acb717.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/OpenSans-Regular_d0acb717.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/OpenSans[wght]_54a65da5.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/OpenSans[wght]_54a65da5.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Quicksand-Bold_20b26f76.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/Quicksand-Bold_20b26f76.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Quicksand-Regular_3e913e7e.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/Quicksand-Regular_3e913e7e.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Quicksand-SemiBold_be48a442.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/Quicksand-SemiBold_be48a442.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Quicksand[wght]_87bdcc7f.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/fonts/Quicksand[wght]_87bdcc7f.woff2 -------------------------------------------------------------------------------- /src/assets/llama-nightscape.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/llama-nightscape.jpg -------------------------------------------------------------------------------- /src/assets/no-auth-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/assets/no-auth-image.jpg -------------------------------------------------------------------------------- /src/components/date/dateRanges.ts: -------------------------------------------------------------------------------- 1 | export const DATE_RANGES = { 2 | // Format: 3 | // Key is the title, as a translation string, the first entry of the value array 4 | // is the "from" date, the second one is the "to" date. 5 | 'today': ['now/d', 'now/d+1d'], 6 | 7 | 'lastWeek': ['now/w-1w', 'now/w-2w'], 8 | 'thisWeek': ['now/w', 'now/w+1w'], 9 | 'restOfThisWeek': ['now', 'now/w+1w'], 10 | 'nextWeek': ['now/w+1w', 'now/w+2w'], 11 | 'next7Days': ['now', 'now+7d'], 12 | 13 | 'lastMonth': ['now/M-1M', 'now/M-2M'], 14 | 'thisMonth': ['now/M', 'now/M+1M'], 15 | 'restOfThisMonth': ['now', 'now/M+1M'], 16 | 'nextMonth': ['now/M+1M', 'now/M+2M'], 17 | 'next30Days': ['now', 'now+30d'], 18 | 19 | 'thisYear': ['now/y', 'now/y+1y'], 20 | 'restOfThisYear': ['now', 'now/y+1y'], 21 | } 22 | -------------------------------------------------------------------------------- /src/components/date/datemathHelp.story.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /src/components/home/DemoMode.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 28 | 29 | -------------------------------------------------------------------------------- /src/components/home/Logo.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 31 | 32 | -------------------------------------------------------------------------------- /src/components/home/PoweredByLink.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/input/AsyncEditor.ts: -------------------------------------------------------------------------------- 1 | import {createAsyncComponent} from '@/helpers/createAsyncComponent' 2 | 3 | const TipTap = createAsyncComponent(() => import('@/components/input/editor/TipTap.vue')) 4 | 5 | export default TipTap 6 | -------------------------------------------------------------------------------- /src/components/input/Button.story.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/input/ColorPicker.story.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/input/SimpleButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 27 | -------------------------------------------------------------------------------- /src/components/input/editor/commands.ts: -------------------------------------------------------------------------------- 1 | import {Extension} from '@tiptap/core' 2 | import Suggestion from '@tiptap/suggestion' 3 | 4 | // Copied and adjusted from https://github.com/ueberdosis/tiptap/tree/252acb32d27a0f9af14813eeed83d8a50059a43a/demos/src/Experiments/Commands/Vue 5 | 6 | export default Extension.create({ 7 | name: 'slash-menu-commands', 8 | 9 | addOptions() { 10 | return { 11 | suggestion: { 12 | char: '/', 13 | command: ({editor, range, props}) => { 14 | props.command({editor, range}) 15 | }, 16 | }, 17 | } 18 | }, 19 | 20 | addProseMirrorPlugins() { 21 | return [ 22 | Suggestion({ 23 | editor: this.editor, 24 | ...this.options.suggestion, 25 | }), 26 | ] 27 | }, 28 | }) -------------------------------------------------------------------------------- /src/components/input/editor/setLinkInEditor.ts: -------------------------------------------------------------------------------- 1 | import inputPrompt from '@/helpers/inputPrompt' 2 | 3 | export async function setLinkInEditor(pos, editor) { 4 | const previousUrl = editor?.getAttributes('link').href || '' 5 | const url = await inputPrompt(pos, previousUrl) 6 | 7 | // empty 8 | if (url === '') { 9 | editor 10 | ?.chain() 11 | .focus() 12 | .extendMarkRange('link') 13 | .unsetLink() 14 | .run() 15 | 16 | return 17 | } 18 | 19 | // update link 20 | editor 21 | ?.chain() 22 | .focus() 23 | .extendMarkRange('link') 24 | .setLink({href: url, target: '_blank'}) 25 | .run() 26 | } -------------------------------------------------------------------------------- /src/components/input/editor/types.ts: -------------------------------------------------------------------------------- 1 | export type UploadCallback = (files: File[] | FileList) => Promise 2 | 3 | export interface BottomAction { 4 | title: string 5 | action: () => void, 6 | } 7 | -------------------------------------------------------------------------------- /src/components/misc/ButtonLink.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/misc/Card.story.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/misc/Done.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/misc/OpenQuickActions.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 41 | -------------------------------------------------------------------------------- /src/components/misc/ProgressBar.story.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | -------------------------------------------------------------------------------- /src/components/misc/colorBubble.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/misc/error.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | 23 | 31 | -------------------------------------------------------------------------------- /src/components/misc/legal.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/misc/loading.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | 14 | 21 | 22 | -------------------------------------------------------------------------------- /src/components/misc/nothing.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/misc/notification.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | -------------------------------------------------------------------------------- /src/components/misc/popup.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 57 | 58 | 74 | -------------------------------------------------------------------------------- /src/components/misc/shortcut.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | 33 | -------------------------------------------------------------------------------- /src/components/tasks/partials/date-table-cell.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | -------------------------------------------------------------------------------- /src/components/tasks/partials/label.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/tasks/partials/labels.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/tasks/partials/percentDoneSelect.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 66 | -------------------------------------------------------------------------------- /src/components/tasks/partials/reminders.story.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | -------------------------------------------------------------------------------- /src/components/tasks/partials/sort.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 32 | -------------------------------------------------------------------------------- /src/composables/useBodyClass.ts: -------------------------------------------------------------------------------- 1 | import {ref, watchEffect} from 'vue' 2 | import {tryOnBeforeUnmount} from '@vueuse/core' 3 | 4 | export function useBodyClass(className: string, defaultValue = false) { 5 | const isActive = ref(defaultValue) 6 | 7 | watchEffect(() => { 8 | isActive.value 9 | ? document.body.classList.add(className) 10 | : document.body.classList.remove(className) 11 | }) 12 | 13 | tryOnBeforeUnmount(() => isActive.value && document.body.classList.remove(className)) 14 | 15 | return isActive 16 | } -------------------------------------------------------------------------------- /src/composables/useCopyToClipboard.ts: -------------------------------------------------------------------------------- 1 | import {error} from '@/message' 2 | import {useI18n} from 'vue-i18n' 3 | 4 | export function useCopyToClipboard() { 5 | const {t} = useI18n({useScope: 'global'}) 6 | 7 | function fallbackCopyTextToClipboard(text: string) { 8 | const textArea = document.createElement('textarea') 9 | textArea.value = text 10 | 11 | // Avoid scrolling to bottom 12 | textArea.style.top = '0' 13 | textArea.style.left = '0' 14 | textArea.style.position = 'fixed' 15 | 16 | document.body.appendChild(textArea) 17 | textArea.focus() 18 | textArea.select() 19 | 20 | try { 21 | // NOTE: the execCommand is deprecated but as of 2022_09 22 | // widely supported and works without https 23 | const successful = document.execCommand('copy') 24 | if (!successful) { 25 | throw new Error() 26 | } 27 | } catch (err) { 28 | error(t('misc.copyError')) 29 | } 30 | 31 | document.body.removeChild(textArea) 32 | } 33 | 34 | return async (text: string) => { 35 | if (!navigator.clipboard) { 36 | fallbackCopyTextToClipboard(text) 37 | return 38 | } 39 | try { 40 | await navigator.clipboard.writeText(text) 41 | } catch(e) { 42 | error(t('misc.copyError')) 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/composables/useDaytimeSalutation.ts: -------------------------------------------------------------------------------- 1 | import {computed, onActivated, ref} from 'vue' 2 | import {useI18n} from 'vue-i18n' 3 | 4 | import {useAuthStore} from '@/stores/auth' 5 | import {hourToDaytime} from '@/helpers/hourToDaytime' 6 | 7 | export type Daytime = 'night' | 'morning' | 'day' | 'evening' 8 | 9 | export function useDaytimeSalutation() { 10 | const {t} = useI18n({useScope: 'global'}) 11 | const now = ref(new Date()) 12 | onActivated(() => now.value = new Date()) 13 | const authStore = useAuthStore() 14 | 15 | const name = computed(() => authStore.userDisplayName) 16 | const daytime = computed(() => hourToDaytime(now.value)) 17 | 18 | const salutations = { 19 | 'night': () => t('home.welcomeNight', {username: name.value}), 20 | 'morning': () => t('home.welcomeMorning', {username: name.value}), 21 | 'day': () => t('home.welcomeDay', {username: name.value}), 22 | 'evening': () => t('home.welcomeEvening', {username: name.value}), 23 | } as Record string> 24 | 25 | return computed(() => name.value ? salutations[daytime.value]() : undefined) 26 | } -------------------------------------------------------------------------------- /src/composables/useOnline.ts: -------------------------------------------------------------------------------- 1 | import {ref} from 'vue' 2 | import {useOnline as useNetworkOnline} from '@vueuse/core' 3 | import type {ConfigurableWindow} from '@vueuse/core' 4 | 5 | export function useOnline(options?: ConfigurableWindow) { 6 | const isOnline = useNetworkOnline(options) 7 | const fakeOnlineState = Boolean(import.meta.env.VITE_IS_ONLINE) 8 | if (isOnline.value === false && fakeOnlineState) { 9 | console.log('Setting fake online state', fakeOnlineState) 10 | return ref(true) 11 | } 12 | return isOnline 13 | } -------------------------------------------------------------------------------- /src/composables/useRedirectToLastVisited.ts: -------------------------------------------------------------------------------- 1 | import {useRouter} from 'vue-router' 2 | import {getLastVisited, clearLastVisited} from '@/helpers/saveLastVisited' 3 | 4 | export function useRedirectToLastVisited() { 5 | 6 | const router = useRouter() 7 | 8 | function getLastVisitedRoute() { 9 | const last = getLastVisited() 10 | if (last === null) { 11 | return null 12 | } 13 | 14 | clearLastVisited() 15 | return { 16 | name: last.name, 17 | params: last.params, 18 | query: last.query, 19 | } 20 | } 21 | 22 | function redirectIfSaved() { 23 | const lastRoute = getLastVisitedRoute() 24 | if (!lastRoute) { 25 | return router.push({name: 'home'}) 26 | } 27 | 28 | return router.push(lastRoute) 29 | } 30 | 31 | return { 32 | redirectIfSaved, 33 | getLastVisitedRoute, 34 | } 35 | } -------------------------------------------------------------------------------- /src/composables/useTitle.ts: -------------------------------------------------------------------------------- 1 | import {computed} from 'vue' 2 | import type {Ref} from 'vue' 3 | 4 | import {useTitle as useTitleVueUse, toRef} from '@vueuse/core' 5 | 6 | type UseTitleParameters = Parameters 7 | 8 | export function useTitle(...args: UseTitleParameters) { 9 | 10 | const [newTitle, ...restArgs] = args 11 | 12 | const pageTitle = toRef(newTitle) as Ref 13 | 14 | const completeTitle = computed(() => 15 | (typeof pageTitle.value === 'undefined' || pageTitle.value === '') 16 | ? 'Vikunja' 17 | : `${pageTitle.value} | Vikunja`, 18 | ) 19 | 20 | return useTitleVueUse(completeTitle, ...restArgs) 21 | } -------------------------------------------------------------------------------- /src/constants/date.ts: -------------------------------------------------------------------------------- 1 | export const DATEFNS_DATE_FORMAT_KEBAB = 'yyyy-LL-dd' 2 | 3 | export const SECONDS_A_MINUTE = 60 4 | export const SECONDS_A_HOUR = SECONDS_A_MINUTE * 60 5 | export const SECONDS_A_DAY = SECONDS_A_HOUR * 24 6 | export const SECONDS_A_WEEK = SECONDS_A_DAY * 7 7 | export const SECONDS_A_MONTH = SECONDS_A_DAY * 30 8 | export const SECONDS_A_YEAR = SECONDS_A_DAY * 365 9 | 10 | export const MILLISECONDS_A_SECOND = 1000 11 | export const MILLISECONDS_A_MINUTE = SECONDS_A_MINUTE * MILLISECONDS_A_SECOND 12 | export const MILLISECONDS_A_HOUR = SECONDS_A_HOUR * MILLISECONDS_A_SECOND 13 | export const MILLISECONDS_A_DAY = SECONDS_A_DAY * MILLISECONDS_A_SECOND 14 | export const MILLISECONDS_A_WEEK = SECONDS_A_WEEK * MILLISECONDS_A_SECOND -------------------------------------------------------------------------------- /src/constants/linkShareHash.ts: -------------------------------------------------------------------------------- 1 | export const LINK_SHARE_HASH_PREFIX = '#share-auth-token=' 2 | -------------------------------------------------------------------------------- /src/constants/priorities.ts: -------------------------------------------------------------------------------- 1 | export const PRIORITIES = { 2 | 'UNSET': 0, 3 | 'LOW': 1, 4 | 'MEDIUM': 2, 5 | 'HIGH': 3, 6 | 'URGENT': 4, 7 | 'DO_NOW': 5, 8 | } as const 9 | 10 | export type Priority = typeof PRIORITIES[keyof typeof PRIORITIES] -------------------------------------------------------------------------------- /src/constants/rights.ts: -------------------------------------------------------------------------------- 1 | export const RIGHTS = { 2 | 'READ': 0, 3 | 'READ_WRITE': 1, 4 | 'ADMIN': 2, 5 | } as const 6 | 7 | export type Right = typeof RIGHTS[keyof typeof RIGHTS] -------------------------------------------------------------------------------- /src/directives/cypress.ts: -------------------------------------------------------------------------------- 1 | import type {Directive} from 'vue' 2 | 3 | declare global { 4 | interface Window { 5 | Cypress: object; 6 | } 7 | } 8 | 9 | const cypressDirective = >{ 10 | mounted(el, {arg, value}) { 11 | const testingId = arg || value 12 | if ((window.Cypress || import.meta.env.DEV) && testingId) { 13 | el.setAttribute('data-cy', testingId) 14 | } 15 | }, 16 | beforeUnmount(el) { 17 | el.removeAttribute('data-cy') 18 | }, 19 | } 20 | 21 | export default cypressDirective 22 | -------------------------------------------------------------------------------- /src/directives/focus.ts: -------------------------------------------------------------------------------- 1 | import type {Directive} from 'vue' 2 | 3 | const focus = >{ 4 | // When the bound element is inserted into the DOM... 5 | mounted(el, {modifiers}) { 6 | // Focus the element only if the viewport is big enough 7 | // auto focusing elements on mobile can be annoying since in these cases the 8 | // keyboard always pops up and takes half of the available space on the screen. 9 | // The threshhold is the same as the breakpoints in css. 10 | if (window.innerWidth > 769 || modifiers?.always) { 11 | el.focus() 12 | } 13 | }, 14 | } 15 | 16 | export default focus -------------------------------------------------------------------------------- /src/directives/shortcut.ts: -------------------------------------------------------------------------------- 1 | import type {Directive} from 'vue' 2 | import {install, uninstall} from '@github/hotkey' 3 | 4 | const directive = >{ 5 | mounted(el, {value}) { 6 | if(value === '') { 7 | return 8 | } 9 | install(el, value) 10 | }, 11 | beforeUnmount(el) { 12 | uninstall(el) 13 | }, 14 | } 15 | 16 | export default directive 17 | -------------------------------------------------------------------------------- /src/helpers/calculateItemPosition.ts: -------------------------------------------------------------------------------- 1 | export const calculateItemPosition = (positionBefore: number | null, positionAfter: number | null): number => { 2 | if (positionBefore === null) { 3 | if (positionAfter === null) { 4 | return 0 5 | } 6 | 7 | // If there is no task after it, we just add 2^16 to the last position to have enough room in the future 8 | return positionAfter / 2 9 | } 10 | 11 | // If there is no task after it, we just add 2^16 to the last position to have enough room in the future 12 | if (positionAfter === null) { 13 | return positionBefore + Math.pow(2, 16) 14 | } 15 | 16 | // If we have both a task before and after it, we acually calculate the position 17 | return positionBefore + (positionAfter - positionBefore) / 2 18 | } -------------------------------------------------------------------------------- /src/helpers/calculateTaskPosition.test.ts: -------------------------------------------------------------------------------- 1 | import {it, expect} from 'vitest' 2 | 3 | import {calculateItemPosition} from './calculateItemPosition' 4 | 5 | it('should calculate the task position', () => { 6 | const result = calculateItemPosition(10, 100) 7 | expect(result).toBe(55) 8 | }) 9 | it('should return 0 if no position was provided', () => { 10 | const result = calculateItemPosition(null, null) 11 | expect(result).toBe(0) 12 | }) 13 | it('should calculate the task position for the first task', () => { 14 | const result = calculateItemPosition(null, 100) 15 | expect(result).toBe(50) 16 | }) 17 | it('should calculate the task position for the last task', () => { 18 | const result = calculateItemPosition(10, null) 19 | expect(result).toBe(65546) 20 | }) 21 | -------------------------------------------------------------------------------- /src/helpers/canNestProjectDeeper.ts: -------------------------------------------------------------------------------- 1 | export function canNestProjectDeeper(level: number) { 2 | if (level < 2) { 3 | return true 4 | } 5 | 6 | return level >= 2 && window.PROJECT_INFINITE_NESTING_ENABLED 7 | } -------------------------------------------------------------------------------- /src/helpers/checklistFromText.ts: -------------------------------------------------------------------------------- 1 | interface CheckboxStatistics { 2 | total: number 3 | checked: number 4 | } 5 | 6 | interface MatchedCheckboxes { 7 | checked: number[] 8 | unchecked: number[] 9 | } 10 | 11 | const getCheckboxesInText = (text: string): MatchedCheckboxes => { 12 | const regex = /data-checked="(true|false)"/g 13 | let match 14 | const checkboxes: MatchedCheckboxes = { 15 | checked: [], 16 | unchecked: [], 17 | } 18 | 19 | while ((match = regex.exec(text)) !== null) { 20 | if (match[1] === 'true') { 21 | checkboxes.checked.push(match.index) 22 | } else { 23 | checkboxes.unchecked.push(match.index) 24 | } 25 | } 26 | 27 | return checkboxes 28 | } 29 | 30 | /** 31 | * Returns the indices where checkboxes start and end in the given text. 32 | * 33 | * @param text 34 | */ 35 | export const findCheckboxesInText = (text: string): number[] => { 36 | const checkboxes = getCheckboxesInText(text) 37 | 38 | return [ 39 | ...checkboxes.checked, 40 | ...checkboxes.unchecked, 41 | ].sort((a, b) => a < b ? -1 : 1) 42 | } 43 | 44 | export const getChecklistStatistics = (text: string): CheckboxStatistics => { 45 | const checkboxes = getCheckboxesInText(text) 46 | 47 | return { 48 | total: checkboxes.checked.length + checkboxes.unchecked.length, 49 | checked: checkboxes.checked.length, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/helpers/closeWhenClickedOutside.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calls the close callback when a click happened outside of the rootElement. 3 | * 4 | * @param event The "click" event object. 5 | * @param rootElement 6 | * @param closeCallback A closure function to call when the click event happened outside of the rootElement. 7 | */ 8 | export const closeWhenClickedOutside = (event: MouseEvent, rootElement: HTMLElement, closeCallback: () => void) => { 9 | // We walk up the tree to see if any parent of the clicked element is the root element. 10 | // If it is not, we call the close callback. We're doing all this hassle to only call the 11 | // closing callback when a click happens outside of the rootElement. 12 | let parent = (event.target as HTMLElement)?.parentElement 13 | while (parent !== rootElement) { 14 | if (parent === null || parent.parentElement === null) { 15 | parent = null 16 | break 17 | } 18 | 19 | parent = parent.parentElement 20 | } 21 | 22 | if (parent === rootElement) { 23 | return 24 | } 25 | 26 | closeCallback() 27 | } -------------------------------------------------------------------------------- /src/helpers/color/colorFromHex.test.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from 'vitest' 2 | 3 | import {colorFromHex} from './colorFromHex' 4 | 5 | test('hex', () => { 6 | const color = '#ffffff' 7 | expect(colorFromHex(color)).toBe('ffffff') 8 | }) 9 | 10 | test('no hex', () => { 11 | const color = 'ffffff' 12 | expect(colorFromHex(color)).toBe('ffffff') 13 | }) 14 | -------------------------------------------------------------------------------- /src/helpers/color/colorFromHex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the hex color code without the '#' if it has one. 3 | * 4 | * @param color 5 | * @returns {string} 6 | */ 7 | export function colorFromHex(color: string): string { 8 | if (color !== '' && color.substring(0, 1) === '#') { 9 | color = color.substring(1, 7) 10 | } 11 | 12 | return color 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/color/colorIsDark.test.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from 'vitest' 2 | 3 | import {colorIsDark} from './colorIsDark' 4 | 5 | test('dark color', () => { 6 | const color = '#111111' 7 | expect(colorIsDark(color)).toBe(false) 8 | }) 9 | 10 | test('light color', () => { 11 | const color = '#DDDDDD' 12 | expect(colorIsDark(color)).toBe(true) 13 | }) 14 | 15 | test('default dark', () => { 16 | const color = '' 17 | expect(colorIsDark(color)).toBe(true) 18 | }) 19 | -------------------------------------------------------------------------------- /src/helpers/color/colorIsDark.ts: -------------------------------------------------------------------------------- 1 | export function colorIsDark(color: string | undefined) { 2 | if (typeof color === 'undefined') { 3 | return true // Defaults to dark 4 | } 5 | 6 | if (color === '#' || color === '') { 7 | return true // Defaults to dark 8 | } 9 | 10 | if (color.substring(0, 1) !== '#') { 11 | color = '#' + color 12 | } 13 | 14 | const rgb = parseInt(color.substring(1, 7), 16) // convert rrggbb to decimal 15 | const r = (rgb >> 16) & 0xff // extract red 16 | const g = (rgb >> 8) & 0xff // extract green 17 | const b = (rgb >> 0) & 0xff // extract blue 18 | 19 | // this is a quick and dirty implementation of the WCAG 3.0 APCA color contrast formula 20 | // see: https://gist.github.com/Myndex/e1025706436736166561d339fd667493#andys-shortcut-to-luminance--lightness 21 | const Ys = Math.pow(r/255.0,2.2) * 0.2126 + 22 | Math.pow(g/255.0,2.2) * 0.7152 + 23 | Math.pow(b/255.0,2.2) * 0.0722 24 | 25 | return Math.pow(Ys,0.678) >= 0.5 26 | } -------------------------------------------------------------------------------- /src/helpers/color/randomColor.ts: -------------------------------------------------------------------------------- 1 | 2 | const COLORS = [ 3 | '#ffbe0b', 4 | '#fd8a09', 5 | '#fb5607', 6 | '#ff006e', 7 | '#efbdeb', 8 | '#8338ec', 9 | '#5f5ff6', 10 | '#3a86ff', 11 | '#4c91ff', 12 | '#0ead69', 13 | '#25be8b', 14 | '#073b4c', 15 | '#373f47', 16 | ] 17 | 18 | export function getRandomColorHex(): string { 19 | return COLORS[Math.floor(Math.random() * COLORS.length)] 20 | } 21 | -------------------------------------------------------------------------------- /src/helpers/createAsyncComponent.ts: -------------------------------------------------------------------------------- 1 | import { defineAsyncComponent, type AsyncComponentLoader, type AsyncComponentOptions, type Component, type ComponentPublicInstance } from 'vue' 2 | 3 | import ErrorComponent from '@/components/misc/error.vue' 4 | import LoadingComponent from '@/components/misc/loading.vue' 5 | 6 | const DEFAULT_TIMEOUT = 60000 7 | 8 | export function createAsyncComponent(source: AsyncComponentLoader | AsyncComponentOptions): T { 11 | if (typeof source === 'function') { 12 | source = { loader: source } 13 | } 14 | 15 | return defineAsyncComponent({ 16 | ...source, 17 | loadingComponent: LoadingComponent, 18 | errorComponent: ErrorComponent, 19 | timeout: DEFAULT_TIMEOUT, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/downloadBlob.ts: -------------------------------------------------------------------------------- 1 | export const downloadBlob = (url: string, filename: string) => { 2 | const link = document.createElement('a') 3 | link.href = url 4 | link.setAttribute('download', filename) 5 | link.click() 6 | window.URL.revokeObjectURL(url) 7 | } -------------------------------------------------------------------------------- /src/helpers/editorContentEmpty.ts: -------------------------------------------------------------------------------- 1 | export function isEditorContentEmpty(content: string): boolean { 2 | return content === '' || content === '

' 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/fetcher.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import {getToken} from '@/helpers/auth' 3 | 4 | export function HTTPFactory() { 5 | const instance = axios.create({baseURL: window.API_URL}) 6 | 7 | instance.interceptors.request.use((config) => { 8 | // by setting the baseURL fresh for every request 9 | // we make sure that it is never outdated in case it is updated 10 | config.baseURL = window.API_URL 11 | 12 | return config 13 | }) 14 | 15 | return instance 16 | } 17 | 18 | export function AuthenticatedHTTPFactory() { 19 | const instance = HTTPFactory() 20 | 21 | instance.interceptors.request.use((config) => { 22 | config.headers = { 23 | ...config.headers, 24 | 'Content-Type': 'application/json', 25 | } 26 | 27 | // Set the default auth header if we have a token 28 | const token = getToken() 29 | if (token !== null) { 30 | config.headers['Authorization'] = `Bearer ${token}` 31 | } 32 | return config 33 | }) 34 | 35 | return instance 36 | } 37 | -------------------------------------------------------------------------------- /src/helpers/flatpickrLanguage.ts: -------------------------------------------------------------------------------- 1 | import {useAuthStore} from '@/stores/auth' 2 | import FlatpickrLanguages from 'flatpickr/dist/l10n' 3 | import type { CustomLocale, key } from 'flatpickr/dist/types/locale' 4 | 5 | export function getFlatpickrLanguage(): CustomLocale { 6 | const authStore = useAuthStore() 7 | const lang = authStore.settings.language 8 | const langPair = lang.split('-') 9 | let language = FlatpickrLanguages[lang === 'vi-vn' ? 'vn' : 'en'] 10 | if (langPair.length > 0 && FlatpickrLanguages[langPair[0] as key] !== undefined) { 11 | language = FlatpickrLanguages[langPair[0] as key] 12 | } 13 | language.firstDayOfWeek = authStore.settings.weekStart ?? language.firstDayOfWeek 14 | return language 15 | } -------------------------------------------------------------------------------- /src/helpers/getBlobFromBlurHash.ts: -------------------------------------------------------------------------------- 1 | import {decode} from 'blurhash' 2 | 3 | export async function getBlobFromBlurHash(blurHash: string): Promise { 4 | if (blurHash === '') { 5 | return null 6 | } 7 | 8 | const pixels = decode(blurHash, 32, 32) 9 | const canvas = document.createElement('canvas') 10 | canvas.width = 32 11 | canvas.height = 32 12 | const ctx = canvas.getContext('2d') 13 | if (ctx === null) { 14 | return null 15 | } 16 | 17 | const imageData = ctx.createImageData(32, 32) 18 | imageData.data.set(pixels) 19 | ctx.putImageData(imageData, 0, 0) 20 | 21 | return new Promise((resolve, reject) => { 22 | canvas.toBlob(b => { 23 | if (b === null) { 24 | reject(b) 25 | return 26 | } 27 | 28 | resolve(b) 29 | }) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/helpers/getFullBaseUrl.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get full BASE_URL 3 | * - including path 4 | * - will always end with a trailing slash 5 | */ 6 | export function getFullBaseUrl() { 7 | // (1) The injected BASE_URL is declared from the `resolvedBase` that might miss a trailing slash... 8 | // see: https://github.com/vitejs/vite/blob/b35fe883fdc699ac1450882562872095abe9959b/packages/vite/src/node/config.ts#LL614C25-L614C25 9 | const rawBase = import.meta.env.BASE_URL 10 | // (2) so we readd a slash like done here 11 | // https://github.com/vitejs/vite/blob/b35fe883fdc699ac1450882562872095abe9959b/packages/vite/src/node/config.ts#L643 12 | // See this comment: https://github.com/vitejs/vite/pull/10723#issuecomment-1303627478 13 | return rawBase.endsWith('/') ? rawBase : rawBase + '/' 14 | } -------------------------------------------------------------------------------- /src/helpers/getHumanSize.ts: -------------------------------------------------------------------------------- 1 | const SIZES = [ 2 | 'B', 3 | 'KB', 4 | 'MB', 5 | 'GB', 6 | 'TB', 7 | ] as const 8 | 9 | export function getHumanSize(inputSize: number) { 10 | let iterator = 0 11 | let size = inputSize 12 | while (size > 1024) { 13 | size /= 1024 14 | iterator++ 15 | } 16 | 17 | return Number(Math.round(Number(size + 'e2')) + 'e-2') + ' ' + SIZES[iterator] 18 | } -------------------------------------------------------------------------------- /src/helpers/getInheritedBackgroundColor.ts: -------------------------------------------------------------------------------- 1 | function getDefaultBackground() { 2 | const div = document.createElement('div') 3 | document.head.appendChild(div) 4 | const bg = window.getComputedStyle(div).backgroundColor 5 | document.head.removeChild(div) 6 | return bg 7 | } 8 | 9 | // get default style for current browser 10 | const defaultStyle = getDefaultBackground() // typically "rgba(0, 0, 0, 0)" 11 | 12 | // based on https://stackoverflow.com/a/62630563/15522256 13 | export function getInheritedBackgroundColor(el: HTMLElement): string { 14 | const backgroundColor = window.getComputedStyle(el).backgroundColor 15 | 16 | if (backgroundColor !== defaultStyle) return backgroundColor 17 | 18 | if (!el.parentElement) { 19 | // we reached the top parent el without getting an explicit color 20 | return defaultStyle 21 | } 22 | 23 | return getInheritedBackgroundColor(el.parentElement) 24 | } -------------------------------------------------------------------------------- /src/helpers/getProjectTitle.ts: -------------------------------------------------------------------------------- 1 | import {i18n} from '@/i18n' 2 | import type {IProject} from '@/modelTypes/IProject' 3 | 4 | export function getProjectTitle(project: IProject) { 5 | if (project.id === -1) { 6 | return i18n.global.t('project.pseudo.favorites.title') 7 | } 8 | 9 | if (project.title === 'Inbox') { 10 | return i18n.global.t('project.inboxTitle') 11 | } 12 | 13 | return project.title 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers/hourToDaytime.test.ts: -------------------------------------------------------------------------------- 1 | import {describe, it, expect} from 'vitest' 2 | import {hourToDaytime} from "./hourToDaytime" 3 | 4 | function dateWithHour(hours: number): Date { 5 | const newDate = new Date() 6 | newDate.setHours(hours, 0, 0,0 ) 7 | return newDate 8 | } 9 | 10 | describe('Salutation', () => { 11 | it('shows the right salutation in the night', () => { 12 | const salutation = hourToDaytime(dateWithHour(4)) 13 | expect(salutation).toBe('night') 14 | }) 15 | it('shows the right salutation in the morning', () => { 16 | const salutation = hourToDaytime(dateWithHour(8)) 17 | expect(salutation).toBe('morning') 18 | }) 19 | it('shows the right salutation in the day', () => { 20 | const salutation = hourToDaytime(dateWithHour(13)) 21 | expect(salutation).toBe('day') 22 | }) 23 | it('shows the right salutation in the night', () => { 24 | const salutation = hourToDaytime(dateWithHour(20)) 25 | expect(salutation).toBe('evening') 26 | }) 27 | it('shows the right salutation in the night again', () => { 28 | const salutation = hourToDaytime(dateWithHour(23)) 29 | expect(salutation).toBe('night') 30 | }) 31 | }) -------------------------------------------------------------------------------- /src/helpers/hourToDaytime.ts: -------------------------------------------------------------------------------- 1 | import type { Daytime } from '@/composables/useDaytimeSalutation' 2 | 3 | export function hourToDaytime(now: Date): Daytime { 4 | const hours = now.getHours() 5 | 6 | const daytimeMap = { 7 | night: hours < 5 || hours > 23, 8 | morning: hours < 11, 9 | day: hours < 18, 10 | evening: hours < 23, 11 | } as Record 12 | 13 | return (Object.keys(daytimeMap) as Daytime[]).find((daytime) => daytimeMap[daytime]) || 'night' 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers/inputPrompt.ts: -------------------------------------------------------------------------------- 1 | import {createRandomID} from '@/helpers/randomId' 2 | import tippy from 'tippy.js' 3 | import {nextTick} from 'vue' 4 | import {eventToHotkeyString} from '@github/hotkey' 5 | 6 | export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise { 7 | return new Promise((resolve) => { 8 | const id = 'link-input-' + createRandomID() 9 | 10 | const linkPopup = tippy('body', { 11 | getReferenceClientRect: () => pos, 12 | appendTo: () => document.body, 13 | content: `
`, 14 | showOnCreate: true, 15 | interactive: true, 16 | trigger: 'manual', 17 | placement: 'top-start', 18 | allowHTML: true, 19 | }) 20 | 21 | linkPopup[0].show() 22 | 23 | nextTick(() => document.getElementById(id)?.focus()) 24 | 25 | document.getElementById(id)?.addEventListener('keydown', event => { 26 | const hotkeyString = eventToHotkeyString(event) 27 | if (hotkeyString !== 'Enter') { 28 | return 29 | } 30 | 31 | const url = event.target.value 32 | 33 | resolve(url) 34 | 35 | linkPopup[0].hide() 36 | }) 37 | 38 | }) 39 | } -------------------------------------------------------------------------------- /src/helpers/isAppleDevice.ts: -------------------------------------------------------------------------------- 1 | export const isAppleDevice = (): boolean => { 2 | return navigator.userAgent.includes('Mac') || [ 3 | 'iPad Simulator', 4 | 'iPhone Simulator', 5 | 'iPod Simulator', 6 | 'iPad', 7 | 'iPhone', 8 | 'iPod', 9 | ].includes(navigator.platform) 10 | } 11 | -------------------------------------------------------------------------------- /src/helpers/isEmail.ts: -------------------------------------------------------------------------------- 1 | export function isEmail(email: string): boolean { 2 | const format = /^.+@.+$/ 3 | const match = email.match(format) 4 | 5 | return match === null ? false : match.length > 0 6 | } 7 | -------------------------------------------------------------------------------- /src/helpers/isValidHttpUrl.ts: -------------------------------------------------------------------------------- 1 | export function isValidHttpUrl(urlToCheck: string): boolean { 2 | let url 3 | 4 | try { 5 | url = new URL(urlToCheck) 6 | } catch (_) { 7 | return false 8 | } 9 | 10 | return url.protocol === 'http:' || url.protocol === 'https:' 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers/parseDateOrNull.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make date objects from timestamps 3 | */ 4 | export function parseDateOrNull(date: string | Date) { 5 | if (date instanceof Date) { 6 | return date 7 | } 8 | 9 | if ((typeof date === 'string') && !date.startsWith('0001')) { 10 | return new Date(date) 11 | } 12 | 13 | return null 14 | } 15 | -------------------------------------------------------------------------------- /src/helpers/playPop.ts: -------------------------------------------------------------------------------- 1 | import popSoundFile from '@/assets/audio/pop.mp3' 2 | 3 | export const playSoundWhenDoneKey = 'playSoundWhenTaskDone' 4 | 5 | export function playPopSound() { 6 | try { 7 | const popSound = new Audio(popSoundFile) 8 | popSound.play() 9 | } catch (e) { 10 | console.error('Could not play pop sound:', e) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/randomId.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_ID_LENGTH = 9 2 | 3 | export function createRandomID(idLength = DEFAULT_ID_LENGTH) { 4 | return Math.random().toString(36).slice(2, idLength) 5 | } -------------------------------------------------------------------------------- /src/helpers/redirectToProvider.ts: -------------------------------------------------------------------------------- 1 | import {createRandomID} from '@/helpers/randomId' 2 | import type {IProvider} from '@/types/IProvider' 3 | import {parseURL} from 'ufo' 4 | 5 | export function getRedirectUrlFromCurrentFrontendPath(provider: IProvider): string { 6 | // We're not using the redirect url provided by the server to allow redirects when using the electron app. 7 | // The implications are not quite clear yet hence the logic to pass in another redirect url still exists. 8 | const url = parseURL(window.location.href) 9 | return `${url.protocol}//${url.host}/auth/openid/${provider.key}` 10 | } 11 | 12 | export const redirectToProvider = (provider: IProvider) => { 13 | 14 | console.log({provider}) 15 | 16 | const redirectUrl = getRedirectUrlFromCurrentFrontendPath(provider) 17 | const state = createRandomID(24) 18 | localStorage.setItem('state', state) 19 | 20 | window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=openid email profile&state=${state}` 21 | } 22 | export const redirectToProviderOnLogout = (provider: IProvider) => { 23 | if (provider.logoutUrl.length > 0) { 24 | window.location.href = `${provider.logoutUrl}` 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/helpers/replaceAll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This function replaces all text, no matter the case. 3 | * 4 | * See https://stackoverflow.com/a/7313467/10924593 5 | * 6 | * @parma str 7 | * @param search 8 | * @param replace 9 | * @returns {*} 10 | */ 11 | export const replaceAll = (str: string, search: string, replace: string) => { 12 | const esc = search.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') 13 | const reg = new RegExp(esc, 'ig') 14 | return str.replace(reg, replace) 15 | } -------------------------------------------------------------------------------- /src/helpers/saveCollapsedBucketState.ts: -------------------------------------------------------------------------------- 1 | import type {IBucket} from '@/modelTypes/IBucket' 2 | import type {IProject} from '@/modelTypes/IProject' 3 | 4 | const key = 'collapsedBuckets' 5 | 6 | export type CollapsedBuckets = {[id: IBucket['id']]: boolean} 7 | 8 | function getAllState() { 9 | const saved = localStorage.getItem(key) 10 | return saved === null 11 | ? {} 12 | : JSON.parse(saved) 13 | } 14 | 15 | export const saveCollapsedBucketState = ( 16 | projectId: IProject['id'], 17 | collapsedBuckets: CollapsedBuckets, 18 | ) => { 19 | const state = getAllState() 20 | state[projectId] = collapsedBuckets 21 | for (const bucketId in state[projectId]) { 22 | if (!state[projectId][bucketId]) { 23 | delete state[projectId][bucketId] 24 | } 25 | } 26 | localStorage.setItem(key, JSON.stringify(state)) 27 | } 28 | 29 | export function getCollapsedBucketState(projectId : IProject['id']) { 30 | const state = getAllState() 31 | return typeof state[projectId] !== 'undefined' 32 | ? state[projectId] 33 | : {} 34 | } 35 | -------------------------------------------------------------------------------- /src/helpers/saveLastVisited.ts: -------------------------------------------------------------------------------- 1 | const LAST_VISITED_KEY = 'lastVisited' 2 | 3 | export const saveLastVisited = (name: string | undefined, params: object, query: object) => { 4 | if (typeof name === 'undefined') { 5 | return 6 | } 7 | 8 | localStorage.setItem(LAST_VISITED_KEY, JSON.stringify({name, params, query})) 9 | } 10 | 11 | export const getLastVisited = () => { 12 | const lastVisited = localStorage.getItem(LAST_VISITED_KEY) 13 | if (lastVisited === null) { 14 | return null 15 | } 16 | 17 | return JSON.parse(lastVisited) 18 | } 19 | 20 | export const clearLastVisited = () => { 21 | return localStorage.removeItem(LAST_VISITED_KEY) 22 | } 23 | -------------------------------------------------------------------------------- /src/helpers/scrollIntoView.ts: -------------------------------------------------------------------------------- 1 | export function scrollIntoView(el: HTMLElement | null | undefined) { 2 | if (!el) { 3 | return 4 | } 5 | 6 | const boundingRect = el.getBoundingClientRect() 7 | const scrollY = window.scrollY 8 | 9 | if ( 10 | boundingRect.top > (scrollY + window.innerHeight) || 11 | boundingRect.top < scrollY 12 | ) { 13 | el.scrollIntoView({ 14 | behavior: 'smooth', 15 | block: 'center', 16 | inline: 'nearest', 17 | }) 18 | } 19 | } -------------------------------------------------------------------------------- /src/helpers/setTitle.ts: -------------------------------------------------------------------------------- 1 | export function setTitle(title : undefined | string) { 2 | document.title = (typeof title === 'undefined' || title === '') 3 | ? 'Vikunja' 4 | : `${title} | Vikunja` 5 | } -------------------------------------------------------------------------------- /src/helpers/time/calculateDayInterval.ts: -------------------------------------------------------------------------------- 1 | type Day = T 2 | 3 | export function calculateDayInterval(dateString: string, currentDay = (new Date().getDay())): Day { 4 | switch (dateString) { 5 | case 'today': 6 | return 0 7 | case 'tomorrow': 8 | return 1 9 | case 'nextMonday': 10 | // Monday is 1, so we calculate the distance to the next 1 11 | return (currentDay + (8 - currentDay * 2)) % 7 12 | case 'thisWeekend': 13 | // Saturday is 6 so we calculate the distance to the next 6 14 | return (6 - currentDay) % 6 15 | case 'laterThisWeek': 16 | if (currentDay === 5 || currentDay === 6 || currentDay === 0) { 17 | return 0 18 | } 19 | 20 | return 2 21 | case 'laterNextWeek': 22 | return calculateDayInterval('laterThisWeek', currentDay) + 7 23 | case 'nextWeek': 24 | return 7 25 | default: 26 | return 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/helpers/time/calculateNearestHours.ts: -------------------------------------------------------------------------------- 1 | export function calculateNearestHours(currentDate: Date = new Date()): number { 2 | if (currentDate.getHours() <= 9 || currentDate.getHours() > 21) { 3 | return 9 4 | } 5 | 6 | if (currentDate.getHours() <= 12) { 7 | return 12 8 | } 9 | 10 | if (currentDate.getHours() <= 15) { 11 | return 15 12 | } 13 | 14 | if (currentDate.getHours() <= 18) { 15 | return 18 16 | } 17 | 18 | if (currentDate.getHours() <= 21) { 19 | return 21 20 | } 21 | 22 | // Same case as in the first if, will never be called 23 | return 9 24 | } -------------------------------------------------------------------------------- /src/helpers/time/createDateFromString.test.ts: -------------------------------------------------------------------------------- 1 | import {test, expect} from 'vitest' 2 | 3 | import {createDateFromString} from './createDateFromString' 4 | 5 | test('YYYY-MM-DD HH:MM', () => { 6 | const dateString = '2021-02-06 12:00' 7 | const date = createDateFromString(dateString) 8 | expect(date).toBeInstanceOf(Date) 9 | expect(date.getDate()).toBe(6) 10 | expect(date.getMonth()).toBe(1) 11 | expect(date.getFullYear()).toBe(2021) 12 | expect(date.getHours()).toBe(12) 13 | expect(date.getMinutes()).toBe(0) 14 | expect(date.getSeconds()).toBe(0) 15 | }) 16 | -------------------------------------------------------------------------------- /src/helpers/time/createDateFromString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a new date from any format in a way that all browsers, especially safari, can understand. 3 | * 4 | * @see https://kolaente.dev/vikunja/frontend/issues/207 5 | * 6 | * @param dateString 7 | * @returns {Date} 8 | */ 9 | export function createDateFromString(dateString: string | Date) { 10 | if (dateString instanceof Date) { 11 | return dateString 12 | } 13 | 14 | if (dateString.includes('-')) { 15 | dateString = dateString.replace(/-/g, '/') 16 | } 17 | 18 | return new Date(dateString) 19 | } 20 | -------------------------------------------------------------------------------- /src/helpers/time/getNextWeekDate.ts: -------------------------------------------------------------------------------- 1 | import {MILLISECONDS_A_WEEK} from '@/constants/date' 2 | 3 | export function getNextWeekDate(): Date { 4 | return new Date((new Date()).getTime() + MILLISECONDS_A_WEEK) 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/time/isoToKebabDate.ts: -------------------------------------------------------------------------------- 1 | import type {DateISO} from '@/types/DateISO' 2 | import type {DateKebab} from '@/types/DateKebab' 3 | 4 | // ✅ Format a date to YYYY-MM-DD (or any other format) 5 | function padTo2Digits(num: number) { 6 | return num.toString().padStart(2, '0') 7 | } 8 | 9 | export function isoToKebabDate(isoDate: DateISO) { 10 | const date = new Date(isoDate) 11 | return [ 12 | date.getFullYear(), 13 | padTo2Digits(date.getMonth() + 1), // January is 0, but we want it to be 1 14 | padTo2Digits(date.getDate()), 15 | ].join('-') as DateKebab 16 | } -------------------------------------------------------------------------------- /src/helpers/time/parseBooleanProp.ts: -------------------------------------------------------------------------------- 1 | export function parseBooleanProp(booleanProp: string | undefined) { 2 | return (booleanProp === 'false' || booleanProp === '0') 3 | ? false 4 | : Boolean(booleanProp) 5 | } -------------------------------------------------------------------------------- /src/helpers/time/parseDateOrString.ts: -------------------------------------------------------------------------------- 1 | export function parseDateOrString(rawValue: string | undefined | null, fallback: unknown): (unknown | string | Date) { 2 | if (rawValue === null || typeof rawValue === 'undefined') { 3 | return fallback 4 | } 5 | 6 | if (rawValue.toLowerCase().includes('now') || rawValue.toLowerCase().includes('||')) { 7 | return rawValue 8 | } 9 | 10 | const d = new Date(rawValue) 11 | 12 | return !isNaN(+d) 13 | ? d 14 | : rawValue 15 | } 16 | -------------------------------------------------------------------------------- /src/helpers/time/parseDateProp.ts: -------------------------------------------------------------------------------- 1 | import type {DateISO} from '@/types/DateISO' 2 | import type {DateKebab} from '@/types/DateKebab' 3 | 4 | export function parseDateProp(kebabDate: DateKebab | undefined): string | undefined { 5 | try { 6 | 7 | if (!kebabDate) { 8 | throw new Error('No value') 9 | } 10 | const dateValues = kebabDate.split('-') 11 | const [, monthString, dateString] = dateValues 12 | const [year, month, date] = dateValues.map(val => Number(val)) 13 | const dateValuesAreValid = ( 14 | !Number.isNaN(year) && 15 | monthString.length >= 1 && monthString.length <= 2 && 16 | !Number.isNaN(month) && 17 | month >= 1 && month <= 12 && 18 | dateString.length >= 1 && dateString.length <= 31 && 19 | !Number.isNaN(date) && 20 | date >= 1 && date <= 31 21 | ) 22 | if (!dateValuesAreValid) { 23 | throw new Error('Invalid date values') 24 | } 25 | return new Date(year, month - 1, date).toISOString() as DateISO 26 | } catch(e) { 27 | // ignore nonsense route queries 28 | return 29 | } 30 | } -------------------------------------------------------------------------------- /src/helpers/time/parseKebabDate.ts: -------------------------------------------------------------------------------- 1 | import {parse} from 'date-fns' 2 | import {DATEFNS_DATE_FORMAT_KEBAB} from '@/constants/date' 3 | import type {DateKebab} from '@/types/DateKebab' 4 | 5 | export function parseKebabDate(date: DateKebab): Date { 6 | return parse(date, DATEFNS_DATE_FORMAT_KEBAB, new Date()) 7 | } -------------------------------------------------------------------------------- /src/helpers/time/period.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SECONDS_A_DAY, 3 | SECONDS_A_HOUR, 4 | SECONDS_A_MINUTE, 5 | SECONDS_A_WEEK, 6 | } from '@/constants/date' 7 | 8 | export type PeriodUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'years' 9 | 10 | /** 11 | * Convert time period given as seconds to days, hour, minutes, seconds 12 | */ 13 | export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: number } { 14 | if (seconds % SECONDS_A_DAY === 0) { 15 | if (seconds % SECONDS_A_WEEK === 0) { 16 | return {unit: 'weeks', amount: seconds / SECONDS_A_WEEK} 17 | } else { 18 | return {unit: 'days', amount: seconds / SECONDS_A_DAY} 19 | } 20 | } 21 | 22 | return { 23 | unit: 'hours', 24 | amount: seconds / SECONDS_A_HOUR, 25 | } 26 | } 27 | 28 | /** 29 | * Convert time period of days, hour, minutes, seconds to duration in seconds 30 | */ 31 | export function periodToSeconds(period: number, unit: PeriodUnit): number { 32 | switch (unit) { 33 | case 'minutes': 34 | return period * SECONDS_A_MINUTE 35 | case 'hours': 36 | return period * SECONDS_A_HOUR 37 | case 'days': 38 | return period * SECONDS_A_DAY 39 | case 'weeks': 40 | return period * SECONDS_A_WEEK 41 | } 42 | 43 | return 0 44 | } 45 | -------------------------------------------------------------------------------- /src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | export function findIndexById(array : T[], id : string | number) { 2 | return array.findIndex(({id: currentId}) => currentId === id) 3 | } 4 | 5 | export function findById(array : T[], id : string | number) { 6 | return array.find(({id: currentId}) => currentId === id) 7 | } 8 | 9 | interface ObjectWithId { 10 | id: number 11 | } 12 | 13 | export function includesById(array: ObjectWithId[], id: string | number) { 14 | return array.some(({id: currentId}) => currentId === id) 15 | } 16 | 17 | // https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_isnil 18 | export function isNil(value: unknown) { 19 | return value == null 20 | } 21 | 22 | export function omitBy(obj: Record, check: (value: unknown) => boolean) { 23 | if (isNil(obj)) { 24 | return {} 25 | } 26 | 27 | return Object.fromEntries( 28 | Object.entries(obj).filter(([, value]) => !check(value)), 29 | ) 30 | } -------------------------------------------------------------------------------- /src/histoire.setup.ts: -------------------------------------------------------------------------------- 1 | import './polyfills' 2 | import {defineSetupVue3} from '@histoire/plugin-vue' 3 | import {i18n} from './i18n' 4 | 5 | // import './histoire.css' // Import global CSS 6 | import './styles/global.scss' 7 | 8 | import {createPinia} from 'pinia' 9 | 10 | import cypress from '@/directives/cypress' 11 | 12 | import FontAwesomeIcon from '@/components/misc/Icon' 13 | import XButton from '@/components/input/button.vue' 14 | import Modal from '@/components/misc/modal.vue' 15 | import Card from '@/components/misc/card.vue' 16 | 17 | export const setupVue3 = defineSetupVue3(({ app }) => { 18 | // Add Pinia store 19 | const pinia = createPinia() 20 | app.use(pinia) 21 | app.use(i18n) 22 | 23 | app.directive('cy', cypress) 24 | 25 | app.component('Icon', FontAwesomeIcon) 26 | app.component('XButton', XButton) 27 | app.component('Modal', Modal) 28 | app.component('Card', Card) 29 | }) -------------------------------------------------------------------------------- /src/indexes/index.ts: -------------------------------------------------------------------------------- 1 | import {Document} from 'flexsearch' 2 | 3 | export interface withId { 4 | id: number, 5 | } 6 | 7 | const indexes: { [k: string]: Document } = {} 8 | 9 | export const createNewIndexer = (name: string, fieldsToIndex: string[]) => { 10 | if (typeof indexes[name] === 'undefined') { 11 | indexes[name] = new Document({ 12 | tokenize: 'full', 13 | document: { 14 | id: 'id', 15 | index: fieldsToIndex, 16 | }, 17 | }) 18 | } 19 | 20 | const index = indexes[name] 21 | 22 | function add(item: withId) { 23 | return index.add(item.id, item) 24 | } 25 | 26 | function remove(item: withId) { 27 | return index.remove(item.id) 28 | } 29 | 30 | function update(item: withId) { 31 | return index.update(item.id, item) 32 | } 33 | 34 | function search(query: string | null) { 35 | if (query === '' || query === null) { 36 | return null 37 | } 38 | 39 | return index.search(query) 40 | ?.flatMap(r => r.result) 41 | .filter((value, index, self) => self.indexOf(value) === index) as number[] 42 | || null 43 | } 44 | 45 | return { 46 | add, 47 | remove, 48 | update, 49 | search, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/message/index.ts: -------------------------------------------------------------------------------- 1 | import {i18n} from '@/i18n' 2 | import {notify} from '@kyvg/vue3-notification' 3 | 4 | export function getErrorText(r): string { 5 | const data = r?.reason?.response?.data || r?.response?.data 6 | 7 | if (data?.code) { 8 | const path = `error.${data.code}` 9 | const message = i18n.global.t(path) 10 | 11 | // If message and path are equal no translation exists for that error code 12 | if (path !== message) { 13 | return message 14 | } 15 | } 16 | 17 | let message = data?.message || r.message 18 | 19 | if (typeof r.cause?.message !== 'undefined') { 20 | message += ' ' + r.cause.message 21 | } 22 | 23 | return message 24 | } 25 | 26 | export interface Action { 27 | title: string, 28 | callback: () => void, 29 | } 30 | 31 | export function error(e, actions: Action[] = []) { 32 | notify({ 33 | type: 'error', 34 | title: i18n.global.t('error.error'), 35 | text: [getErrorText(e)], 36 | actions: actions, 37 | }) 38 | } 39 | 40 | export function success(e, actions: Action[] = []) { 41 | notify({ 42 | type: 'success', 43 | title: i18n.global.t('error.success'), 44 | text: [getErrorText(e)], 45 | data: { 46 | actions: actions, 47 | }, 48 | }) 49 | } -------------------------------------------------------------------------------- /src/modelTypes/IAbstract.ts: -------------------------------------------------------------------------------- 1 | import type {Right} from '@/constants/rights' 2 | 3 | export interface IAbstract { 4 | maxRight: Right | null // FIXME: should this be readonly? 5 | } -------------------------------------------------------------------------------- /src/modelTypes/IApiToken.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from '@/modelTypes/IAbstract' 2 | 3 | export interface IApiPermission { 4 | [key: string]: string[] 5 | } 6 | 7 | export interface IApiToken extends IAbstract { 8 | id: number 9 | title: string 10 | token: string 11 | permissions: IApiPermission 12 | expiresAt: Date 13 | created: Date 14 | } 15 | -------------------------------------------------------------------------------- /src/modelTypes/IAttachment.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IFile} from './IFile' 3 | import type {IUser} from './IUser' 4 | 5 | export interface IAttachment extends IAbstract { 6 | id: number 7 | taskId: number 8 | createdBy: IUser 9 | file: IFile 10 | created: Date 11 | } -------------------------------------------------------------------------------- /src/modelTypes/IAvatar.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export type AvatarProvider = 'default' | 'initials' | 'gravatar' | 'marble' | 'upload' 4 | 5 | export interface IAvatar extends IAbstract { 6 | avatarProvider: AvatarProvider 7 | } -------------------------------------------------------------------------------- /src/modelTypes/IBackgroundImage.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export interface IBackgroundImage extends IAbstract { 4 | id: number 5 | url: string 6 | thumb: string 7 | info: { 8 | author: string 9 | authorName: string 10 | } 11 | blurHash: string 12 | } -------------------------------------------------------------------------------- /src/modelTypes/IBucket.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | import type {ITask} from './ITask' 4 | 5 | export interface IBucket extends IAbstract { 6 | id: number 7 | title: string 8 | projectId: number 9 | limit: number 10 | tasks: ITask[] 11 | position: number 12 | count: number 13 | 14 | createdBy: IUser 15 | created: Date 16 | updated: Date 17 | } -------------------------------------------------------------------------------- /src/modelTypes/ICaldavToken.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export interface ICaldavToken extends IAbstract { 4 | id: number; 5 | 6 | created: Date; 7 | } -------------------------------------------------------------------------------- /src/modelTypes/IEmailUpdate.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export interface IEmailUpdate extends IAbstract { 4 | newEmail: string 5 | password: string 6 | } -------------------------------------------------------------------------------- /src/modelTypes/IFile.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export interface IFile extends IAbstract { 4 | id: number 5 | mime: string 6 | name: string 7 | size: number 8 | 9 | created: Date 10 | } -------------------------------------------------------------------------------- /src/modelTypes/ILabel.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | 4 | export interface ILabel extends IAbstract { 5 | id: number 6 | title: string 7 | hexColor: string 8 | description: string 9 | createdBy: IUser 10 | projectId: number 11 | textColor: string 12 | 13 | created: Date 14 | updated: Date 15 | } -------------------------------------------------------------------------------- /src/modelTypes/ILabelTask.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export interface ILabelTask extends IAbstract { 4 | id: number 5 | taskId: number 6 | labelId: number 7 | } -------------------------------------------------------------------------------- /src/modelTypes/ILinkShare.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | import type { Right } from '@/constants/rights' 4 | 5 | export interface ILinkShare extends IAbstract { 6 | id: number 7 | hash: string 8 | right: Right 9 | sharedBy: IUser 10 | sharingType: number // FIXME: use correct numbers 11 | projectId: number 12 | name: string 13 | password: string 14 | 15 | created: Date 16 | updated: Date 17 | } -------------------------------------------------------------------------------- /src/modelTypes/IPasswordReset.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export interface IPasswordReset extends IAbstract { 4 | token: string 5 | newPassword: string 6 | email: string 7 | } -------------------------------------------------------------------------------- /src/modelTypes/IPasswordUpdate.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export interface IPasswordUpdate extends IAbstract { 4 | newPassword: string 5 | oldPassword: string 6 | } -------------------------------------------------------------------------------- /src/modelTypes/IProject.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {ITask} from './ITask' 3 | import type {IUser} from './IUser' 4 | import type {ISubscription} from './ISubscription' 5 | 6 | 7 | export interface IProject extends IAbstract { 8 | id: number 9 | title: string 10 | description: string 11 | owner: IUser 12 | tasks: ITask[] 13 | isArchived: boolean 14 | hexColor: string 15 | identifier: string 16 | backgroundInformation: unknown | null // FIXME: improve type 17 | isFavorite: boolean 18 | subscription: ISubscription 19 | position: number 20 | backgroundBlurHash: string 21 | parentProjectId: number 22 | doneBucketId: number 23 | defaultBucketId: number 24 | 25 | created: Date 26 | updated: Date 27 | } -------------------------------------------------------------------------------- /src/modelTypes/IProjectDuplicate.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IProject} from './IProject' 3 | 4 | export interface IProjectDuplicate extends IAbstract { 5 | projectId: number 6 | duplicatedProject: IProject | null 7 | parentProjectId: IProject['id'] 8 | } -------------------------------------------------------------------------------- /src/modelTypes/ISavedFilter.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | import type {IFilter} from '@/types/IFilter' 4 | 5 | export interface ISavedFilter extends IAbstract { 6 | id: number 7 | title: string 8 | description: string 9 | filters: IFilter 10 | 11 | owner: IUser 12 | created: Date 13 | updated: Date 14 | } -------------------------------------------------------------------------------- /src/modelTypes/ISubscription.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | 4 | export interface ISubscription extends IAbstract { 5 | id: number 6 | entity: string // FIXME: correct type? 7 | entityId: number // FIXME: correct type? 8 | user: IUser 9 | 10 | created: Date 11 | } -------------------------------------------------------------------------------- /src/modelTypes/ITaskAssignee.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {ITask} from './ITask' 3 | import type {IUser} from './IUser' 4 | 5 | export interface ITaskAssignee extends IAbstract { 6 | created: Date 7 | userId: IUser['id'] 8 | taskId: ITask['id'] 9 | } -------------------------------------------------------------------------------- /src/modelTypes/ITaskComment.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | import type {ITask} from './ITask' 4 | 5 | export interface ITaskComment extends IAbstract { 6 | id: number 7 | taskId: ITask['id'] 8 | comment: string 9 | author: IUser 10 | 11 | created: Date 12 | updated: Date 13 | } -------------------------------------------------------------------------------- /src/modelTypes/ITaskRelation.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | import type {ITask} from './ITask' 4 | 5 | import type {IRelationKind} from '@/types/IRelationKind' 6 | 7 | export interface ITaskRelation extends IAbstract { 8 | id: number 9 | otherTaskId: ITask['id'] 10 | taskId: ITask['id'] 11 | relationKind: IRelationKind 12 | 13 | createdBy: IUser 14 | created: Date 15 | } -------------------------------------------------------------------------------- /src/modelTypes/ITaskReminder.ts: -------------------------------------------------------------------------------- 1 | import type { IAbstract } from './IAbstract' 2 | import type { IReminderPeriodRelativeTo } from '@/types/IReminderPeriodRelativeTo' 3 | 4 | export interface ITaskReminder extends IAbstract { 5 | reminder: Date | null 6 | relativePeriod: number 7 | relativeTo: IReminderPeriodRelativeTo | null 8 | } -------------------------------------------------------------------------------- /src/modelTypes/ITeam.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | import type {ITeamMember} from './ITeamMember' 4 | import type {Right} from '@/constants/rights' 5 | 6 | export interface ITeam extends IAbstract { 7 | id: number 8 | name: string 9 | description: string 10 | members: ITeamMember[] 11 | right: Right 12 | 13 | createdBy: IUser 14 | created: Date 15 | updated: Date 16 | } -------------------------------------------------------------------------------- /src/modelTypes/ITeamMember.ts: -------------------------------------------------------------------------------- 1 | import type {IUser} from './IUser' 2 | import type {IProject} from './IProject' 3 | 4 | export interface ITeamMember extends IUser { 5 | admin: boolean 6 | teamId: IProject['id'] 7 | } 8 | -------------------------------------------------------------------------------- /src/modelTypes/ITeamProject.ts: -------------------------------------------------------------------------------- 1 | import type {ITeamShareBase} from './ITeamShareBase' 2 | import type {IProject} from './IProject' 3 | 4 | export interface ITeamProject extends ITeamShareBase { 5 | projectId: IProject['id'] 6 | } -------------------------------------------------------------------------------- /src/modelTypes/ITeamShareBase.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {ITeam} from './ITeam' 3 | import type {Right} from '@/constants/rights' 4 | 5 | export interface ITeamShareBase extends IAbstract { 6 | teamId: ITeam['id'] 7 | right: Right 8 | 9 | created: Date 10 | updated: Date 11 | } -------------------------------------------------------------------------------- /src/modelTypes/ITotp.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | 3 | export interface ITotp extends IAbstract { 4 | secret: string 5 | enabled: boolean 6 | url: string 7 | } -------------------------------------------------------------------------------- /src/modelTypes/IUser.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUserSettings} from './IUserSettings' 3 | 4 | export const AUTH_TYPES = { 5 | 'UNKNOWN': 0, 6 | 'USER': 1, 7 | 'LINK_SHARE': 2, 8 | } as const 9 | 10 | export type AuthType = typeof AUTH_TYPES[keyof typeof AUTH_TYPES] 11 | 12 | export interface IUser extends IAbstract { 13 | id: number 14 | email: string 15 | username: string 16 | name: string 17 | exp: number 18 | type: AuthType 19 | 20 | created: Date 21 | updated: Date 22 | settings: IUserSettings 23 | 24 | isLocalUser: boolean 25 | deletionScheduledAt: string | Date | null 26 | } -------------------------------------------------------------------------------- /src/modelTypes/IUserProject.ts: -------------------------------------------------------------------------------- 1 | import type {IUserShareBase} from './IUserShareBase' 2 | import type {IProject} from './IProject' 3 | 4 | export interface IUserProject extends IUserShareBase { 5 | projectId: IProject['id'] 6 | } -------------------------------------------------------------------------------- /src/modelTypes/IUserSettings.ts: -------------------------------------------------------------------------------- 1 | 2 | import type {IAbstract} from './IAbstract' 3 | import type {IProject} from './IProject' 4 | import type {PrefixMode} from '@/modules/parseTaskText' 5 | import type {BasicColorSchema} from '@vueuse/core' 6 | import type {SupportedLocale} from '@/i18n' 7 | 8 | export interface IFrontendSettings { 9 | playSoundWhenDone: boolean 10 | quickAddMagicMode: PrefixMode 11 | colorSchema: BasicColorSchema 12 | filterIdUsedOnOverview: IProject['id'] | null 13 | } 14 | 15 | export interface IUserSettings extends IAbstract { 16 | name: string 17 | emailRemindersEnabled: boolean 18 | discoverableByName: boolean 19 | discoverableByEmail: boolean 20 | overdueTasksRemindersEnabled: boolean 21 | overdueTasksRemindersTime: string | Date 22 | defaultProjectId: undefined | IProject['id'] 23 | weekStart: 0 | 1 | 2 | 3 | 4 | 5 | 6 24 | timezone: string 25 | language: SupportedLocale 26 | frontendSettings: IFrontendSettings 27 | } -------------------------------------------------------------------------------- /src/modelTypes/IUserShareBase.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from './IUser' 3 | import type {Right} from '@/constants/rights' 4 | 5 | export interface IUserShareBase extends IAbstract { 6 | userId: IUser['id'] 7 | right: Right 8 | 9 | created: Date 10 | updated: Date 11 | } -------------------------------------------------------------------------------- /src/modelTypes/IWebhook.ts: -------------------------------------------------------------------------------- 1 | import type {IAbstract} from './IAbstract' 2 | import type {IUser} from '@/modelTypes/IUser' 3 | 4 | export interface IWebhook extends IAbstract { 5 | id: number 6 | projectId: number 7 | secret: string 8 | targetUrl: string 9 | events: string[] 10 | createdBy: IUser 11 | 12 | created: Date 13 | updated: Date 14 | } 15 | -------------------------------------------------------------------------------- /src/models/abstractModel.ts: -------------------------------------------------------------------------------- 1 | import {objectToCamelCase} from '@/helpers/case' 2 | import {omitBy, isNil} from '@/helpers/utils' 3 | import type {Right} from '@/constants/rights' 4 | import type {IAbstract} from '@/modelTypes/IAbstract' 5 | 6 | export default abstract class AbstractModel implements IAbstract { 7 | 8 | 9 | /** 10 | * The max right the user has on this object, as returned by the x-max-right header from the api. 11 | */ 12 | maxRight: Right | null = null 13 | 14 | /** 15 | * Takes an object and merges its data with the default data of this model. 16 | */ 17 | assignData(data: Partial) { 18 | data = objectToCamelCase(data) 19 | 20 | // Put all data in our model while overriding those with a value of null or undefined with their defaults 21 | Object.assign(this, omitBy(data, isNil)) 22 | } 23 | } -------------------------------------------------------------------------------- /src/models/apiTokenModel.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from '@/models/abstractModel' 2 | import type {IApiToken} from '@/modelTypes/IApiToken' 3 | 4 | export default class ApiTokenModel extends AbstractModel { 5 | id = 0 6 | title = '' 7 | token = '' 8 | permissions = null 9 | expiresAt: Date = null 10 | created: Date = null 11 | 12 | constructor(data: Partial = {}) { 13 | super() 14 | 15 | this.assignData(data) 16 | 17 | this.expiresAt = new Date(this.expiresAt) 18 | this.created = new Date(this.created) 19 | this.updated = new Date(this.updated) 20 | } 21 | } -------------------------------------------------------------------------------- /src/models/attachment.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserModel from './user' 3 | import FileModel from './file' 4 | import type { IUser } from '@/modelTypes/IUser' 5 | import type { IFile } from '@/modelTypes/IFile' 6 | import type { IAttachment } from '@/modelTypes/IAttachment' 7 | 8 | export const SUPPORTED_IMAGE_SUFFIX = ['.jpg', '.png', '.bmp', '.gif'] 9 | 10 | export default class AttachmentModel extends AbstractModel implements IAttachment { 11 | id = 0 12 | taskId = 0 13 | createdBy: IUser = UserModel 14 | file: IFile = FileModel 15 | created: Date = null 16 | 17 | constructor(data: Partial) { 18 | super() 19 | this.assignData(data) 20 | 21 | this.createdBy = new UserModel(this.createdBy) 22 | this.file = new FileModel(this.file) 23 | this.created = new Date(this.created) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/models/avatar.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import type { IAvatar } from '@/modelTypes/IAvatar' 3 | 4 | export default class AvatarModel extends AbstractModel implements IAvatar { 5 | avatarProvider: IAvatar['avatarProvider'] = 'default' 6 | 7 | constructor(data: Partial) { 8 | super() 9 | this.assignData(data) 10 | } 11 | } -------------------------------------------------------------------------------- /src/models/backgroundImage.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import type {IBackgroundImage} from '@/modelTypes/IBackgroundImage' 3 | 4 | export default class BackgroundImageModel extends AbstractModel implements IBackgroundImage { 5 | id = 0 6 | url = '' 7 | thumb = '' 8 | info: { 9 | author: string 10 | authorName: string 11 | } = {} 12 | blurHash = '' 13 | 14 | constructor(data: Partial) { 15 | super() 16 | this.assignData(data) 17 | } 18 | } -------------------------------------------------------------------------------- /src/models/bucket.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserModel from './user' 3 | import TaskModel from './task' 4 | 5 | import type {IBucket} from '@/modelTypes/IBucket' 6 | import type {ITask} from '@/modelTypes/ITask' 7 | import type {IUser} from '@/modelTypes/IUser' 8 | 9 | export default class BucketModel extends AbstractModel implements IBucket { 10 | id = 0 11 | title = '' 12 | projectId = '' 13 | limit = 0 14 | tasks: ITask[] = [] 15 | position = 0 16 | count = 0 17 | 18 | createdBy: IUser = null 19 | created: Date = null 20 | updated: Date = null 21 | 22 | constructor(data: Partial) { 23 | super() 24 | this.assignData(data) 25 | 26 | this.tasks = this.tasks.map(t => new TaskModel(t)) 27 | 28 | this.createdBy = new UserModel(this.createdBy) 29 | this.created = new Date(this.created) 30 | this.updated = new Date(this.updated) 31 | } 32 | } -------------------------------------------------------------------------------- /src/models/caldavToken.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import type {ICaldavToken} from '@/modelTypes/ICaldavToken' 4 | 5 | export default class CaldavTokenModel extends AbstractModel implements ICaldavToken { 6 | id: number 7 | created: Date 8 | 9 | constructor(data: Partial) { 10 | super() 11 | this.assignData(data) 12 | 13 | if (this.created) { 14 | this.created = new Date(this.created) 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/models/emailUpdate.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import type {IEmailUpdate} from '@/modelTypes/IEmailUpdate' 4 | 5 | export default class EmailUpdateModel extends AbstractModel implements IEmailUpdate { 6 | newEmail = '' 7 | password = '' 8 | 9 | constructor(data : Partial = {}) { 10 | super() 11 | this.assignData(data) 12 | } 13 | } -------------------------------------------------------------------------------- /src/models/file.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import type {IFile} from '@/modelTypes/IFile' 3 | 4 | export default class FileModel extends AbstractModel implements IFile { 5 | id = 0 6 | mime = '' 7 | name = '' 8 | size = 0 9 | created: Date = null 10 | 11 | constructor(data: Partial) { 12 | super() 13 | this.assignData(data) 14 | 15 | this.created = new Date(this.created) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/models/labelTask.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import type { ILabelTask } from '@/modelTypes/ILabelTask' 4 | 5 | export default class LabelTask extends AbstractModel implements ILabelTask { 6 | id = 0 7 | taskId = 0 8 | labelId = 0 9 | 10 | constructor(data: Partial) { 11 | super() 12 | this.assignData(data) 13 | } 14 | } -------------------------------------------------------------------------------- /src/models/linkShare.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserModel from './user' 3 | 4 | import {RIGHTS, type Right} from '@/constants/rights' 5 | import type {ILinkShare} from '@/modelTypes/ILinkShare' 6 | import type {IUser} from '@/modelTypes/IUser' 7 | 8 | export default class LinkShareModel extends AbstractModel implements ILinkShare { 9 | id = 0 10 | hash = '' 11 | right: Right = RIGHTS.READ 12 | sharedBy: IUser = UserModel 13 | sharingType = 0 // FIXME: use correct numbers 14 | projectId = 0 15 | name: '' 16 | password: '' 17 | created: Date = null 18 | updated: Date = null 19 | 20 | constructor(data: Partial) { 21 | super() 22 | this.assignData(data) 23 | 24 | this.sharedBy = new UserModel(this.sharedBy) 25 | 26 | this.created = new Date(this.created) 27 | this.updated = new Date(this.updated) 28 | } 29 | } -------------------------------------------------------------------------------- /src/models/passwordReset.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import type {IPasswordReset} from '@/modelTypes/IPasswordReset' 4 | 5 | export default class PasswordResetModel extends AbstractModel implements IPasswordReset { 6 | token = '' 7 | newPassword = '' 8 | email = '' 9 | 10 | constructor(data: Partial) { 11 | super() 12 | this.assignData(data) 13 | 14 | this.token = localStorage.getItem('passwordResetToken') 15 | } 16 | } -------------------------------------------------------------------------------- /src/models/passwordUpdate.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import type {IPasswordUpdate} from '@/modelTypes/IPasswordUpdate' 4 | 5 | export default class PasswordUpdateModel extends AbstractModel implements IPasswordUpdate { 6 | newPassword = '' 7 | oldPassword = '' 8 | 9 | constructor(data: Partial = {}) { 10 | super() 11 | this.assignData(data) 12 | } 13 | } -------------------------------------------------------------------------------- /src/models/projectDuplicateModel.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import ProjectModel from './project' 3 | 4 | import type {IProjectDuplicate} from '@/modelTypes/IProjectDuplicate' 5 | import type {IProject} from '@/modelTypes/IProject' 6 | 7 | export default class ProjectDuplicateModel extends AbstractModel implements IProjectDuplicate { 8 | projectId = 0 9 | duplicatedProject: IProject | null = null 10 | parentProjectId = 0 11 | 12 | constructor(data : Partial) { 13 | super() 14 | this.assignData(data) 15 | 16 | this.duplicatedProject = this.duplicatedProject ? new ProjectModel(this.duplicatedProject) : null 17 | } 18 | } -------------------------------------------------------------------------------- /src/models/savedFilter.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserModel from '@/models/user' 3 | 4 | import type {ISavedFilter} from '@/modelTypes/ISavedFilter' 5 | import type {IUser} from '@/modelTypes/IUser' 6 | 7 | export default class SavedFilterModel extends AbstractModel implements ISavedFilter { 8 | id = 0 9 | title = '' 10 | description = '' 11 | filters: ISavedFilter['filters'] = { 12 | sortBy: ['done', 'id'], 13 | orderBy: ['asc', 'desc'], 14 | filterBy: ['done'], 15 | filterValue: ['false'], 16 | filterComparator: ['equals'], 17 | filterConcat: 'and', 18 | filterIncludeNulls: true, 19 | } 20 | 21 | owner: IUser = {} 22 | created: Date = null 23 | updated: Date = null 24 | 25 | constructor(data: Partial = {}) { 26 | super() 27 | this.assignData(data) 28 | 29 | this.owner = new UserModel(this.owner) 30 | 31 | this.created = new Date(this.created) 32 | this.updated = new Date(this.updated) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/models/subscription.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserModel from '@/models/user' 3 | 4 | import type {ISubscription} from '@/modelTypes/ISubscription' 5 | import type {IUser} from '@/modelTypes/IUser' 6 | 7 | export default class SubscriptionModel extends AbstractModel implements ISubscription { 8 | id = 0 9 | entity = '' 10 | entityId = 0 11 | user: IUser = {} 12 | 13 | created: Date = null 14 | 15 | constructor(data : Partial) { 16 | super() 17 | this.assignData(data) 18 | 19 | this.created = new Date(this.created) 20 | this.user = new UserModel(this.user) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/models/taskAssignee.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import type {ITaskAssignee} from '@/modelTypes/ITaskAssignee' 4 | import type {IUser} from '@/modelTypes/IUser' 5 | import type {ITask} from '@/modelTypes/ITask' 6 | 7 | export default class TaskAssigneeModel extends AbstractModel implements ITaskAssignee { 8 | created: Date = null 9 | userId: IUser['id'] = 0 10 | taskId: ITask['id'] = 0 11 | 12 | constructor(data: Partial) { 13 | super() 14 | this.assignData(data) 15 | this.created = new Date(this.created) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/models/taskComment.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserModel from './user' 3 | 4 | import type {ITaskComment} from '@/modelTypes/ITaskComment' 5 | import type {ITask} from '@/modelTypes/ITask' 6 | import type {IUser} from '@/modelTypes/IUser' 7 | 8 | export default class TaskCommentModel extends AbstractModel implements ITaskComment { 9 | id = 0 10 | taskId: ITask['id'] = 0 11 | comment = '' 12 | author: IUser = UserModel 13 | 14 | created: Date = null 15 | updated: Date = null 16 | 17 | constructor(data: Partial = {}) { 18 | super() 19 | this.assignData(data) 20 | 21 | this.author = new UserModel(this.author) 22 | this.created = new Date(this.created) 23 | this.updated = new Date(this.updated) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/models/taskRelation.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserModel from './user' 3 | 4 | import type {ITaskRelation} from '@/modelTypes/ITaskRelation' 5 | import type {ITask} from '@/modelTypes/ITask' 6 | import type {IUser} from '@/modelTypes/IUser' 7 | 8 | import {RELATION_KIND, type IRelationKind} from '@/types/IRelationKind' 9 | export default class TaskRelationModel extends AbstractModel implements ITaskRelation { 10 | id = 0 11 | otherTaskId: ITask['id'] = 0 12 | taskId: ITask['id'] = 0 13 | relationKind: IRelationKind = RELATION_KIND.RELATED 14 | 15 | createdBy: IUser = new UserModel() 16 | created: Date = new Date 17 | 18 | constructor(data: Partial) { 19 | super() 20 | this.assignData(data) 21 | 22 | this.createdBy = new UserModel(this.createdBy) 23 | this.created = new Date(this.created) 24 | } 25 | } -------------------------------------------------------------------------------- /src/models/taskReminder.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import type {ITaskReminder} from '@/modelTypes/ITaskReminder' 3 | import {parseDateOrNull} from '@/helpers/parseDateOrNull' 4 | import type {IReminderPeriodRelativeTo} from '@/types/IReminderPeriodRelativeTo' 5 | 6 | export default class TaskReminderModel extends AbstractModel implements ITaskReminder { 7 | reminder: Date | null 8 | relativePeriod = 0 9 | relativeTo: IReminderPeriodRelativeTo | null = null 10 | 11 | constructor(data: Partial = {}) { 12 | super() 13 | this.assignData(data) 14 | this.reminder = parseDateOrNull(data.reminder) 15 | if (this.relativeTo === '') { 16 | this.relativeTo = null 17 | } 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /src/models/team.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserModel from './user' 3 | import TeamMemberModel from './teamMember' 4 | 5 | import {RIGHTS, type Right} from '@/constants/rights' 6 | import type {ITeam} from '@/modelTypes/ITeam' 7 | import type {ITeamMember} from '@/modelTypes/ITeamMember' 8 | import type {IUser} from '@/modelTypes/IUser' 9 | 10 | export default class TeamModel extends AbstractModel implements ITeam { 11 | id = 0 12 | name = '' 13 | description = '' 14 | members: ITeamMember[] = [] 15 | right: Right = RIGHTS.READ 16 | 17 | createdBy: IUser = {} // FIXME: seems wrong 18 | created: Date = null 19 | updated: Date = null 20 | 21 | constructor(data: Partial = {}) { 22 | super() 23 | this.assignData(data) 24 | 25 | // Make the members to usermodels 26 | this.members = this.members.map(m => { 27 | return new TeamMemberModel(m) 28 | }) 29 | this.createdBy = new UserModel(this.createdBy) 30 | 31 | this.created = new Date(this.created) 32 | this.updated = new Date(this.updated) 33 | } 34 | } -------------------------------------------------------------------------------- /src/models/teamMember.ts: -------------------------------------------------------------------------------- 1 | import UserModel from './user' 2 | 3 | import type {ITeamMember} from '@/modelTypes/ITeamMember' 4 | import type {IProject} from '@/modelTypes/IProject' 5 | 6 | export default class TeamMemberModel extends UserModel implements ITeamMember { 7 | admin = false 8 | teamId: IProject['id'] = 0 9 | 10 | constructor(data: Partial) { 11 | super(data) 12 | this.assignData(data) 13 | } 14 | } -------------------------------------------------------------------------------- /src/models/teamProject.ts: -------------------------------------------------------------------------------- 1 | import TeamShareBaseModel from './teamShareBase' 2 | 3 | import type {ITeamProject} from '@/modelTypes/ITeamProject' 4 | import type {IProject} from '@/modelTypes/IProject' 5 | 6 | export default class TeamProjectModel extends TeamShareBaseModel implements ITeamProject { 7 | projectId: IProject['id'] = 0 8 | 9 | constructor(data: Partial) { 10 | super(data) 11 | this.assignData(data) 12 | } 13 | } -------------------------------------------------------------------------------- /src/models/teamShareBase.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import {RIGHTS, type Right} from '@/constants/rights' 4 | import type {ITeamShareBase} from '@/modelTypes/ITeamShareBase' 5 | import type {ITeam} from '@/modelTypes/ITeam' 6 | 7 | /** 8 | * This class is a base class for common team sharing model. 9 | * It is extended in a way, so it can be used for projects. 10 | */ 11 | export default class TeamShareBaseModel extends AbstractModel implements ITeamShareBase { 12 | teamId: ITeam['id'] = 0 13 | right: Right = RIGHTS.READ 14 | 15 | created: Date = null 16 | updated: Date = null 17 | 18 | constructor(data: Partial) { 19 | super() 20 | this.assignData(data) 21 | 22 | this.created = new Date(this.created) 23 | this.updated = new Date(this.updated) 24 | } 25 | } -------------------------------------------------------------------------------- /src/models/totp.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import type {ITotp} from '@/modelTypes/ITotp' 4 | 5 | export default class TotpModel extends AbstractModel implements ITotp { 6 | secret = '' 7 | enabled = false 8 | url = '' 9 | 10 | constructor(data: Partial = {}) { 11 | super() 12 | this.assignData(data) 13 | } 14 | } -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | import UserSettingsModel from '@/models/userSettings' 3 | 4 | import { AUTH_TYPES, type IUser, type AuthType } from '@/modelTypes/IUser' 5 | import type { IUserSettings } from '@/modelTypes/IUserSettings' 6 | 7 | export function getAvatarUrl(user: IUser, size = 50) { 8 | return `${window.API_URL}/avatar/${user.username}?size=${size}` 9 | } 10 | 11 | export function getDisplayName(user: IUser) { 12 | if (user.name !== '') { 13 | return user.name 14 | } 15 | 16 | return user.username 17 | } 18 | 19 | export default class UserModel extends AbstractModel implements IUser { 20 | id = 0 21 | email = '' 22 | username = '' 23 | name = '' 24 | exp = 0 25 | type: AuthType = AUTH_TYPES.UNKNOWN 26 | 27 | created: Date 28 | updated: Date 29 | settings: IUserSettings 30 | 31 | isLocalUser: boolean 32 | deletionScheduledAt: null 33 | 34 | constructor(data: Partial = {}) { 35 | super() 36 | this.assignData(data) 37 | 38 | this.created = new Date(this.created) 39 | this.updated = new Date(this.updated) 40 | 41 | this.settings = new UserSettingsModel(this.settings || {}) 42 | } 43 | } -------------------------------------------------------------------------------- /src/models/userProject.ts: -------------------------------------------------------------------------------- 1 | import UserShareBaseModel from './userShareBase' 2 | 3 | import type {IUserProject} from '@/modelTypes/IUserProject' 4 | import type {IProject} from '@/modelTypes/IProject' 5 | 6 | // This class extends the user share model with a 'rights' parameter which is used in sharing 7 | export default class UserProjectModel extends UserShareBaseModel implements IUserProject { 8 | projectId: IProject['id'] = 0 9 | 10 | constructor(data: Partial) { 11 | super(data) 12 | this.assignData(data) 13 | } 14 | } -------------------------------------------------------------------------------- /src/models/userSettings.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import type {IFrontendSettings, IUserSettings} from '@/modelTypes/IUserSettings' 4 | import {getBrowserLanguage} from '@/i18n' 5 | import {PrefixMode} from '@/modules/parseTaskText' 6 | 7 | export default class UserSettingsModel extends AbstractModel implements IUserSettings { 8 | name = '' 9 | emailRemindersEnabled = true 10 | discoverableByName = false 11 | discoverableByEmail = false 12 | overdueTasksRemindersEnabled = true 13 | overdueTasksRemindersTime = undefined 14 | defaultProjectId = undefined 15 | weekStart = 0 as IUserSettings['weekStart'] 16 | timezone = '' 17 | language = getBrowserLanguage() 18 | frontendSettings: IFrontendSettings = { 19 | playSoundWhenDone: true, 20 | quickAddMagicMode: PrefixMode.Default, 21 | colorSchema: 'auto', 22 | } 23 | 24 | constructor(data: Partial = {}) { 25 | super() 26 | this.assignData(data) 27 | } 28 | } -------------------------------------------------------------------------------- /src/models/userShareBase.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from './abstractModel' 2 | 3 | import {RIGHTS, type Right} from '@/constants/rights' 4 | import type {IUserShareBase} from '@/modelTypes/IUserShareBase' 5 | import type {IUser} from '@/modelTypes/IUser' 6 | 7 | export default class UserShareBaseModel extends AbstractModel implements IUserShareBase { 8 | userId: IUser['id'] = '' 9 | right: Right = RIGHTS.READ 10 | 11 | created: Date = null 12 | updated: Date = null 13 | 14 | constructor(data: Partial) { 15 | super() 16 | this.assignData(data) 17 | 18 | this.created = new Date(this.created) 19 | this.updated = new Date(this.updated) 20 | } 21 | } -------------------------------------------------------------------------------- /src/models/webhook.ts: -------------------------------------------------------------------------------- 1 | import AbstractModel from '@/models/abstractModel' 2 | import type {IWebhook} from '@/modelTypes/IWebhook' 3 | import UserModel from '@/models/user' 4 | 5 | export default class WebhookModel extends AbstractModel implements IWebhook { 6 | id = 0 7 | projectId = 0 8 | secret = '' 9 | targetUrl = '' 10 | events = [] 11 | createdBy = null 12 | 13 | created: Date 14 | updated: Date 15 | 16 | constructor(data: Partial = {}) { 17 | super() 18 | this.assignData(data) 19 | 20 | this.createdBy = new UserModel(this.createdBy) 21 | 22 | this.created = new Date(this.created) 23 | this.updated = new Date(this.updated) 24 | } 25 | } -------------------------------------------------------------------------------- /src/modules/projectHistory.ts: -------------------------------------------------------------------------------- 1 | export interface ProjectHistory { 2 | id: number; 3 | } 4 | 5 | export function getHistory(): ProjectHistory[] { 6 | const savedHistory = localStorage.getItem('projectHistory') 7 | if (savedHistory === null) { 8 | return [] 9 | } 10 | 11 | return JSON.parse(savedHistory) 12 | } 13 | 14 | function saveHistory(history: ProjectHistory[]) { 15 | if (history.length === 0) { 16 | localStorage.removeItem('projectHistory') 17 | return 18 | } 19 | 20 | localStorage.setItem('projectHistory', JSON.stringify(history)) 21 | } 22 | 23 | export function saveProjectToHistory(project: ProjectHistory) { 24 | const history: ProjectHistory[] = getHistory() 25 | 26 | // Remove the element if it already exists in history, preventing duplicates and essentially moving it to the beginning 27 | history.forEach((l, i) => { 28 | if (l.id === project.id) { 29 | history.splice(i, 1) 30 | } 31 | }) 32 | 33 | // Add the new project to the beginning of the project 34 | history.unshift(project) 35 | 36 | if (history.length > 5) { 37 | history.pop() 38 | } 39 | saveHistory(history) 40 | } 41 | 42 | export function removeProjectFromHistory(project: ProjectHistory) { 43 | const history: ProjectHistory[] = getHistory() 44 | 45 | history.forEach((l, i) => { 46 | if (l.id === project.id) { 47 | history.splice(i, 1) 48 | } 49 | }) 50 | saveHistory(history) 51 | } 52 | -------------------------------------------------------------------------------- /src/pinia.ts: -------------------------------------------------------------------------------- 1 | import {createPinia} from 'pinia' 2 | 3 | const pinia = createPinia() 4 | 5 | export default pinia 6 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | // in order to use postcss-preset-env correctly we need some client side plugins 2 | import focusWithinInit from 'postcss-focus-within/browser' 3 | import cssHasPseudo from 'css-has-pseudo/browser' 4 | 5 | focusWithinInit() 6 | cssHasPseudo(document) -------------------------------------------------------------------------------- /src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import {register} from 'register-service-worker' 4 | 5 | import {getFullBaseUrl} from './helpers/getFullBaseUrl' 6 | 7 | if (import.meta.env.PROD) { 8 | register(getFullBaseUrl() + 'sw.js', { 9 | ready() { 10 | console.log('App is being served from cache by a service worker.') 11 | }, 12 | registered() { 13 | console.log('Service worker has been registered.') 14 | }, 15 | cached() { 16 | console.log('Content has been cached for offline use.') 17 | }, 18 | updatefound() { 19 | console.log('New content is downloading.') 20 | }, 21 | updated(registration) { 22 | console.log('New content is available; please refresh.') 23 | // Send an event with the updated info 24 | document.dispatchEvent( 25 | new CustomEvent('swUpdated', {detail: registration}), 26 | ) 27 | }, 28 | offline() { 29 | console.log('No internet connection found. App is running in offline mode.') 30 | }, 31 | error(error) { 32 | console.error('Error during service worker registration:', error) 33 | }, 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/sentry.ts: -------------------------------------------------------------------------------- 1 | import 'virtual:vite-plugin-sentry/sentry-config' 2 | import type {App} from 'vue' 3 | import type {Router} from 'vue-router' 4 | 5 | export default async function setupSentry(app: App, router: Router) { 6 | const Sentry = await import('@sentry/vue') 7 | const {Integrations} = await import('@sentry/tracing') 8 | 9 | Sentry.init({ 10 | app, 11 | dsn: window.SENTRY_DSN, 12 | release: import.meta.env.VITE_PLUGIN_SENTRY_CONFIG.release, 13 | dist: import.meta.env.VITE_PLUGIN_SENTRY_CONFIG.dist, 14 | integrations: [ 15 | new Integrations.BrowserTracing({ 16 | routingInstrumentation: Sentry.vueRouterInstrumentation(router), 17 | tracingOrigins: ['localhost', /^\//], 18 | }), 19 | ], 20 | tracesSampleRate: 1.0, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/services/accountDelete.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | 3 | export default class AccountDeleteService extends AbstractService { 4 | request(password: string) { 5 | return this.post('/user/deletion/request', {password}) 6 | } 7 | 8 | confirm(token: string) { 9 | return this.post('/user/deletion/confirm', {token}) 10 | } 11 | 12 | cancel(password: string) { 13 | return this.post('/user/deletion/cancel', {password}) 14 | } 15 | } -------------------------------------------------------------------------------- /src/services/apiToken.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from '@/services/abstractService' 2 | import type {IApiToken} from '@/modelTypes/IApiToken' 3 | import ApiTokenModel from '@/models/apiTokenModel' 4 | 5 | export default class ApiTokenService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/tokens', 9 | getAll: '/tokens', 10 | delete: '/tokens/{id}', 11 | }) 12 | } 13 | 14 | processModel(model: IApiToken) { 15 | return { 16 | ...model, 17 | expiresAt: new Date(model.expiresAt).toISOString(), 18 | created: new Date(model.created).toISOString(), 19 | } 20 | } 21 | 22 | modelFactory(data: Partial) { 23 | return new ApiTokenModel(data) 24 | } 25 | 26 | async getAvailableRoutes() { 27 | const cancel = this.setLoading() 28 | 29 | try { 30 | const response = await this.http.get('/routes') 31 | return response.data 32 | } finally { 33 | cancel() 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/services/avatar.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import AvatarModel from '@/models/avatar' 3 | import type { IAvatar } from '@/modelTypes/IAvatar' 4 | 5 | export default class AvatarService extends AbstractService { 6 | constructor() { 7 | super({ 8 | get: '/user/settings/avatar', 9 | update: '/user/settings/avatar', 10 | create: '/user/settings/avatar/upload', 11 | }) 12 | } 13 | 14 | modelFactory(data: Partial) { 15 | return new AvatarModel(data) 16 | } 17 | 18 | useCreateInterceptor() { 19 | return false 20 | } 21 | 22 | create(blob) { 23 | return this.uploadBlob( 24 | this.paths.create, 25 | blob, 26 | 'avatar', 27 | 'avatar.jpg', // This fails without a file name 28 | ) 29 | } 30 | } -------------------------------------------------------------------------------- /src/services/backgroundUnsplash.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import BackgroundImageModel from '../models/backgroundImage' 3 | import ProjectModel from '@/models/project' 4 | import type { IBackgroundImage } from '@/modelTypes/IBackgroundImage' 5 | 6 | export default class BackgroundUnsplashService extends AbstractService { 7 | constructor() { 8 | super({ 9 | getAll: '/backgrounds/unsplash/search', 10 | update: '/projects/{projectId}/backgrounds/unsplash', 11 | }) 12 | } 13 | 14 | modelFactory(data: Partial) { 15 | return new BackgroundImageModel(data) 16 | } 17 | 18 | modelUpdateFactory(data) { 19 | return new ProjectModel(data) 20 | } 21 | 22 | async thumb(model) { 23 | const response = await this.http({ 24 | url: `/backgrounds/unsplash/images/${model.id}/thumb`, 25 | method: 'GET', 26 | responseType: 'blob', 27 | }) 28 | return window.URL.createObjectURL(new Blob([response.data])) 29 | } 30 | } -------------------------------------------------------------------------------- /src/services/backgroundUpload.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import ProjectModel from '@/models/project' 3 | 4 | import type { IProject } from '@/modelTypes/IProject' 5 | import type { IFile } from '@/modelTypes/IFile' 6 | 7 | export default class BackgroundUploadService extends AbstractService { 8 | constructor() { 9 | super({ 10 | create: '/projects/{projectId}/backgrounds/upload', 11 | }) 12 | } 13 | 14 | useCreateInterceptor() { 15 | return false 16 | } 17 | 18 | modelCreateFactory(data: Partial) { 19 | return new ProjectModel(data) 20 | } 21 | 22 | /** 23 | * Uploads a file to the server 24 | */ 25 | create(projectId: IProject['id'], file: IFile) { 26 | return this.uploadFile( 27 | this.getReplacedRoute(this.paths.create, {projectId}), 28 | file, 29 | 'background', 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/services/bucket.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import BucketModel from '../models/bucket' 3 | import TaskService from '@/services/task' 4 | import type { IBucket } from '@/modelTypes/IBucket' 5 | 6 | export default class BucketService extends AbstractService { 7 | constructor() { 8 | super({ 9 | getAll: '/projects/{projectId}/buckets', 10 | create: '/projects/{projectId}/buckets', 11 | update: '/projects/{projectId}/buckets/{id}', 12 | delete: '/projects/{projectId}/buckets/{id}', 13 | }) 14 | } 15 | 16 | modelFactory(data: Partial) { 17 | return new BucketModel(data) 18 | } 19 | 20 | beforeUpdate(model) { 21 | const taskService = new TaskService() 22 | model.tasks = model.tasks?.map(t => taskService.processModel(t)) 23 | return model 24 | } 25 | } -------------------------------------------------------------------------------- /src/services/caldavToken.ts: -------------------------------------------------------------------------------- 1 | import CaldavTokenModel from '@/models/caldavToken' 2 | import type {ICaldavToken} from '@/modelTypes/ICaldavToken' 3 | import AbstractService from './abstractService' 4 | 5 | export default class CaldavTokenService extends AbstractService { 6 | constructor() { 7 | super({ 8 | getAll: '/user/settings/token/caldav', 9 | create: '/user/settings/token/caldav', 10 | delete: '/user/settings/token/caldav/{id}', 11 | }) 12 | } 13 | 14 | modelFactory(data) { 15 | return new CaldavTokenModel(data) 16 | } 17 | } -------------------------------------------------------------------------------- /src/services/dataExport.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import {downloadBlob} from '../helpers/downloadBlob' 3 | 4 | const DOWNLOAD_NAME = 'vikunja-export.zip' 5 | 6 | export default class DataExportService extends AbstractService { 7 | request(password: string) { 8 | return this.post('/user/export/request', {password}) 9 | } 10 | 11 | async download(password: string) { 12 | const clear = this.setLoading() 13 | try { 14 | const url = await this.getBlobUrl('/user/export/download', 'POST', {password}) 15 | downloadBlob(url, DOWNLOAD_NAME) 16 | } finally { 17 | clear() 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/services/emailUpdate.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | 3 | export default class EmailUpdateService extends AbstractService { 4 | constructor() { 5 | super({ 6 | update: '/user/settings/email', 7 | }) 8 | } 9 | } -------------------------------------------------------------------------------- /src/services/label.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import LabelModel from '@/models/label' 3 | import type {ILabel} from '@/modelTypes/ILabel' 4 | import {colorFromHex} from '@/helpers/color/colorFromHex' 5 | 6 | export default class LabelService extends AbstractService { 7 | constructor() { 8 | super({ 9 | create: '/labels', 10 | getAll: '/labels', 11 | get: '/labels/{id}', 12 | update: '/labels/{id}', 13 | delete: '/labels/{id}', 14 | }) 15 | } 16 | 17 | processModel(label) { 18 | label.created = new Date(label.created).toISOString() 19 | label.updated = new Date(label.updated).toISOString() 20 | label.hexColor = colorFromHex(label.hexColor) 21 | return label 22 | } 23 | 24 | modelFactory(data) { 25 | return new LabelModel(data) 26 | } 27 | 28 | beforeUpdate(label) { 29 | return this.processModel(label) 30 | } 31 | 32 | beforeCreate(label) { 33 | return this.processModel(label) 34 | } 35 | } -------------------------------------------------------------------------------- /src/services/labelTask.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import LabelTask from '@/models/labelTask' 3 | import type {ILabelTask} from '@/modelTypes/ILabelTask' 4 | 5 | export default class LabelTaskService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/tasks/{taskId}/labels', 9 | getAll: '/tasks/{taskId}/labels', 10 | delete: '/tasks/{taskId}/labels/{labelId}', 11 | }) 12 | } 13 | 14 | modelFactory(data) { 15 | return new LabelTask(data) 16 | } 17 | } -------------------------------------------------------------------------------- /src/services/linkShare.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import LinkShareModel from '@/models/linkShare' 3 | import type {ILinkShare} from '@/modelTypes/ILinkShare' 4 | 5 | export default class LinkShareService extends AbstractService { 6 | constructor() { 7 | super({ 8 | getAll: '/projects/{projectId}/shares', 9 | get: '/projects/{projectId}/shares/{id}', 10 | create: '/projects/{projectId}/shares', 11 | delete: '/projects/{projectId}/shares/{id}', 12 | }) 13 | } 14 | 15 | modelFactory(data) { 16 | return new LinkShareModel(data) 17 | } 18 | } -------------------------------------------------------------------------------- /src/services/migrator/abstractMigration.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from '../abstractService' 2 | 3 | export type MigrationConfig = { code: string } 4 | 5 | // This service builds on top of the abstract service and basically just hides away method names. 6 | // It enables migration services to be created with minimal overhead and even better method names. 7 | export default class AbstractMigrationService extends AbstractService { 8 | serviceUrlKey = '' 9 | 10 | constructor(serviceUrlKey: string) { 11 | super({ 12 | update: '/migration/' + serviceUrlKey + '/migrate', 13 | }) 14 | this.serviceUrlKey = serviceUrlKey 15 | } 16 | 17 | getAuthUrl() { 18 | return this.getM('/migration/' + this.serviceUrlKey + '/auth') 19 | } 20 | 21 | getStatus() { 22 | return this.getM('/migration/' + this.serviceUrlKey + '/status') 23 | } 24 | 25 | migrate(data: MigrationConfig) { 26 | return this.update(data) 27 | } 28 | } -------------------------------------------------------------------------------- /src/services/migrator/abstractMigrationFile.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from '../abstractService' 2 | 3 | // This service builds on top of the abstract service and basically just hides away method names. 4 | // It enables migration services to be created with minimal overhead and even better method names. 5 | export default class AbstractMigrationFileService extends AbstractService { 6 | serviceUrlKey = '' 7 | 8 | constructor(serviceUrlKey: string) { 9 | super({ 10 | create: '/migration/' + serviceUrlKey + '/migrate', 11 | }) 12 | this.serviceUrlKey = serviceUrlKey 13 | } 14 | 15 | getStatus() { 16 | return this.getM('/migration/' + this.serviceUrlKey + '/status') 17 | } 18 | 19 | useCreateInterceptor() { 20 | return false 21 | } 22 | 23 | migrate(file: File) { 24 | return this.uploadFile( 25 | this.paths.create, 26 | file, 27 | 'import', 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/notification.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from '@/services/abstractService' 2 | import NotificationModel from '@/models/notification' 3 | import type {INotification} from '@/modelTypes/INotification' 4 | 5 | export default class NotificationService extends AbstractService { 6 | constructor() { 7 | super({ 8 | getAll: '/notifications', 9 | update: '/notifications/{id}', 10 | }) 11 | } 12 | 13 | modelFactory(data) { 14 | return new NotificationModel(data) 15 | } 16 | 17 | beforeUpdate(model) { 18 | if (!model) { 19 | return model 20 | } 21 | 22 | model.created = new Date(model.created).toISOString() 23 | model.readAt = new Date(model.readAt).toISOString() 24 | return model 25 | } 26 | 27 | async markAllRead() { 28 | return this.post('/notifications', false) 29 | } 30 | } -------------------------------------------------------------------------------- /src/services/passwordReset.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import PasswordResetModel from '@/models/passwordReset' 3 | import type {IPasswordReset} from '@/modelTypes/IPasswordReset' 4 | 5 | export default class PasswordResetService extends AbstractService { 6 | 7 | constructor() { 8 | super({}) 9 | this.paths = { 10 | reset: '/user/password/reset', 11 | requestReset: '/user/password/token', 12 | } 13 | } 14 | 15 | modelFactory(data) { 16 | return new PasswordResetModel(data) 17 | } 18 | 19 | async resetPassword(model) { 20 | const cancel = this.setLoading() 21 | try { 22 | const response = await this.http.post(this.paths.reset, model) 23 | return this.modelFactory(response.data) 24 | } finally { 25 | cancel() 26 | } 27 | } 28 | 29 | async requestResetPassword(model) { 30 | const cancel = this.setLoading() 31 | try { 32 | const response = await this.http.post(this.paths.requestReset, model) 33 | return this.modelFactory(response.data) 34 | } finally { 35 | cancel() 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/services/passwordUpdateService.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import type {IPasswordUpdate} from '@/modelTypes/IPasswordUpdate' 3 | 4 | export default class PasswordUpdateService extends AbstractService { 5 | constructor() { 6 | super({ 7 | update: '/user/password', 8 | }) 9 | } 10 | } -------------------------------------------------------------------------------- /src/services/projectDuplicateService.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import projectDuplicateModel from '@/models/projectDuplicateModel' 3 | import type {IProjectDuplicate} from '@/modelTypes/IProjectDuplicate' 4 | 5 | export default class ProjectDuplicateService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/projects/{projectId}/duplicate', 9 | }) 10 | } 11 | 12 | beforeCreate(model) { 13 | 14 | model.project = null 15 | return model 16 | } 17 | 18 | modelFactory(data) { 19 | return new projectDuplicateModel(data) 20 | } 21 | } -------------------------------------------------------------------------------- /src/services/projectUsers.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import UserModel from '../models/user' 3 | 4 | export default class ProjectUserService extends AbstractService { 5 | constructor() { 6 | super({ 7 | getAll: '/projects/{projectId}/projectusers', 8 | }) 9 | } 10 | 11 | modelFactory(data) { 12 | return new UserModel(data) 13 | } 14 | } -------------------------------------------------------------------------------- /src/services/subscription.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from '@/services/abstractService' 2 | import SubscriptionModel from '@/models/subscription' 3 | import type {ISubscription} from '@/modelTypes/ISubscription' 4 | 5 | export default class SubscriptionService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/subscriptions/{entity}/{entityId}', 9 | delete: '/subscriptions/{entity}/{entityId}', 10 | }) 11 | } 12 | 13 | modelFactory(data) { 14 | return new SubscriptionModel(data) 15 | } 16 | } -------------------------------------------------------------------------------- /src/services/taskAssignee.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import TaskAssigneeModel from '@/models/taskAssignee' 3 | import type {ITaskAssignee} from '@/modelTypes/ITaskAssignee' 4 | 5 | export default class TaskAssigneeService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/tasks/{taskId}/assignees', 9 | delete: '/tasks/{taskId}/assignees/{userId}', 10 | }) 11 | } 12 | 13 | modelFactory(data) { 14 | return new TaskAssigneeModel(data) 15 | } 16 | } -------------------------------------------------------------------------------- /src/services/taskCollection.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from '@/services/abstractService' 2 | import TaskModel from '@/models/task' 3 | 4 | import type {ITask} from '@/modelTypes/ITask' 5 | 6 | // FIXME: unite with other filter params types 7 | export interface GetAllTasksParams { 8 | sort_by: ('start_date' | 'done' | 'id')[], 9 | order_by: ('asc' | 'asc' | 'desc')[], 10 | filter_by: 'start_date'[], 11 | filter_comparator: ('greater_equals' | 'less_equals')[], 12 | filter_value: [string, string] // [dateFrom, dateTo], 13 | filter_concat: 'and', 14 | filter_include_nulls: boolean, 15 | } 16 | 17 | export default class TaskCollectionService extends AbstractService { 18 | constructor() { 19 | super({ 20 | getAll: '/projects/{projectId}/tasks', 21 | }) 22 | } 23 | 24 | modelFactory(data) { 25 | return new TaskModel(data) 26 | } 27 | } -------------------------------------------------------------------------------- /src/services/taskComment.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import TaskCommentModel from '@/models/taskComment' 3 | import type {ITaskComment} from '@/modelTypes/ITaskComment' 4 | 5 | export default class TaskCommentService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/tasks/{taskId}/comments', 9 | getAll: '/tasks/{taskId}/comments', 10 | get: '/tasks/{taskId}/comments/{id}', 11 | update: '/tasks/{taskId}/comments/{id}', 12 | delete: '/tasks/{taskId}/comments/{id}', 13 | }) 14 | } 15 | 16 | modelFactory(data) { 17 | return new TaskCommentModel(data) 18 | } 19 | } -------------------------------------------------------------------------------- /src/services/taskRelation.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import TaskRelationModel from '@/models/taskRelation' 3 | import type {ITaskRelation} from '@/modelTypes/ITaskRelation' 4 | 5 | export default class TaskRelationService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/tasks/{taskId}/relations', 9 | delete: '/tasks/{taskId}/relations/{relationKind}/{otherTaskId}', 10 | }) 11 | } 12 | 13 | modelFactory(data) { 14 | return new TaskRelationModel(data) 15 | } 16 | } -------------------------------------------------------------------------------- /src/services/team.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import TeamModel from '@/models/team' 3 | import type {ITeam} from '@/modelTypes/ITeam' 4 | 5 | export default class TeamService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/teams', 9 | get: '/teams/{id}', 10 | getAll: '/teams', 11 | update: '/teams/{id}', 12 | delete: '/teams/{id}', 13 | }) 14 | } 15 | 16 | modelFactory(data) { 17 | return new TeamModel(data) 18 | } 19 | } -------------------------------------------------------------------------------- /src/services/teamMember.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import TeamMemberModel from '@/models/teamMember' 3 | import type {ITeamMember} from '@/modelTypes/ITeamMember' 4 | 5 | export default class TeamMemberService extends AbstractService { 6 | constructor() { 7 | super({ 8 | create: '/teams/{teamId}/members', 9 | delete: '/teams/{teamId}/members/{username}', 10 | update: '/teams/{teamId}/members/{username}/admin', 11 | }) 12 | } 13 | 14 | modelFactory(data) { 15 | return new TeamMemberModel(data) 16 | } 17 | 18 | beforeCreate(model) { 19 | model.userId = model.id // The api wants to get the user id as user_Id 20 | model.admin = model.admin === null ? false : model.admin 21 | return model 22 | } 23 | } -------------------------------------------------------------------------------- /src/services/teamProject.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import TeamProjectModel from '@/models/teamProject' 3 | import type {ITeamProject} from '@/modelTypes/ITeamProject' 4 | import TeamModel from '@/models/team' 5 | 6 | export default class TeamProjectService extends AbstractService { 7 | constructor() { 8 | super({ 9 | create: '/projects/{projectId}/teams', 10 | getAll: '/projects/{projectId}/teams', 11 | update: '/projects/{projectId}/teams/{teamId}', 12 | delete: '/projects/{projectId}/teams/{teamId}', 13 | }) 14 | } 15 | 16 | modelFactory(data) { 17 | return new TeamProjectModel(data) 18 | } 19 | 20 | modelGetAllFactory(data) { 21 | return new TeamModel(data) 22 | } 23 | } -------------------------------------------------------------------------------- /src/services/totp.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import TotpModel from '@/models/totp' 3 | import type {ITotp} from '@/modelTypes/ITotp' 4 | 5 | export default class TotpService extends AbstractService { 6 | urlPrefix = '/user/settings/totp' 7 | 8 | constructor() { 9 | super({}) 10 | 11 | this.paths.get = this.urlPrefix 12 | } 13 | 14 | modelFactory(data) { 15 | return new TotpModel(data) 16 | } 17 | 18 | enroll() { 19 | return this.post(`${this.urlPrefix}/enroll`, {}) 20 | } 21 | 22 | enable(model) { 23 | return this.post(`${this.urlPrefix}/enable`, model) 24 | } 25 | 26 | disable(model) { 27 | return this.post(`${this.urlPrefix}/disable`, model) 28 | } 29 | 30 | async qrcode() { 31 | const response = await this.http({ 32 | url: `${this.urlPrefix}/qrcode`, 33 | method: 'GET', 34 | responseType: 'blob', 35 | }) 36 | return new Blob([response.data]) 37 | } 38 | } -------------------------------------------------------------------------------- /src/services/user.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import UserModel from '@/models/user' 3 | import type {IUser} from '@/modelTypes/IUser' 4 | 5 | export default class UserService extends AbstractService { 6 | constructor() { 7 | super({ 8 | getAll: '/users', 9 | }) 10 | } 11 | 12 | modelFactory(data) { 13 | return new UserModel(data) 14 | } 15 | } -------------------------------------------------------------------------------- /src/services/userProject.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from './abstractService' 2 | import UserProjectModel from '@/models/userProject' 3 | import type {IUserProject} from '@/modelTypes/IUserProject' 4 | import UserModel from '@/models/user' 5 | 6 | export default class UserProjectService extends AbstractService { 7 | constructor() { 8 | super({ 9 | create: '/projects/{projectId}/users', 10 | getAll: '/projects/{projectId}/users', 11 | update: '/projects/{projectId}/users/{userId}', 12 | delete: '/projects/{projectId}/users/{userId}', 13 | }) 14 | } 15 | 16 | modelFactory(data) { 17 | return new UserProjectModel(data) 18 | } 19 | 20 | modelGetAllFactory(data) { 21 | return new UserModel(data) 22 | } 23 | } -------------------------------------------------------------------------------- /src/services/userSettings.ts: -------------------------------------------------------------------------------- 1 | import type {IUserSettings} from '@/modelTypes/IUserSettings' 2 | import AbstractService from './abstractService' 3 | 4 | export default class UserSettingsService extends AbstractService { 5 | constructor() { 6 | super({ 7 | update: '/user/settings/general', 8 | }) 9 | } 10 | } -------------------------------------------------------------------------------- /src/services/webhook.ts: -------------------------------------------------------------------------------- 1 | import AbstractService from '@/services/abstractService' 2 | import type {IWebhook} from '@/modelTypes/IWebhook' 3 | import WebhookModel from '@/models/webhook' 4 | 5 | export default class WebhookService extends AbstractService { 6 | constructor() { 7 | super({ 8 | getAll: '/projects/{projectId}/webhooks', 9 | create: '/projects/{projectId}/webhooks', 10 | update: '/projects/{projectId}/webhooks/{id}', 11 | delete: '/projects/{projectId}/webhooks/{id}', 12 | }) 13 | } 14 | 15 | modelFactory(data) { 16 | return new WebhookModel(data) 17 | } 18 | 19 | async getAvailableEvents(): Promise { 20 | const cancel = this.setLoading() 21 | 22 | try { 23 | const response = await this.http.get('/webhooks/events') 24 | return response.data 25 | } finally { 26 | cancel() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/stores/attachments.ts: -------------------------------------------------------------------------------- 1 | import {ref, readonly} from 'vue' 2 | import {defineStore, acceptHMRUpdate} from 'pinia' 3 | import {findIndexById} from '@/helpers/utils' 4 | 5 | import type {IAttachment} from '@/modelTypes/IAttachment' 6 | 7 | export const useAttachmentStore = defineStore('attachment', () => { 8 | const attachments = ref([]) 9 | 10 | function set(newAttachments: IAttachment[]) { 11 | console.debug('Set attachments', newAttachments) 12 | attachments.value = newAttachments 13 | } 14 | 15 | function add(attachment: IAttachment) { 16 | console.debug('Add attachement', attachment) 17 | attachments.value.push(attachment) 18 | } 19 | 20 | function removeById(id: IAttachment['id']) { 21 | const attachmentIndex = findIndexById(attachments.value, id) 22 | attachments.value.splice(attachmentIndex, 1) 23 | console.debug('Remove attachement', id) 24 | } 25 | 26 | return { 27 | attachments: readonly(attachments), 28 | set, 29 | add, 30 | removeById, 31 | } 32 | }) 33 | 34 | // support hot reloading 35 | if (import.meta.hot) { 36 | import.meta.hot.accept(acceptHMRUpdate(useAttachmentStore, import.meta.hot)) 37 | } -------------------------------------------------------------------------------- /src/stores/helper.ts: -------------------------------------------------------------------------------- 1 | const LOADING_TIMEOUT = 100 2 | 3 | export function setModuleLoading(loadFunc: (isLoading: boolean) => void) { 4 | const timeout = setTimeout(() => loadFunc(true), LOADING_TIMEOUT) 5 | return () => { 6 | clearTimeout(timeout) 7 | loadFunc(false) 8 | } 9 | } -------------------------------------------------------------------------------- /src/styles/common-imports.scss: -------------------------------------------------------------------------------- 1 | // 2 | // IMPORTANT NOTE: 3 | // 4 | // The styles in this file and all imported styles should not 5 | // create CSS output when compiled! 6 | // Instead they should only define SCSS that gets compiled to nothing. 7 | // 8 | // The reason is that this file is prefixed in _every_ component style so that 9 | // the component has access to the variables, mixins, etc. that 10 | // are defined here. 11 | // 12 | 13 | $family-sans-serif: 'Open Sans', Helvetica, Arial, sans-serif; 14 | 15 | // the default values get overwritten by the definitions above 16 | @import "bulma-css-variables/sass/utilities/_all"; 17 | 18 | // since $tablet is defined by bulma we can just define it after importing the utilities 19 | $mobile: math.div($tablet, 2); 20 | 21 | $vikunja-font: 'Quicksand', sans-serif; 22 | 23 | $pagination-current-border: var(--primary); 24 | $navbar-item-active-color: var(--primary); 25 | 26 | $site-background: var(--grey-100); 27 | 28 | $transition-duration: 150ms; 29 | $transition: $transition-duration ease; 30 | 31 | $button-height: 34px; 32 | $switch-view-height: 2.69rem; 33 | 34 | $navbar-height: 4rem; 35 | $navbar-width: 300px; 36 | $navbar-padding: 2rem; 37 | 38 | $vikunja-nav-color: var(--grey-700); 39 | $vikunja-nav-selected-width: 0.4rem; 40 | 41 | $close-button-min-space: 84px; 42 | -------------------------------------------------------------------------------- /src/styles/components/_index.scss: -------------------------------------------------------------------------------- 1 | @import "tooltip"; 2 | @import "labels"; 3 | @import "project"; 4 | @import "task"; 5 | @import "tasks"; 6 | -------------------------------------------------------------------------------- /src/styles/components/labels.scss: -------------------------------------------------------------------------------- 1 | // FIXME: adapt lables.vue so that it can be used for this aswell 2 | .labels-list { 3 | a, a:hover { 4 | text-decoration: none; 5 | } 6 | 7 | .tag { 8 | margin: .5rem 0 .5rem .5rem; 9 | background: var(--grey-200); 10 | 11 | // FIXME: only used in ListLabels.vue 12 | &.disabled { 13 | opacity: 0.7; 14 | 15 | &, a { 16 | cursor: default; 17 | } 18 | } 19 | } 20 | 21 | .multiselect .tag { 22 | margin: 0 0 0 .5rem; 23 | } 24 | } 25 | 26 | .tasks .task span.tag span { 27 | width: auto; 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/styles/components/task.scss: -------------------------------------------------------------------------------- 1 | // FIXME: should be in TaskDetailView.vue 2 | .link-share-container:not(.has-background) .task-view { 3 | background: transparent; 4 | } -------------------------------------------------------------------------------- /src/styles/components/tooltip.scss: -------------------------------------------------------------------------------- 1 | .v-popper--theme-tooltip .v-popper__inner { 2 | padding: 5px 10px; 3 | font-size: 0.8rem; 4 | background: var(--grey-900); 5 | color: var(--white); 6 | border-radius: 5px; 7 | } 8 | 9 | .dark .v-popper--theme-tooltip .v-popper__inner { 10 | background: var(--white); 11 | color: var(--grey-900); 12 | } -------------------------------------------------------------------------------- /src/styles/custom-properties/_index.scss: -------------------------------------------------------------------------------- 1 | @import "colors"; 2 | @import "shadows"; -------------------------------------------------------------------------------- /src/styles/custom-properties/shadows.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --shadow-xs: 0 1px 3px hsla(var(--grey-500-hsl), .12), 3 | 0 1px 2px hsla(var(--grey-500-hsl), .24); 4 | --shadow-sm: 0 3px 6px hsla(var(--grey-500-hsl), .12), 5 | 0 2px 4px hsla(var(--grey-500-hsl), .10); 6 | --shadow-md: 0 10px 20px hsla(var(--grey-500-hsl), .12), 7 | 0 3px 6px hsla(var(--grey-500-hsl), .08); 8 | --shadow-lg: 0 15px 25px hsla(var(--grey-500-hsl), .12), 9 | 0 5px 10px hsla(var(--grey-500-hsl), .05); 10 | 11 | &.dark { 12 | // Even darker shadows for dark mode 13 | --shadow-xs: 0 1px 3px hsla(var(--grey-50-hsl), 0.4), 14 | 0 1px 2px hsla(var(--grey-50-hsl), 0.8); 15 | --shadow-sm: 0 3px 6px hsla(var(--grey-50-hsl), 0.8), 16 | 0 2px 4px hsla(var(--grey-50-hsl), 0.6); 17 | --shadow-md: 0 10px 20px hsla(var(--grey-50-hsl), 0.8), 18 | 0 3px 6px hsla(var(--grey-50-hsl), 0.6); 19 | --shadow-lg: 0 15px 25px hsla(var(--grey-50-hsl), 0.8), 20 | 0 5px 10px hsla(var(--grey-50-hsl), 0.4); 21 | } 22 | } -------------------------------------------------------------------------------- /src/styles/theme/_index.scss: -------------------------------------------------------------------------------- 1 | @import "scrollbars"; 2 | 3 | @import "theme"; 4 | 5 | @import "background"; 6 | @import "content"; 7 | @import "form"; 8 | @import "link-share"; 9 | @import "loading"; 10 | @import "flatpickr"; 11 | @import 'helpers'; 12 | @import 'navigation'; -------------------------------------------------------------------------------- /src/styles/theme/background.scss: -------------------------------------------------------------------------------- 1 | .app-container.has-background, 2 | .link-share-container.has-background { 3 | position: relative; 4 | 5 | &, .app-container-background { 6 | background-position: center; 7 | background-size: cover; 8 | background-repeat: no-repeat; 9 | background-attachment: fixed; 10 | min-height: 100vh; 11 | } 12 | 13 | // FIXME: move to pagination component 14 | .pagination-link:not(.is-current) { 15 | background: var(--grey-100); 16 | } 17 | 18 | .box, 19 | .card, 20 | .switch-view, 21 | .project-table .button, 22 | .filter-container .button, 23 | .search .button { 24 | box-shadow: none; 25 | } 26 | 27 | .task-view { 28 | border-radius: $radius; 29 | 30 | @media screen and (min-width: $tablet) { 31 | margin-inline: 1rem; 32 | } 33 | } 34 | 35 | .kanban .tasks { 36 | background: transparent; 37 | 38 | .task { 39 | border-radius: $radius !important; 40 | } 41 | } 42 | } 43 | 44 | .app-container-background { 45 | width: 100vw; 46 | height: 100vh; 47 | position: fixed; 48 | z-index: 0; 49 | } 50 | 51 | .background-fade-in { 52 | opacity: 0; 53 | transition: opacity $transition; 54 | transition-delay: $transition-duration * 2; // To fake an appearing background 55 | 56 | &.is-visible { 57 | opacity: 1; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/styles/theme/content.scss: -------------------------------------------------------------------------------- 1 | .content h3 { 2 | .icon, 3 | .is-small { 4 | font-size: 1rem; 5 | } 6 | } 7 | 8 | .table.has-actions { 9 | border-top: 1px solid var(--grey-100); 10 | border-radius: 4px; 11 | overflow: hidden; 12 | 13 | td { 14 | vertical-align: middle; 15 | } 16 | 17 | td.actions { 18 | text-align: right; 19 | } 20 | } 21 | 22 | .content-widescreen { 23 | margin: 0 auto; 24 | max-width: $widescreen; 25 | } 26 | 27 | .content blockquote { 28 | background-color: var(--grey-200); 29 | border-left: .25rem solid var(--grey-300); 30 | } 31 | -------------------------------------------------------------------------------- /src/styles/theme/helpers.scss: -------------------------------------------------------------------------------- 1 | .d-print-none { 2 | @media print { 3 | display: none !important; 4 | } 5 | } -------------------------------------------------------------------------------- /src/styles/theme/link-share.scss: -------------------------------------------------------------------------------- 1 | .field.has-addons.no-input-mobile { 2 | .control:first-child { 3 | width: 100%; 4 | 5 | @media screen and (max-width: $tablet) { 6 | display: none; 7 | } 8 | } 9 | 10 | .button { 11 | height: 40px; 12 | 13 | @media screen and (max-width: $tablet) { 14 | border-radius: $radius !important; 15 | } 16 | } 17 | } 18 | 19 | .link-share-container { 20 | &.project\.gantt-view, 21 | &.project\.kanban-view { 22 | .container { 23 | max-width: 100vw; 24 | 25 | .column { 26 | width: 100%; 27 | margin: 0; 28 | } 29 | } 30 | } 31 | } 32 | 33 | .link-share-container:not(.has-background) { 34 | .list-view { 35 | max-width: 100%; 36 | } 37 | 38 | .loader-container, .gantt-chart-container > .card { 39 | box-shadow: none !important; 40 | border: none; 41 | 42 | .task-add { 43 | padding: 1rem 0 0; 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/styles/theme/loading.scss: -------------------------------------------------------------------------------- 1 | // FIXME: move to loading.vue 2 | .loader-container.is-loading { 3 | position: relative; 4 | pointer-events: none; 5 | opacity: 0.5; 6 | 7 | &::after { 8 | @include loader; 9 | position: absolute; 10 | top: calc(50% - 2.5rem); 11 | left: calc(50% - 2.5rem); 12 | width: 5rem; 13 | height: 5rem; 14 | border-width: 0.25rem; 15 | } 16 | 17 | &.is-loading-small { 18 | &::after { 19 | width: 1.5rem; 20 | height: 1.5rem; 21 | top: calc(50% - .75rem); 22 | left: calc(50% - .75rem); 23 | border-width: 2px; 24 | } 25 | } 26 | } 27 | 28 | // FIXME: move to ShowTasks.vue 29 | .spinner.is-loading { 30 | pointer-events: none; 31 | 32 | &::after { 33 | @include loader; 34 | width: 2rem; 35 | height: 2rem; 36 | margin-left: calc(50% - 1rem); 37 | margin-top: 1rem; 38 | border-width: 0.25rem; 39 | } 40 | } -------------------------------------------------------------------------------- /src/styles/theme/scrollbars.scss: -------------------------------------------------------------------------------- 1 | $scrollbar-height: 8px; 2 | $scrollbar-track-color: var(--grey-200); 3 | $scrollbar-thumb-color: var(--grey-300); 4 | $scrollbar-hover-color: var(--grey-500); 5 | 6 | // Chrome 7 | ::-webkit-scrollbar { 8 | width: $scrollbar-height; 9 | height: $scrollbar-height; 10 | } 11 | 12 | ::-webkit-scrollbar-track { 13 | background: $scrollbar-track-color; 14 | border-radius: .5rem; 15 | } 16 | 17 | ::-webkit-scrollbar-thumb { 18 | border-radius: .5rem; 19 | background: $scrollbar-thumb-color; 20 | transition: all $transition; 21 | 22 | &:hover { 23 | background: $scrollbar-hover-color; 24 | } 25 | } 26 | 27 | // Firefox 28 | * { 29 | scrollbar-color: $scrollbar-thumb-color $scrollbar-track-color; 30 | scrollbar-width: thin; 31 | } 32 | -------------------------------------------------------------------------------- /src/styles/transitions.scss: -------------------------------------------------------------------------------- 1 | .fade-enter-active, 2 | .fade-leave-active { 3 | transition: opacity $transition-duration; 4 | } 5 | 6 | .fade-enter-from, 7 | .fade-leave-to { 8 | opacity: 0; 9 | } 10 | 11 | .width-enter-active, 12 | .width-leave-active { 13 | transition: width $transition-duration; 14 | } 15 | 16 | .width-enter-from, 17 | .width-leave-to { 18 | width: 0; 19 | } -------------------------------------------------------------------------------- /src/types/DateISO.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a date as a string value in ISO format. 3 | * same format as `new Date().toISOString()` 4 | */ 5 | export type DateISO = T 6 | 7 | new Date().toISOString() -------------------------------------------------------------------------------- /src/types/DateKebab.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Date in Format 2022-12-10 3 | */ 4 | export type DateKebab = `${string}-${string}-${string}` 5 | -------------------------------------------------------------------------------- /src/types/IFilter.ts: -------------------------------------------------------------------------------- 1 | export interface IFilter { 2 | sortBy: ('done' | 'id')[] 3 | orderBy: ('asc' | 'desc')[] 4 | filterBy: 'done'[] 5 | filterValue: 'false'[] 6 | filterComparator: 'equals'[] 7 | filterConcat: 'and' 8 | filterIncludeNulls: boolean 9 | } -------------------------------------------------------------------------------- /src/types/IProvider.ts: -------------------------------------------------------------------------------- 1 | export interface IProvider { 2 | name: string; 3 | key: string; 4 | authUrl: string; 5 | clientId: string; 6 | logoutUrl: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/types/IRelationKind.ts: -------------------------------------------------------------------------------- 1 | export enum RELATION_KIND { 2 | 'SUBTASK' = 'subtask', 3 | 'PARENTTASK' = 'parenttask', 4 | 'RELATED' = 'related', 5 | 'DUPLICATES' = 'duplicates', 6 | 'BLOCKING' = 'blocking', 7 | 'BLOCKED' = 'blocked', 8 | 'PROCEDES' = 'precedes', 9 | 'FOLLOWS' = 'follows', 10 | 'COPIEDFROM' = 'copiedfrom', 11 | 'COPIEDTO' = 'copiedto', 12 | } 13 | 14 | export type IRelationKind = typeof RELATION_KIND[keyof typeof RELATION_KIND] 15 | 16 | export const RELATION_KINDS = [...Object.values(RELATION_KIND)] as const -------------------------------------------------------------------------------- /src/types/IReminderPeriodRelativeTo.ts: -------------------------------------------------------------------------------- 1 | export const REMINDER_PERIOD_RELATIVE_TO_TYPES = { 2 | DUEDATE: 'due_date', 3 | STARTDATE: 'start_date', 4 | ENDDATE: 'end_date', 5 | } as const 6 | 7 | export type IReminderPeriodRelativeTo = typeof REMINDER_PERIOD_RELATIVE_TO_TYPES[keyof typeof REMINDER_PERIOD_RELATIVE_TO_TYPES] 8 | 9 | -------------------------------------------------------------------------------- /src/types/IRepeatAfter.ts: -------------------------------------------------------------------------------- 1 | export const REPEAT_TYPES = { 2 | Seconds: 'seconds', 3 | Minutes: 'minutes', 4 | Hours: 'hours', 5 | Days: 'days', 6 | Weeks: 'weeks', 7 | Months: 'months', 8 | Years: 'years', 9 | } as const 10 | 11 | export type IRepeatType = typeof REPEAT_TYPES[keyof typeof REPEAT_TYPES] 12 | 13 | export interface IRepeatAfter { 14 | type: IRepeatType, 15 | amount: number, 16 | } -------------------------------------------------------------------------------- /src/types/IRepeatMode.ts: -------------------------------------------------------------------------------- 1 | export const TASK_REPEAT_MODES = { 2 | 'REPEAT_MODE_DEFAULT': 0, 3 | 'REPEAT_MODE_MONTH': 1, 4 | 'REPEAT_MODE_FROM_CURRENT_DATE': 2, 5 | } as const 6 | 7 | export type IRepeatMode = typeof TASK_REPEAT_MODES[keyof typeof TASK_REPEAT_MODES] 8 | -------------------------------------------------------------------------------- /src/types/PartialWithId.ts: -------------------------------------------------------------------------------- 1 | export type PartialWithId = Pick & Omit, 'id'> -------------------------------------------------------------------------------- /src/types/ProjectView.ts: -------------------------------------------------------------------------------- 1 | export const PROJECT_VIEWS = { 2 | LIST: 'list', 3 | GANTT: 'gantt', 4 | TABLE: 'table', 5 | KANBAN: 'kanban', 6 | } as const 7 | 8 | export type ProjectView = typeof PROJECT_VIEWS[keyof typeof PROJECT_VIEWS] 9 | -------------------------------------------------------------------------------- /src/types/cypress.d.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'cypress/vue' 2 | 3 | type MountParams = Parameters; 4 | type OptionsParam = MountParams[1]; 5 | 6 | declare global { 7 | namespace Cypress { 8 | interface Chainable { 9 | mount: typeof mount; 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/types/global-components.d.ts: -------------------------------------------------------------------------------- 1 | // import FontAwesomeIcon from '@/components/misc/Icon' 2 | import type { FontAwesomeIcon as FontAwesomeIconFixedTypes } from './vue-fontawesome' 3 | import type XButton from '@/components/input/button.vue' 4 | import type Modal from '@/components/misc/modal.vue' 5 | import type Card from '@/components/misc/card.vue' 6 | 7 | // Here we define globally imported components 8 | // See: 9 | // https://github.com/johnsoncodehk/volar/blob/2ca8fd3434423c7bea1c8e08132df3b9ce84eea7/extensions/vscode-vue-language-features/README.md#usage 10 | // Under the hidden collapsible "Define Global Components" 11 | 12 | declare module '@vue/runtime-core' { 13 | export interface GlobalComponents { 14 | Icon: FontAwesomeIconFixedTypes 15 | XButton: typeof XButton, 16 | Modal: typeof Modal, 17 | Card: typeof Card, 18 | } 19 | } 20 | 21 | export {} 22 | -------------------------------------------------------------------------------- /src/types/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/types/vue-fontawesome.ts: -------------------------------------------------------------------------------- 1 | // copied and slightly modified from unmerged pull request that corrects types 2 | // https://github.com/FortAwesome/vue-fontawesome/pull/355 3 | 4 | import type { FaSymbol, FlipProp, IconLookup, IconProp, PullProp, SizeProp, Transform } from '@fortawesome/fontawesome-svg-core' 5 | import type { DefineComponent } from 'vue' 6 | 7 | 8 | interface FontAwesomeIconProps { 9 | border?: boolean 10 | fixedWidth?: boolean 11 | flip?: FlipProp 12 | icon: IconProp 13 | mask?: IconLookup 14 | listItem?: boolean 15 | pull?: PullProp 16 | pulse?: boolean 17 | rotation?: 90 | 180 | 270 | '90' | '180' | '270' 18 | swapOpacity?: boolean 19 | size?: SizeProp 20 | spin?: boolean 21 | transform?: Transform 22 | symbol?: FaSymbol 23 | title?: string | string[] 24 | inverse?: boolean 25 | } 26 | 27 | interface FontAwesomeLayersProps { 28 | fixedWidth?: boolean 29 | } 30 | 31 | interface FontAwesomeLayersTextProps { 32 | value: string | number 33 | transform?: object | string 34 | counter?: boolean 35 | position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' 36 | } 37 | 38 | export type FontAwesomeIcon = DefineComponent 39 | export type FontAwesomeLayers = DefineComponent 40 | export type FontAwesomeLayersText = DefineComponent -------------------------------------------------------------------------------- /src/urls.ts: -------------------------------------------------------------------------------- 1 | export const POWERED_BY = 'https://vikunja.io' 2 | export const CALDAV_DOCS = 'https://vikunja.io/docs/caldav/' 3 | -------------------------------------------------------------------------------- /src/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "VERSION": "dev" 3 | } -------------------------------------------------------------------------------- /src/views/404.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /src/views/About.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 44 | -------------------------------------------------------------------------------- /src/views/filters/FilterDelete.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 28 | -------------------------------------------------------------------------------- /src/views/migrate/icons/ticktick.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/migrate/icons/todoist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/views/migrate/icons/trello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/views/migrate/icons/vikunja-file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/views/migrate/icons/vikunja-file.png -------------------------------------------------------------------------------- /src/views/migrate/icons/wunderlist.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/go-vikunja/frontend/652d3c738438b5fd170cc941bdedae916f99ba09/src/views/migrate/icons/wunderlist.jpg -------------------------------------------------------------------------------- /src/views/migrate/migrators.ts: -------------------------------------------------------------------------------- 1 | import wunderlistIcon from './icons/wunderlist.jpg' 2 | import todoistIcon from './icons/todoist.svg?url' 3 | import trelloIcon from './icons/trello.svg?url' 4 | import microsoftTodoIcon from './icons/microsoft-todo.svg?url' 5 | import vikunjaFileIcon from './icons/vikunja-file.png?url' 6 | import tickTickIcon from './icons/ticktick.svg?url' 7 | 8 | export interface Migrator { 9 | id: string 10 | name: string 11 | isFileMigrator?: boolean 12 | icon: string 13 | } 14 | 15 | interface IMigratorRecord { 16 | [key: Migrator['id']]: Migrator 17 | } 18 | 19 | export const MIGRATORS = { 20 | wunderlist: { 21 | id: 'wunderlist', 22 | name: 'Wunderlist', 23 | icon: wunderlistIcon, 24 | }, 25 | todoist: { 26 | id: 'todoist', 27 | name: 'Todoist', 28 | icon: todoistIcon as string, 29 | }, 30 | trello: { 31 | id: 'trello', 32 | name: 'Trello', 33 | icon: trelloIcon as string, 34 | }, 35 | 'microsoft-todo': { 36 | id: 'microsoft-todo', 37 | name: 'Microsoft Todo', 38 | icon: microsoftTodoIcon as string, 39 | }, 40 | 'vikunja-file': { 41 | id: 'vikunja-file', 42 | name: 'Vikunja Export', 43 | icon: vikunjaFileIcon, 44 | isFileMigrator: true, 45 | }, 46 | ticktick: { 47 | id: 'ticktick', 48 | name: 'TickTick', 49 | icon: tickTickIcon as string, 50 | isFileMigrator: true, 51 | }, 52 | } as const satisfies IMigratorRecord 53 | -------------------------------------------------------------------------------- /src/views/project/ProjectInfo.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 46 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*.d.ts", "src/**/*", "src/**/*.vue", "src/**/*.json"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "baseUrl": ".", 8 | "lib": ["ESNext", "DOM", "WebWorker"], 9 | 10 | "importHelpers": true, 11 | "sourceMap": true, 12 | "strictNullChecks": true, 13 | 14 | "paths": { 15 | "@/*": ["./src/*"] 16 | }, 17 | "types": [ 18 | // https://github.com/ikenfin/vite-plugin-sentry#typescript 19 | "vite-plugin-sentry/client" 20 | ], 21 | "ignoreDeprecations": "5.0" 22 | }, 23 | "vueCompilerOptions": { 24 | // "strictTemplates": true 25 | "jsxTemplates": true 26 | } 27 | } -------------------------------------------------------------------------------- /tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@tsconfig/node18/tsconfig.json", 4 | "@vue/tsconfig/tsconfig.json" 5 | ], 6 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "env.config.d.ts"], 7 | "compilerOptions": { 8 | "composite": true, 9 | "types": ["node"] 10 | } 11 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.config.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | }, 10 | { 11 | "path": "./tsconfig.vitest.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "exclude": [], 4 | "compilerOptions": { 5 | "composite": true, 6 | "lib": [], 7 | "types": ["node"] 8 | } 9 | } --------------------------------------------------------------------------------